From 280eed93bcc9b2350a458625001e1d1a4e9749db Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Mon, 20 Apr 2026 18:21:43 -0400 Subject: [PATCH] feat(cli): add /gsd-sync-skills for cross-runtime managed skill sync (#2491) * fix(tests): update 5 source-text tests to read config-schema.cjs VALID_CONFIG_KEYS moved from config.cjs to config-schema.cjs in the drift-prevention companion PR. Tests that read config.cjs source text and checked for key literal includes() now point to the correct file. Closes #2480 Co-Authored-By: Claude Sonnet 4.6 * feat(cli): add /gsd-sync-skills for cross-runtime managed skill sync (#2380) Adds /gsd-sync-skills command so multi-runtime users can keep gsd-* skill directories aligned across runtime roots after updating one runtime with gsd-update. Changes: - bin/install.js: add --skills-root flag that prints the skills root path for any supported runtime, reusing the existing getGlobalDir() table. Banner is suppressed when --skills-root is used (machine-readable output). - commands/gsd/sync-skills.md: slash command definition - get-shit-done/workflows/sync-skills.md: full workflow spec covering argument parsing, path resolution via --skills-root, diff computation (CREATE/UPDATE/ REMOVE/SKIP), dry-run report (default), apply execution, idempotency guarantee, and safety rules (only gsd-* touched, dry-run performs no writes). Safety rules: only gsd-* directories are ever created/updated/removed; non-GSD skills in destination roots are never touched; --dry-run is the default. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Sonnet 4.6 --- bin/install.js | 15 +- commands/gsd/sync-skills.md | 19 +++ docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 4 +- get-shit-done/workflows/sync-skills.md | 182 ++++++++++++++++++++++ tests/enh-2380-sync-skills.test.cjs | 199 +++++++++++++++++++++++++ 6 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 commands/gsd/sync-skills.md create mode 100644 get-shit-done/workflows/sync-skills.md create mode 100644 tests/enh-2380-sync-skills.test.cjs diff --git a/bin/install.js b/bin/install.js index 0df408e8..23de28ae 100755 --- a/bin/install.js +++ b/bin/install.js @@ -78,6 +78,7 @@ const hasCline = args.includes('--cline'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working const hasAll = args.includes('--all'); const hasUninstall = args.includes('--uninstall') || args.includes('-u'); +const hasSkillsRoot = args.includes('--skills-root'); const hasPortableHooks = args.includes('--portable-hooks') || process.env.GSD_PORTABLE_HOOKS === '1'; const hasSdk = args.includes('--sdk'); const hasNoSdk = args.includes('--no-sdk'); @@ -438,7 +439,7 @@ const explicitConfigDir = parseConfigDirArg(); const hasHelp = args.includes('--help') || args.includes('-h'); const forceStatusline = args.includes('--force-statusline'); -console.log(banner); +if (!hasSkillsRoot) console.log(banner); if (hasUninstall) { console.log(' Mode: Uninstall\n'); @@ -6960,7 +6961,17 @@ if (process.env.GSD_TEST_MODE) { } else { // Main logic - if (hasGlobal && hasLocal) { + if (hasSkillsRoot) { + // Print the skills root directory for a given runtime (used by /gsd-sync-skills). + // Usage: node install.js --skills-root + const runtimeArg = args[args.indexOf('--skills-root') + 1]; + if (!runtimeArg || runtimeArg.startsWith('--')) { + console.error('Usage: node install.js --skills-root '); + process.exit(1); + } + const globalDir = getGlobalDir(runtimeArg, null); + console.log(path.join(globalDir, 'skills')); + } else if (hasGlobal && hasLocal) { console.error(` ${yellow}Cannot specify both --global and --local${reset}`); process.exit(1); } else if (explicitConfigDir && hasLocal) { diff --git a/commands/gsd/sync-skills.md b/commands/gsd/sync-skills.md new file mode 100644 index 00000000..fb1db9e2 --- /dev/null +++ b/commands/gsd/sync-skills.md @@ -0,0 +1,19 @@ +--- +name: gsd:sync-skills +description: Sync managed GSD skills across runtime roots so multi-runtime users stay aligned after an update +allowed-tools: + - Bash + - AskUserQuestion +--- + + +Sync managed `gsd-*` skill directories from one canonical runtime's skills root to one or more destination runtime skills roots. + +Routes to the sync-skills workflow which handles: +- Argument parsing (--from, --to, --dry-run, --apply) +- Runtime skills root resolution via install.js --skills-root +- Diff computation (CREATE / UPDATE / REMOVE per destination) +- Dry-run reporting (default — no writes) +- Apply execution (copy and remove with idempotency) +- Non-GSD skill preservation (only gsd-* dirs are touched) + diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index d9d48494..d9aa053d 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -114,6 +114,7 @@ "/gsd-ui-phase", "/gsd-ui-review", "/gsd-ultraplan-phase", + "/gsd-sync-skills", "/gsd-undo", "/gsd-update", "/gsd-validate-phase", @@ -192,6 +193,7 @@ "spike-wrap-up.md", "spike.md", "stats.md", + "sync-skills.md", "transition.md", "ui-phase.md", "ui-review.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index af9c06bb..211c7d70 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -54,7 +54,7 @@ Full roster at `agents/gsd-*.md`. The "Primary doc" column flags whether [`docs/ --- -## Commands (82 shipped) +## Commands (83 shipped) Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md` section order; each row carries the command name, a one-line role derived from the command's frontmatter `description:`, and a link to the source file. `tests/command-count-sync.test.cjs` locks the count against the filesystem. @@ -165,6 +165,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md | `/gsd-settings` | Configure GSD workflow toggles and model profile. | [commands/gsd/settings.md](../commands/gsd/settings.md) | | `/gsd-set-profile` | Switch model profile for GSD agents (quality/balanced/budget/inherit). | [commands/gsd/set-profile.md](../commands/gsd/set-profile.md) | | `/gsd-pr-branch` | Create a clean PR branch by filtering out `.planning/` commits. | [commands/gsd/pr-branch.md](../commands/gsd/pr-branch.md) | +| `/gsd-sync-skills` | Sync managed GSD skill directories across runtime roots for multi-runtime users. | [commands/gsd/sync-skills.md](../commands/gsd/sync-skills.md) | | `/gsd-update` | Update GSD to latest version with changelog display. | [commands/gsd/update.md](../commands/gsd/update.md) | | `/gsd-reapply-patches` | Reapply local modifications after a GSD update. | [commands/gsd/reapply-patches.md](../commands/gsd/reapply-patches.md) | | `/gsd-help` | Show available GSD commands and usage guide. | [commands/gsd/help.md](../commands/gsd/help.md) | @@ -249,6 +250,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators | `spike.md` | Rapid feasibility validation through focused, throwaway experiments. | `/gsd-spike` | | `spike-wrap-up.md` | Curate spike findings and package them as a persistent `spike-findings-[project]` skill. | `/gsd-spike-wrap-up` | | `stats.md` | Project statistics rendering — phases, plans, requirements, git metrics. | `/gsd-stats` | +| `sync-skills.md` | Cross-runtime GSD skill sync — diff and apply `gsd-*` skill directories across runtime roots. | `/gsd-sync-skills` | | `transition.md` | Phase-boundary transition workflow — workstream checks, state advancement. | `execute-phase.md`, `/gsd-next` | | `ui-phase.md` | Generate UI-SPEC.md design contract via gsd-ui-researcher. | `/gsd-ui-phase` | | `ui-review.md` | Retroactive 6-pillar visual audit via gsd-ui-auditor. | `/gsd-ui-review` | diff --git a/get-shit-done/workflows/sync-skills.md b/get-shit-done/workflows/sync-skills.md new file mode 100644 index 00000000..d22447cf --- /dev/null +++ b/get-shit-done/workflows/sync-skills.md @@ -0,0 +1,182 @@ +# sync-skills — Cross-Runtime GSD Skill Sync + +**Command:** `/gsd-sync-skills` + +Sync managed `gsd-*` skill directories from one canonical runtime's skills root to one or more destination runtime skills roots. Keeps multi-runtime installs aligned after a `gsd-update` on one runtime. + +--- + +## Arguments + +| Flag | Required | Default | Description | +|------|----------|---------|-------------| +| `--from ` | Yes | *(none)* | Source runtime — the canonical runtime to copy from | +| `--to ` | Yes | *(none)* | Destination runtime or `all` supported runtimes | +| `--dry-run` | No | *on by default* | Preview changes without writing anything | +| `--apply` | No | *off* | Execute the diff (overrides dry-run) | + +If neither `--dry-run` nor `--apply` is specified, dry-run is the default. + +**Supported runtime names:** `claude`, `codex`, `copilot`, `cursor`, `windsurf`, `opencode`, `gemini`, `kilo`, `augment`, `trae`, `qwen`, `codebuddy`, `cline`, `antigravity` + +--- + +## Step 1: Parse Arguments + +```bash +FROM_RUNTIME="" +TO_RUNTIMES=() +IS_APPLY=false + +# Parse --from +if [[ "$@" == *"--from"* ]]; then + FROM_RUNTIME=$(echo "$@" | grep -oP '(?<=--from )\S+') +fi + +# Parse --to +if [[ "$@" == *"--to all"* ]]; then + TO_RUNTIMES=(claude codex copilot cursor windsurf opencode gemini kilo augment trae qwen codebuddy cline antigravity) +elif [[ "$@" == *"--to"* ]]; then + TO_RUNTIMES=( $(echo "$@" | grep -oP '(?<=--to )\S+') ) +fi + +# Parse --apply +if [[ "$@" == *"--apply"* ]]; then + IS_APPLY=true +fi +``` + +**Validation:** +- If `--from` is missing or unrecognized: print error and exit +- If `--to` is missing or unrecognized: print error and exit +- If `--from` == `--to` (single destination): print `[no-op: source and destination are the same runtime]` and exit + +--- + +## Step 2: Resolve Skills Roots + +Use `install.js --skills-root` to resolve paths — this reuses the single authoritative path table rather than duplicating it: + +```bash +INSTALL_JS="$(dirname "$0")/../get-shit-done/bin/install.js" +# If running from a global install, resolve relative to the GSD package +INSTALL_JS_GLOBAL="$HOME/.claude/get-shit-done/bin/install.js" +[[ ! -f "$INSTALL_JS" ]] && INSTALL_JS="$INSTALL_JS_GLOBAL" + +SRC_SKILLS_ROOT=$(node "$INSTALL_JS" --skills-root "$FROM_RUNTIME") + +for DEST_RUNTIME in "${TO_RUNTIMES[@]}"; do + DEST_SKILLS_ROOTS["$DEST_RUNTIME"]=$(node "$INSTALL_JS" --skills-root "$DEST_RUNTIME") +done +``` + +**Guard:** If the source skills root does not exist, print: +``` +error: source skills root not found: + Is GSD installed globally for the '' runtime? + Run: node ~/.claude/get-shit-done/bin/install.js --global -- +``` +Then exit. + +**Guard:** If `--to` contains the same runtime as `--from`, skip that destination silently. + +--- + +## Step 3: Compute Diff Per Destination + +For each destination runtime: + +```bash +# List gsd-* subdirectories in source +SRC_SKILLS=$(ls -1 "$SRC_SKILLS_ROOT" 2>/dev/null | grep '^gsd-') + +# List gsd-* subdirectories in destination (may not exist yet) +DST_SKILLS=$(ls -1 "$DEST_ROOT" 2>/dev/null | grep '^gsd-') + +# Diff: +# CREATE — in SRC but not in DST +# UPDATE — in both; content differs (compare recursively via checksums) +# REMOVE — in DST but not in SRC (stale GSD skill no longer in source) +# SKIP — in both; content identical (already up to date) +``` + +**Non-GSD preservation:** Only `gsd-*` entries are ever created, updated, or removed. Entries in the destination that do not start with `gsd-` are never touched. + +--- + +## Step 4: Print Diff Report + +Always print the report, regardless of `--apply` or `--dry-run`: + +``` +sync source: () +sync targets: , + +== () == +CREATE: gsd-help +UPDATE: gsd-update +REMOVE: gsd-old-command +SKIP: gsd-plan-phase (up to date) +(N changes) + +== () == +CREATE: gsd-help +(N changes) + +dry-run only. use --apply to execute. ← omit this line if --apply +``` + +If a destination root does not exist and `--apply` is true, print `CREATE DIR: ` before its entries. + +If all destinations are already up to date: +``` +All destinations are up to date. No changes needed. +``` + +--- + +## Step 5: Execute (only when --apply) + +If `--dry-run` (or no flag): skip this step entirely and exit after printing the report. + +For each destination with changes: + +```bash +mkdir -p "$DEST_ROOT" + +for SKILL in $CREATE_LIST $UPDATE_LIST; do + rm -rf "$DEST_ROOT/$SKILL" + cp -r "$SRC_SKILLS_ROOT/$SKILL" "$DEST_ROOT/$SKILL" +done + +for SKILL in $REMOVE_LIST; do + rm -rf "$DEST_ROOT/$SKILL" +done +``` + +**Idempotency:** Running `--apply` a second time with no intervening changes must report zero changes (all entries are SKIP). + +**Atomicity:** Each skill directory is replaced as a unit (remove then copy). Partial updates of individual files within a skill are not performed — the whole directory is replaced. + +After executing all destinations: + +``` +Sync complete: skills synced to runtime(s). +``` + +--- + +## Safety Rules + +1. **Only `gsd-*` directories** are created, updated, or removed. Any directory not starting with `gsd-` in a destination root is untouched. +2. **Dry-run is the default.** `--apply` must be passed explicitly to write anything. +3. **Source root must exist.** Never create the source root; it must have been created by a prior `gsd-update` or installer run. +4. **No cross-runtime content transformation.** Sync copies files verbatim. It does not apply runtime-specific content transformations (those happen at install time). If a runtime requires transformed content (e.g. Augment's format differs), the developer should run the installer for that runtime instead of using sync. + +--- + +## Limitations + +- Sync copies files verbatim and does not apply runtime-specific content transformations. Use the GSD installer directly for runtimes that require format conversion. +- Cross-project skills (`.agents/skills/`) are out of scope — this command only touches global runtime skills roots. +- Bidirectional sync is not supported. Choose one canonical source with `--from`. diff --git a/tests/enh-2380-sync-skills.test.cjs b/tests/enh-2380-sync-skills.test.cjs new file mode 100644 index 00000000..af64efe9 --- /dev/null +++ b/tests/enh-2380-sync-skills.test.cjs @@ -0,0 +1,199 @@ +'use strict'; + +/** + * Tests for #2380 — /gsd-sync-skills cross-runtime skill sync. + * + * Verifies: + * 1. install.js --skills-root resolves correct paths + * 2. sync-skills.md workflow covers required behavioral specs + * 3. commands/gsd/sync-skills.md slash command exists + * 4. INVENTORY in sync + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const os = require('node:os'); + +const INSTALL_JS = path.join(__dirname, '../bin/install.js'); +const WORKFLOW = path.join(__dirname, '../get-shit-done/workflows/sync-skills.md'); +const COMMAND = path.join(__dirname, '../commands/gsd/sync-skills.md'); + +function readWorkflow() { + return fs.readFileSync(WORKFLOW, 'utf-8'); +} + +// ── install.js --skills-root ────────────────────────────────────────────────── + +describe('install.js --skills-root', () => { + const CASES = [ + { runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') }, + { runtime: 'codex', expected: path.join(os.homedir(), '.codex', 'skills') }, + { runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') }, + { runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') }, + { runtime: 'gemini', expected: path.join(os.homedir(), '.gemini', 'skills') }, + ]; + + for (const { runtime, expected } of CASES) { + test(`resolves correct skills root for ${runtime}`, () => { + const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', runtime], { + encoding: 'utf-8', + env: { ...process.env, GSD_TEST_MODE: undefined }, // ensure not in test mode + }); + // Strip trailing newline + const actual = result.stdout.trim(); + assert.strictEqual(actual, expected, `Expected ${expected}, got ${actual}`); + }); + } + + test('exits non-zero when runtime arg is missing', () => { + const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root'], { + encoding: 'utf-8', + }); + assert.notStrictEqual(result.status, 0, 'Should exit with error when runtime arg is missing'); + }); + + test('returns a path ending in /skills', () => { + const result = spawnSync(process.execPath, [INSTALL_JS, '--skills-root', 'windsurf'], { + encoding: 'utf-8', + }); + assert.ok(result.stdout.trim().endsWith('skills'), 'Skills root must end in /skills'); + }); +}); + +// ── sync-skills.md workflow content ────────────────────────────────────────── + +describe('sync-skills.md — required behavioral specs', () => { + let content; + + test('workflow file exists', () => { + content = readWorkflow(); + assert.ok(content.length > 0, 'sync-skills.md must exist and be non-empty'); + }); + + test('--dry-run is the default (no writes without --apply)', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('dry-run') && (content.includes('default') || content.includes('Default')), + 'workflow must document --dry-run as default' + ); + }); + + test('--apply flag is required to execute writes', () => { + content = content || readWorkflow(); + assert.ok(content.includes('--apply'), 'workflow must document --apply flag'); + }); + + test('--from flag documented', () => { + content = content || readWorkflow(); + assert.ok(content.includes('--from'), 'workflow must document --from flag'); + }); + + test('--to flag documented (runtime|all)', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('--to') && content.includes('all'), + 'workflow must document --to flag with "all" option' + ); + }); + + test('only gsd-* directories are touched (non-GSD preservation)', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('gsd-*') && (content.includes('non-GSD') || content.includes('Non-GSD') || content.includes('not starting with')), + 'workflow must document that only gsd-* dirs are modified' + ); + }); + + test('idempotency documented (second apply = zero changes)', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('dempoten') || content.includes('Idempoten') || content.includes('zero changes') || content.includes('second run'), + 'workflow must document idempotency' + ); + }); + + test('install.js --skills-root is used for path resolution', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('--skills-root'), + 'workflow must reference install.js --skills-root for path resolution' + ); + }); + + test('diff report format: CREATE / UPDATE / REMOVE documented', () => { + content = content || readWorkflow(); + assert.ok(content.includes('CREATE'), 'workflow must document CREATE in diff report'); + assert.ok(content.includes('UPDATE'), 'workflow must document UPDATE in diff report'); + assert.ok(content.includes('REMOVE'), 'workflow must document REMOVE in diff report'); + }); + + test('source-not-found error guidance documented', () => { + content = content || readWorkflow(); + assert.ok( + content.includes('source skills root not found') || content.includes('source root') || content.includes('not found'), + 'workflow must document error when source skills root is missing' + ); + }); + + test('safety rule: dry-run performs no writes', () => { + content = content || readWorkflow(); + const safetySection = content.includes('Safety Rules') || content.includes('safety'); + assert.ok( + safetySection || content.includes('no writes') || content.includes('--dry-run performs no writes'), + 'workflow must have a safety rule that dry-run performs no writes' + ); + }); +}); + +// ── commands/gsd/sync-skills.md ─────────────────────────────────────────────── + +describe('commands/gsd/sync-skills.md', () => { + test('slash command file exists', () => { + assert.ok(fs.existsSync(COMMAND), 'commands/gsd/sync-skills.md must exist'); + }); + + test('has valid frontmatter name field', () => { + const content = fs.readFileSync(COMMAND, 'utf-8'); + assert.ok( + content.includes('name: gsd:sync-skills'), + 'command must have name: gsd:sync-skills in frontmatter' + ); + }); +}); + +// ── INVENTORY sync ──────────────────────────────────────────────────────────── + +describe('INVENTORY sync', () => { + test('INVENTORY.md lists /gsd-sync-skills command', () => { + const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8'); + assert.ok(inventory.includes('/gsd-sync-skills'), 'INVENTORY.md must list /gsd-sync-skills'); + }); + + test('INVENTORY.md lists sync-skills.md workflow', () => { + const inventory = fs.readFileSync(path.join(__dirname, '../docs/INVENTORY.md'), 'utf-8'); + assert.ok(inventory.includes('sync-skills.md'), 'INVENTORY.md must list sync-skills.md workflow'); + }); + + test('INVENTORY-MANIFEST.json includes /gsd-sync-skills', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8') + ); + assert.ok( + manifest.families.commands.includes('/gsd-sync-skills'), + 'INVENTORY-MANIFEST.json must include /gsd-sync-skills in commands' + ); + }); + + test('INVENTORY-MANIFEST.json includes sync-skills.md', () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, '../docs/INVENTORY-MANIFEST.json'), 'utf-8') + ); + assert.ok( + manifest.families.workflows.includes('sync-skills.md'), + 'INVENTORY-MANIFEST.json must include sync-skills.md in workflows' + ); + }); +});