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 <noreply@anthropic.com>

* 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 <runtime> 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-04-20 18:21:43 -04:00
committed by GitHub
parent b432d4a726
commit 280eed93bc
6 changed files with 418 additions and 3 deletions

View File

@@ -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 <runtime>
const runtimeArg = args[args.indexOf('--skills-root') + 1];
if (!runtimeArg || runtimeArg.startsWith('--')) {
console.error('Usage: node install.js --skills-root <runtime>');
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) {

View File

@@ -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
---
<objective>
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)
</objective>

View File

@@ -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",

View File

@@ -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` |

View File

@@ -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 <runtime>` | Yes | *(none)* | Source runtime — the canonical runtime to copy from |
| `--to <runtime\|all>` | 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: <path>
Is GSD installed globally for the '<runtime>' runtime?
Run: node ~/.claude/get-shit-done/bin/install.js --global --<runtime>
```
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: <runtime> (<src_skills_root>)
sync targets: <dest1>, <dest2>
== <dest1> (<dest1_skills_root>) ==
CREATE: gsd-help
UPDATE: gsd-update
REMOVE: gsd-old-command
SKIP: gsd-plan-phase (up to date)
(N changes)
== <dest2> (<dest2_skills_root>) ==
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: <path>` 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: <N> skills synced to <M> 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`.

View File

@@ -0,0 +1,199 @@
'use strict';
/**
* Tests for #2380 — /gsd-sync-skills cross-runtime skill sync.
*
* Verifies:
* 1. install.js --skills-root <runtime> 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'
);
});
});