Files
AgentSkills/skills_linker.bat

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