425 lines
13 KiB
Batchfile
425 lines
13 KiB
Batchfile
@echo off
|
|
rem ===========================================================================
|
|
rem AgentSkills - skills linker for Windows (cmd / .bat version)
|
|
rem ---------------------------------------------------------------------------
|
|
rem This file is deliberately PURE ASCII, UTF-8/ANSI agnostic, CRLF endings.
|
|
rem
|
|
rem Why: cmd.exe decodes a batch file with the *current console code page*.
|
|
rem A 936 (Simplified-Chinese / Explorer double click) console and a 65001
|
|
rem (PowerShell 7) console decode the same non-ASCII bytes differently, and
|
|
rem switching the code page inside a batch file (chcp) can desynchronise cmd's
|
|
rem parser so that it executes comment text. A pure ASCII file has no such
|
|
rem ambiguity: every byte means the same thing under every code page, and the
|
|
rem output renders identically everywhere. Keep it ASCII when editing.
|
|
rem ===========================================================================
|
|
setlocal EnableExtensions DisableDelayedExpansion
|
|
|
|
rem ===========================================================================
|
|
rem AgentSkills skills linker (Windows) - the counterpart of skills_linker.sh
|
|
rem
|
|
rem What it does: scans <repo root>\<category>\<skill> two levels below this
|
|
rem script and links every skill directory into the global skills directory
|
|
rem (default %USERPROFILE%\.agents\skills) so every project can use them.
|
|
rem How it links: a directory junction first (no admin rights, no developer
|
|
rem mode needed), falling back to a directory symlink (mklink /D). Use
|
|
rem /c (copy mode) when links are not usable, e.g. on a network path.
|
|
rem Safety: it only adds or removes the links themselves inside the target
|
|
rem directory - it never enters or deletes the content behind a link, and it
|
|
rem never overwrites a real directory that happens to share a skill name.
|
|
rem ===========================================================================
|
|
|
|
set "EXITCODE=0"
|
|
set "ROOT=%~dp0"
|
|
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
|
|
if "%ROOT:~-1%"==":" set "ROOT=%ROOT%\"
|
|
set "TARGET="
|
|
set "MODE=link"
|
|
set "DRYRUN=0"
|
|
set "PAUSE_AT_END=0"
|
|
set "TMPLIST="
|
|
set /a N_LINKED=0 >nul
|
|
set /a N_REPLACED=0 >nul
|
|
set /a N_REMOVED=0 >nul
|
|
set /a N_COPIED=0 >nul
|
|
set /a N_SKIPPED=0 >nul
|
|
set /a N_WARN=0 >nul
|
|
set /a N_FAIL=0 >nul
|
|
|
|
rem A double click starts us as: cmd /c ""<script>" " (no arguments, the path
|
|
rem wrapped in doubled quotes). Only that case waits for a key at the end, so
|
|
rem windows opened by Explorer do not vanish; every other call stays scriptable.
|
|
if not "%~1"=="" goto pause_checked
|
|
echo(%cmdcmdline% | findstr /c:"\"\"" >nul 2>&1 && set "PAUSE_AT_END=1"
|
|
:pause_checked
|
|
|
|
rem ------------------------------ parse arguments ------------------------------
|
|
:parse
|
|
if "%~1"=="" goto parsed
|
|
set "ARG=%~1"
|
|
if /i "%ARG%"=="/?" goto usage
|
|
if /i "%ARG%"=="-?" goto usage
|
|
if /i "%ARG%"=="/h" goto usage
|
|
if /i "%ARG%"=="-h" goto usage
|
|
if /i "%ARG%"=="--help" goto usage
|
|
if /i "%ARG%"=="/l" goto arg_shift
|
|
if /i "%ARG%"=="-l" goto arg_shift
|
|
if /i "%ARG%"=="--link" goto arg_shift
|
|
if /i "%ARG%"=="/c" goto opt_copy
|
|
if /i "%ARG%"=="-c" goto opt_copy
|
|
if /i "%ARG%"=="--copy" goto opt_copy
|
|
if /i "%ARG%"=="/u" goto opt_unlink
|
|
if /i "%ARG%"=="-u" goto opt_unlink
|
|
if /i "%ARG%"=="--unlink" goto opt_unlink
|
|
if /i "%ARG%"=="/n" goto opt_dry
|
|
if /i "%ARG%"=="-n" goto opt_dry
|
|
if /i "%ARG%"=="--dry-run" goto opt_dry
|
|
if /i "%ARG%"=="/t" goto opt_target
|
|
if /i "%ARG%"=="-t" goto opt_target
|
|
if /i "%ARG%"=="--target" goto opt_target
|
|
echo [FAIL] unknown option: "%ARG%"
|
|
call :show_usage
|
|
set "EXITCODE=2"
|
|
goto finish
|
|
|
|
:opt_copy
|
|
set "MODE=copy"
|
|
goto arg_shift
|
|
:opt_unlink
|
|
set "MODE=unlink"
|
|
goto arg_shift
|
|
:opt_dry
|
|
set "DRYRUN=1"
|
|
goto arg_shift
|
|
:opt_target
|
|
set "V=%~2"
|
|
if "%V%"=="" goto err_value
|
|
if "%V:~0,1%"=="/" goto err_value
|
|
if "%V:~0,1%"=="-" goto err_value
|
|
set "TARGET=%V%"
|
|
shift
|
|
goto arg_shift
|
|
:err_value
|
|
echo [FAIL] option %ARG% needs a directory value
|
|
call :show_usage
|
|
set "EXITCODE=2"
|
|
goto finish
|
|
:arg_shift
|
|
shift
|
|
goto parse
|
|
|
|
:parsed
|
|
if defined TARGET goto have_target
|
|
if not defined USERPROFILE goto err_nohome
|
|
set "TARGET=%USERPROFILE%\.agents\skills"
|
|
:have_target
|
|
for %%A in ("%TARGET%") do set "TARGET=%%~fA"
|
|
|
|
rem ------------------------------ banner ------------------------------
|
|
echo(
|
|
echo == AgentSkills skills linker ==
|
|
echo( repo root : "%ROOT%"
|
|
echo( target : "%TARGET%"
|
|
if /i "%MODE%"=="link" echo( mode : link (junction, falls back to a symlink)
|
|
if /i "%MODE%"=="copy" echo( mode : copy (real copies, no links)
|
|
if /i "%MODE%"=="unlink" echo( mode : unlink (removes only links into this repo)
|
|
if "%DRYRUN%"=="1" echo( dry run : yes (nothing is written or deleted)
|
|
echo(
|
|
|
|
rem ------------------------------ prepare target ------------------------------
|
|
if /i "%MODE%"=="unlink" goto pre_unlink
|
|
if "%DRYRUN%"=="1" goto scan
|
|
if exist "%TARGET%\" goto scan
|
|
mkdir "%TARGET%" >nul 2>&1
|
|
if not exist "%TARGET%\" goto fail_target
|
|
echo [DIR] created target directory "%TARGET%"
|
|
goto scan
|
|
|
|
:pre_unlink
|
|
if not exist "%TARGET%\" goto no_target
|
|
goto scan
|
|
|
|
:no_target
|
|
echo nothing to unlink: target directory "%TARGET%" does not exist
|
|
goto summary
|
|
|
|
rem ------------------------------ scan and process ------------------------------
|
|
:scan
|
|
if not defined TEMP set "TEMP=%TMP%"
|
|
if defined TEMP set "TMPLIST=%TEMP%\agentskills_seen_%RANDOM%%RANDOM%.txt"
|
|
if not defined TMPLIST set "TMPLIST=%ROOT%\agentskills_seen_%RANDOM%.txt"
|
|
type nul > "%TMPLIST%" 2>nul
|
|
for /f "delims=" %%C in ('dir /b /ad /on "%ROOT%" 2^>nul') do (
|
|
set "CAT=%%C"
|
|
call :each_category
|
|
)
|
|
goto summary
|
|
|
|
rem ------------------------------ summary ------------------------------
|
|
:summary
|
|
if defined TMPLIST del /f /q "%TMPLIST%" >nul 2>&1
|
|
echo(
|
|
echo ------------------------------ summary ------------------------------
|
|
echo( new links : %N_LINKED%
|
|
echo( replaced : %N_REPLACED%
|
|
echo( copied : %N_COPIED%
|
|
echo( removed : %N_REMOVED%
|
|
echo( skipped : %N_SKIPPED%
|
|
echo( warnings : %N_WARN%
|
|
echo( failures : %N_FAIL%
|
|
echo(
|
|
if "%DRYRUN%"=="1" echo (dry run: the lines above are the plan, nothing was changed)
|
|
if %N_FAIL% gtr 0 set "EXITCODE=1"
|
|
if %N_WARN% gtr 0 if "%EXITCODE%"=="0" set "EXITCODE=2"
|
|
goto finish
|
|
|
|
:usage
|
|
call :show_usage
|
|
set "EXITCODE=0"
|
|
goto finish
|
|
|
|
rem ------------------------------ cleanup ------------------------------
|
|
:finish
|
|
if defined TMPLIST del /f /q "%TMPLIST%" >nul 2>&1
|
|
if "%PAUSE_AT_END%"=="1" pause
|
|
endlocal & exit /b %EXITCODE%
|
|
|
|
:err_nohome
|
|
echo [FAIL] environment variable USERPROFILE is not set, use /t to name a target
|
|
set "EXITCODE=2"
|
|
goto finish
|
|
|
|
rem ============================== subroutines ==============================
|
|
|
|
rem walk the skill directories of one category directory
|
|
:each_category
|
|
if "%CAT:~0,1%"=="." goto :eof
|
|
for /f "delims=" %%S in ('dir /b /ad /on "%ROOT%\%CAT%" 2^>nul') do (
|
|
set "SKILL=%%S"
|
|
call :each_skill
|
|
)
|
|
goto :eof
|
|
|
|
rem process one skill directory
|
|
:each_skill
|
|
if "%SKILL:~0,1%"=="." goto :eof
|
|
set "NAME=%SKILL%"
|
|
set "SRC=%ROOT%\%CAT%\%NAME%"
|
|
set "DEST=%TARGET%\%NAME%"
|
|
|
|
rem skill names must be unique; the first category scanned (alphabetical) wins
|
|
findstr /x /i /c:"\"%NAME%\"" "%TMPLIST%" >nul 2>&1
|
|
if not errorlevel 1 goto warn_dup
|
|
>>"%TMPLIST%" echo("%NAME%"
|
|
|
|
if not exist "%SRC%\SKILL.md" echo [NOTE] no SKILL.md inside "%SRC%", treated as a skill directory anyway
|
|
|
|
if /i "%MODE%"=="unlink" goto entry_unlink
|
|
if /i "%MODE%"=="copy" goto entry_copy
|
|
goto entry_link
|
|
|
|
rem ------------------------------ link mode ------------------------------
|
|
:entry_link
|
|
if not exist "%DEST%" goto link_new
|
|
if not exist "%DEST%\" goto warn_file
|
|
call :resolve_dest
|
|
if not defined CURTARGET goto warn_dir
|
|
call :same_source
|
|
if "%SAME%"=="1" goto skip_same
|
|
echo [REPL] "%DEST%" (was "%CURTARGET%") -^> "%SRC%"
|
|
set /a N_REPLACED+=1 >nul
|
|
set "REPLACING=1"
|
|
if "%DRYRUN%"=="1" goto :eof
|
|
call :remove_link
|
|
if "%OK%"=="0" goto fail_remove
|
|
:link_new
|
|
if "%DRYRUN%"=="1" goto link_done
|
|
call :make_link
|
|
if "%OK%"=="0" goto fail_link
|
|
:link_done
|
|
if defined REPLACING goto :eof
|
|
set /a N_LINKED+=1 >nul
|
|
echo [NEW] "%SRC%" -^> "%DEST%"
|
|
goto :eof
|
|
|
|
rem ------------------------------ copy mode ------------------------------
|
|
:entry_copy
|
|
if not exist "%DEST%" goto copy_do
|
|
if not exist "%DEST%\" goto warn_file
|
|
call :resolve_dest
|
|
if not defined CURTARGET goto copy_update
|
|
echo [REPL] "%DEST%" is a link, copying a real directory instead
|
|
set /a N_REPLACED+=1 >nul
|
|
if "%DRYRUN%"=="1" goto :eof
|
|
call :remove_link
|
|
if "%OK%"=="0" goto fail_remove
|
|
goto copy_do
|
|
:copy_update
|
|
echo [SYNC] updating the existing directory "%DEST%"
|
|
:copy_do
|
|
if "%DRYRUN%"=="1" goto copy_done
|
|
robocopy "%SRC%" "%DEST%" /E /NFL /NDL /NJH /NJS /NP /R:1 /W:1 >nul 2>&1
|
|
if errorlevel 8 goto fail_copy
|
|
:copy_done
|
|
set /a N_COPIED+=1 >nul
|
|
echo [COPY] "%SRC%" -^> "%DEST%"
|
|
goto :eof
|
|
|
|
rem ------------------------------ unlink mode ------------------------------
|
|
:entry_unlink
|
|
if not exist "%DEST%" goto :eof
|
|
if not exist "%DEST%\" goto skip_unlink_file
|
|
call :resolve_dest
|
|
if not defined CURTARGET goto skip_unlink_dir
|
|
call :in_repo
|
|
if "%INREPO%"=="0" goto skip_unlink_other
|
|
echo [DEL] "%DEST%" (was "%CURTARGET%")
|
|
set /a N_REMOVED+=1 >nul
|
|
if "%DRYRUN%"=="1" goto :eof
|
|
call :remove_link
|
|
if "%OK%"=="0" goto fail_remove
|
|
goto :eof
|
|
|
|
rem ------------------------------ helpers ------------------------------
|
|
|
|
rem read the link target of %DEST% (junctions and symlinks); stays empty for a real directory
|
|
:resolve_dest
|
|
set "CURTARGET="
|
|
if not exist "%TARGET%\" goto :eof
|
|
for /f "tokens=2 delims=[]" %%T in ('dir /a:l "%TARGET%" 2^>nul ^| findstr /i /c:" %NAME% ["') do set "CURTARGET=%%T"
|
|
goto :eof
|
|
|
|
rem does %CURTARGET% denote the same directory as %SRC%? compares long and 8.3
|
|
rem names, case-insensitively, so re-runs stay idempotent
|
|
:same_source
|
|
set "SAME=0"
|
|
set "A=%CURTARGET%"
|
|
set "B=%SRC%"
|
|
if "%A%"=="" goto :eof
|
|
if "%A:~0,4%"=="\??\" set "A=%A:~4%"
|
|
if /i "%A%"=="%B%" set "SAME=1"
|
|
for %%X in ("%A%") do set "A2=%%~fsX"
|
|
for %%Y in ("%B%") do set "B2=%%~fsY"
|
|
if /i "%A2%"=="%B2%" set "SAME=1"
|
|
goto :eof
|
|
|
|
rem is the link target inside this repository?
|
|
:in_repo
|
|
set "INREPO=0"
|
|
set "P=%ROOT%\"
|
|
call set "REST=%%CURTARGET:%P%=%%"
|
|
if not "%REST%"=="%CURTARGET%" set "INREPO=1"
|
|
goto :eof
|
|
|
|
rem create the link: junction first, symlink as fallback, clearing stale entries
|
|
:make_link
|
|
set "OK=0"
|
|
call :mk_verify J
|
|
if "%OK%"=="1" goto :eof
|
|
call :remove_link
|
|
set "OK=0"
|
|
call :mk_verify J
|
|
if "%OK%"=="1" goto :eof
|
|
call :mk_verify D
|
|
goto :eof
|
|
|
|
rem run one mklink and verify that the link really resolves to %SRC%
|
|
:mk_verify
|
|
mklink /%1 "%DEST%" "%SRC%" >nul 2>&1
|
|
call :resolve_dest
|
|
if not defined CURTARGET goto :eof
|
|
call :same_source
|
|
if "%SAME%"=="1" set "OK=1"
|
|
goto :eof
|
|
|
|
rem delete the link itself: rd without /s never recurses into the link target
|
|
:remove_link
|
|
set "OK=0"
|
|
rd "%DEST%" >nul 2>&1
|
|
if exist "%DEST%\" goto :eof
|
|
if exist "%DEST%" del /f /q "%DEST%" >nul 2>&1
|
|
if exist "%DEST%" goto :eof
|
|
set "OK=1"
|
|
goto :eof
|
|
|
|
rem ------------------------------ notices ------------------------------
|
|
:warn_dup
|
|
echo [WARN] duplicate skill name, skipped "%ROOT%\%CAT%\%NAME%" (the first category scanned wins)
|
|
set /a N_WARN+=1 >nul
|
|
goto :eof
|
|
|
|
:warn_file
|
|
echo [WARN] a plain file already exists at "%DEST%", skipped
|
|
set /a N_WARN+=1 >nul
|
|
goto :eof
|
|
|
|
:warn_dir
|
|
echo [WARN] a real directory (not a link) already exists at "%DEST%", skipped - resolve it manually
|
|
set /a N_WARN+=1 >nul
|
|
goto :eof
|
|
|
|
:skip_same
|
|
set /a N_SKIPPED+=1 >nul
|
|
echo [SKIP] already linked: "%DEST%"
|
|
goto :eof
|
|
|
|
:skip_unlink_file
|
|
echo [SKIP] plain file, left untouched: "%DEST%"
|
|
set /a N_SKIPPED+=1 >nul
|
|
goto :eof
|
|
|
|
:skip_unlink_dir
|
|
echo [SKIP] real directory, left untouched: "%DEST%"
|
|
set /a N_SKIPPED+=1 >nul
|
|
goto :eof
|
|
|
|
:skip_unlink_other
|
|
echo [SKIP] link points outside this repository, left untouched: "%DEST%" -^> "%CURTARGET%"
|
|
set /a N_SKIPPED+=1 >nul
|
|
goto :eof
|
|
|
|
:fail_target
|
|
echo [FAIL] cannot create target directory "%TARGET%"
|
|
set /a N_FAIL+=1 >nul
|
|
goto summary
|
|
|
|
:fail_remove
|
|
echo [FAIL] cannot remove the existing entry "%DEST%"
|
|
set /a N_FAIL+=1 >nul
|
|
goto :eof
|
|
|
|
:fail_link
|
|
echo [FAIL] cannot create the link "%DEST%"
|
|
echo try /c (copy mode); network paths only work with copy mode.
|
|
set /a N_FAIL+=1 >nul
|
|
goto :eof
|
|
|
|
:fail_copy
|
|
echo [FAIL] copy failed: "%DEST%"
|
|
set /a N_FAIL+=1 >nul
|
|
goto :eof
|
|
|
|
rem ------------------------------ help ------------------------------
|
|
:show_usage
|
|
echo(
|
|
echo AgentSkills skills linker for Windows - the counterpart of skills_linker.sh
|
|
echo(
|
|
echo usage: %~nx0 [options]
|
|
echo(
|
|
echo /l /link link mode (default): junction, symlink as fallback
|
|
echo /c /copy copy mode: place real copies (use when links are unusable)
|
|
echo /u /unlink unlink mode: remove only links pointing into this repo
|
|
echo /t /target DIR target directory (default %%USERPROFILE%%\.agents\skills)
|
|
echo /n /dry-run dry run: print the plan, write and delete nothing
|
|
echo /? /help show this help
|
|
echo(
|
|
echo notes:
|
|
echo * repo root = the directory holding this script; it scans the two levels
|
|
echo "category\skill" below it. Directories starting with a dot are skipped.
|
|
echo * already linked to the same skill -^> skip; linked elsewhere -^> replace;
|
|
echo a real directory with the same name -^> skip and warn (never overwritten).
|
|
echo * duplicate skill names keep the first category scanned and count as warnings.
|
|
echo * a double click waits for a key at the end; calls with arguments or
|
|
echo redirected input never wait, so the script stays automation friendly.
|
|
echo * exit codes: 0 all good, 1 failures, 2 warnings (name conflict or duplicate).
|
|
goto :eof
|