From 5e852611e31a66300cd5c19fa1844f7a26ec1a1f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 14:53:32 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(#786):=20elevate=20GitHub=20Copilot=20?= =?UTF-8?q?installer=20=E2=80=94=20lifecycle=20hook=20+=20AGENTS.md=20(#80?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#786): elevate Copilot installer with lifecycle hook + AGENTS.md Emit a self-contained sessionStart hook config (.github/hooks/gsd-session.json local, ~/.copilot/hooks/gsd-session.json global) and write AGENTS.md at the repo root (Copilot CLI reads it as primary instructions) alongside copilot-instructions.md. The hook is an inline `command` hook (no separate hook script), so it cannot dangle. Uninstall removes both and preserves user content. Verified against GitHub Copilot CLI primary docs: hooks-configuration (camelCase events, version+hooks shape, inline bash/powershell command hooks) and add-custom-instructions (AGENTS.md read at repo root as primary instructions). Co-Authored-By: Claude Opus 4.8 * chore(#786): set changeset pr number to 804 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/786-copilot-hooks-agents.md | 5 + bin/install.js | 133 ++++++++++++++- docs/ARCHITECTURE.md | 2 +- docs/how-to/install-on-your-runtime.md | 7 + tests/copilot-install.test.cjs | 220 +++++++++++++++++++++++++ 5 files changed, 365 insertions(+), 2 deletions(-) create mode 100644 .changeset/786-copilot-hooks-agents.md diff --git a/.changeset/786-copilot-hooks-agents.md b/.changeset/786-copilot-hooks-agents.md new file mode 100644 index 000000000..24271c046 --- /dev/null +++ b/.changeset/786-copilot-hooks-agents.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 804 +--- +The GitHub Copilot installer now reaches lifecycle-hook and instruction parity with other first-class runtimes. It emits a self-contained `sessionStart` hook config (`.github/hooks/gsd-session.json` for local installs, `~/.copilot/hooks/gsd-session.json` for global) and writes `AGENTS.md` at the repository root (which Copilot CLI reads as primary instructions) alongside `copilot-instructions.md`. The hook is an inline `command` hook with no separate script file, so it cannot dangle. Both artifacts are removed — with user-authored content preserved — on `--uninstall`. (#786) diff --git a/bin/install.js b/bin/install.js index d3e660915..1439003aa 100755 --- a/bin/install.js +++ b/bin/install.js @@ -101,6 +101,32 @@ function isCodexHooksFeatureKey(key) { const GSD_COPILOT_INSTRUCTIONS_MARKER = ''; const GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER = ''; +// #786 \u2014 GitHub Copilot CLI lifecycle hook constants. +// Copilot reads hook configs from /hooks/*.json (repo scope: .github/hooks/, +// user scope: ~/.copilot/hooks/) with the shape { version, hooks: { : [...] } }. +// Events use camelCase (sessionStart, preToolUse, postToolUse, ...). A `command` +// hook runs an INLINE shell command (bash / powershell), so the GSD hook is fully +// self-contained \u2014 there is no separate hook script to install, and therefore +// nothing that can dangle if a script copy is skipped. See +// https://docs.github.com/en/copilot/reference/hooks-configuration +const GSD_COPILOT_HOOK_FILE = 'gsd-session.json'; +// Copilot parses a command hook's stdout as the hook-output JSON. For sessionStart +// the schema is `{ additionalContext?: string }` (the text is prepended to the +// session as context). So the hook must emit that JSON envelope — not bare text. +// The two messages contain no JSON-special characters, so they embed verbatim. +const GSD_COPILOT_SESSION_MSG_PRESENT = + 'GSD: .planning/STATE.md present - review the current phase and any blockers before acting.'; +const GSD_COPILOT_SESSION_MSG_ABSENT = + 'GSD: no .planning/ workflow found - run /gsd-new-project to start a tracked workflow.'; +const GSD_COPILOT_SESSION_HOOK_BASH = + 'if [ -f .planning/STATE.md ]; then ' + + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}'; else ` + + `printf '%s' '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}'; fi`; +const GSD_COPILOT_SESSION_HOOK_PWSH = + 'if (Test-Path .planning/STATE.md) ' + + `{ '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_PRESENT}"}' } ` + + `else { '{"additionalContext":"${GSD_COPILOT_SESSION_MSG_ABSENT}"}' }`; + // GSD-managed files under hooks/lib/ (helpers required by gsd-*.sh hooks). // git-cmd.js does not start with "gsd-" (shared classifier for #3129), gsd-graphify-rebuild.sh does. const GSD_HOOK_LIB_FILES = ['git-cmd.js', 'gsd-graphify-rebuild.sh']; @@ -5015,6 +5041,57 @@ function stripGsdFromCopilotInstructions(content) { return content; } +/** + * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object. + * + * Returns the verbatim JSON shape Copilot CLI expects: + * { version: 1, hooks: { sessionStart: [ ] } } + * + * The sessionStart entry is a `command` hook whose `bash`/`powershell` bodies + * run inline (no external script file), so the config can never reference a + * hook script that the installer did not also install — it is self-contained + * by construction. The command is advisory-only (always exits 0) and orients + * the agent toward the project's GSD planning state at session start. + * + * @returns {object} Copilot hooks-configuration object + */ +function buildCopilotHookConfig() { + return { + version: 1, + hooks: { + sessionStart: [ + { + type: 'command', + bash: GSD_COPILOT_SESSION_HOOK_BASH, + powershell: GSD_COPILOT_SESSION_HOOK_PWSH, + timeoutSec: 10, + }, + ], + }, + }; +} + +/** + * #786 — Write the GSD-managed Copilot lifecycle hook config under the runtime + * config dir (`/hooks/gsd-session.json`). For local installs + * targetDir is `.github` (→ `.github/hooks/`); for global installs it is + * `~/.copilot` (→ `~/.copilot/hooks/`) — both are valid Copilot hook locations. + * + * The managed file is fully owned by GSD, so it is overwritten wholesale on + * every install (idempotent). User-authored sibling `*.json` hook files in the + * same directory are untouched. + * + * @param {string} targetDir - The Copilot config dir + * @returns {string} The path the hook config was written to + */ +function writeCopilotHookConfig(targetDir) { + const hooksDir = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, GSD_COPILOT_HOOK_FILE); + fs.writeFileSync(hookPath, JSON.stringify(buildCopilotHookConfig(), null, 2) + '\n'); + return hookPath; +} + /** * Generate config.toml and per-agent .toml files for Codex. * Reads agent .md files from source, extracts metadata, writes .toml configs. @@ -6822,6 +6899,24 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); + // #786: AGENTS.md lives at the repo root (outside targetDir) for local Copilot + // installs, so its cleanup must run even when .github (targetDir) was already + // removed — i.e. BEFORE the "target directory missing" early-return below. + if (isCopilot && !isGlobal) { + const agentsMdPath = path.join(process.cwd(), 'AGENTS.md'); + if (fs.existsSync(agentsMdPath)) { + const content = fs.readFileSync(agentsMdPath, 'utf8'); + const cleaned = stripGsdFromCopilotInstructions(content); + if (cleaned === null) { + fs.unlinkSync(agentsMdPath); + console.log(` ${green}✓${reset} Removed AGENTS.md (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(agentsMdPath, cleaned); + console.log(` ${green}✓${reset} Cleaned GSD section from AGENTS.md`); + } + } + } + // Check if target directory exists if (!fs.existsSync(targetDir)) { console.log(` ${yellow}⚠${reset} Directory does not exist: ${locationLabel}`); @@ -6899,6 +6994,23 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`); } } + + // #786: remove the GSD-managed Copilot lifecycle hook config and prune the + // hooks dir if we left it empty. + const hookPath = path.join(targetDir, 'hooks', GSD_COPILOT_HOOK_FILE); + if (fs.existsSync(hookPath)) { + fs.unlinkSync(hookPath); + removedCount++; + console.log(` ${green}✓${reset} Removed Copilot lifecycle hook (${GSD_COPILOT_HOOK_FILE})`); + try { + const hooksDir = path.join(targetDir, 'hooks'); + if (fs.existsSync(hooksDir) && fs.readdirSync(hooksDir).length === 0) { + fs.rmdirSync(hooksDir); + } + } catch { /* non-fatal: leave a non-empty/locked hooks dir in place */ } + } + // Note: AGENTS.md (repo root) is cleaned earlier, before the targetDir + // existence early-return, since it lives outside targetDir (#786). } // 1c. Claude local: remove commands/gsd/ (primary local install location). @@ -9349,8 +9461,24 @@ function install(isGlobal, runtime = 'claude', options = {}) { const template = fs.readFileSync(templatePath, 'utf8'); mergeCopilotInstructions(instructionsPath, template); console.log(` ${green}✓${reset} Generated copilot-instructions.md`); + // #786: also emit AGENTS.md, which Copilot CLI reads as primary + // instructions from the repository root. AGENTS.md is a repo-root concept + // (no documented user-scope home), so emit it only for local installs; + // global scope is already covered by ~/.copilot/copilot-instructions.md. + if (!isGlobal) { + const agentsMdPath = path.join(process.cwd(), 'AGENTS.md'); + mergeCopilotInstructions(agentsMdPath, template); + console.log(` ${green}✓${reset} Generated AGENTS.md`); + } } - // Copilot: no settings.json, no hooks, no statusline (like Codex) + // #786: emit a self-contained Copilot lifecycle hook (sessionStart). Copilot + // command hooks run inline bash/powershell, so this needs no separate hook + // script and cannot dangle. Repo scope → .github/hooks/, user → ~/.copilot/hooks/. + // The hook is a required install artifact, so a write failure is fatal (it + // propagates) rather than silently producing a "successful" install missing + // the feature. + writeCopilotHookConfig(targetDir); + console.log(` ${green}✓${reset} Configured Copilot lifecycle hook (sessionStart)`); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -10814,6 +10942,9 @@ module.exports = { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + GSD_COPILOT_HOOK_FILE, + buildCopilotHookConfig, + writeCopilotHookConfig, convertClaudeToAntigravityContent, convertClaudeCommandToAntigravitySkill, convertClaudeAgentToAntigravityAgent, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 48380d3b2..46400e0be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -800,7 +800,7 @@ The migration-specific ownership and source snapshots live in | Kilo | `~/.config/kilo` | `./.kilo` | `command/gsd-*.md` | `agents/gsd-*.md` | `kilo.json` or `kilo.jsonc`; no GSD hooks | | Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` | `agents/` source markdown plus per-agent TOML | `config.toml` `[agents.gsd-*]`, `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | -| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` and `copilot-instructions.md` | `.agent.md` files | No GSD hooks or statusline | +| GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md`, `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | | Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | | Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | | Windsurf | `~/.codeium/windsurf` | `./.windsurf` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index e7f3ccb1b..d5de73f47 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -156,6 +156,13 @@ npx @opengsd/gsd-core@latest --copilot --global Skills land in `~/.copilot/`. GSD installs as agent `.md` files and repository instruction files. +GSD also wires Copilot's lifecycle hooks and instruction files: + +- **`AGENTS.md`** (local installs) — written at the repository root, which GitHub Copilot CLI reads as primary instructions, alongside `copilot-instructions.md`. +- **Lifecycle hook** — a `sessionStart` hook config is written to `.github/hooks/gsd-session.json` (local) or `~/.copilot/hooks/gsd-session.json` (global). It is a self-contained inline `command` hook (no separate hook script to install), so it can never reference a missing script. The hook is advisory-only: at session start it surfaces whether the project has a `.planning/` workflow. + +Both are removed (and any user-authored content preserved) on `--uninstall`. + **Override the install directory:** ```bash diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 404a6baee..a5f55499d 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -38,6 +38,9 @@ const { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + GSD_COPILOT_HOOK_FILE, + buildCopilotHookConfig, + writeCopilotHookConfig, writeManifest, reportLocalPatches, installRuntimeArtifacts, @@ -1040,6 +1043,112 @@ describe('Copilot instructions merge/strip', () => { }); }); +// ─── Copilot lifecycle hooks (#786) ──────────────────────────────────────────── + +describe('Copilot lifecycle hook config (#786)', () => { + describe('buildCopilotHookConfig', () => { + test('emits the documented Copilot hooks-config shape', () => { + const cfg = buildCopilotHookConfig(); + assert.strictEqual(cfg.version, 1, 'version must be 1 per Copilot hooks schema'); + assert.ok(cfg.hooks && typeof cfg.hooks === 'object', 'has hooks object'); + assert.ok(Array.isArray(cfg.hooks.sessionStart), 'sessionStart is an array (camelCase event name)'); + assert.strictEqual(cfg.hooks.sessionStart.length, 1, 'one sessionStart entry'); + }); + + test('sessionStart entry is a self-contained inline command hook', () => { + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + assert.strictEqual(entry.type, 'command', 'type is command'); + assert.ok(typeof entry.bash === 'string' && entry.bash.length > 0, 'has inline bash body'); + assert.ok(typeof entry.powershell === 'string' && entry.powershell.length > 0, 'has inline powershell body'); + assert.strictEqual(entry.timeoutSec, 10, 'uses timeoutSec (Copilot field), not timeout'); + }); + + test('command bodies emit the Copilot sessionStart JSON envelope (additionalContext)', () => { + // Copilot parses command-hook stdout as JSON; sessionStart schema is + // { additionalContext?: string }. Bare text would be invalid hook output. + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + assert.ok(entry.bash.includes('"additionalContext"'), 'bash body emits additionalContext JSON'); + assert.ok(entry.powershell.includes('"additionalContext"'), 'powershell body emits additionalContext JSON'); + }); + + test('executing the bash hook body produces valid sessionStart JSON', { skip: process.platform === 'win32' }, () => { + const { execFileSync } = require('child_process'); + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hook-exec-')); + try { + // No .planning/STATE.md → absent branch + const outAbsent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); + const parsedAbsent = JSON.parse(outAbsent); + assert.ok(typeof parsedAbsent.additionalContext === 'string', 'absent branch yields additionalContext string'); + assert.ok(/gsd-new-project/.test(parsedAbsent.additionalContext), 'absent branch suggests gsd-new-project'); + + // With .planning/STATE.md → present branch + fs.mkdirSync(path.join(tmp, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(tmp, '.planning', 'STATE.md'), '# state\n'); + const outPresent = execFileSync('bash', ['-c', entry.bash], { cwd: tmp, encoding: 'utf8' }); + const parsedPresent = JSON.parse(outPresent); + assert.ok(/STATE\.md present/.test(parsedPresent.additionalContext), 'present branch references STATE.md'); + } finally { + cleanup(tmp); + } + }); + + test('hook command references no external script path (cannot dangle)', () => { + const [entry] = buildCopilotHookConfig().hooks.sessionStart; + // A dangling hook points at a hook SCRIPT file the installer never wrote. + // The GSD Copilot hook is inline, so it must not reference hooks/gsd-*.js|sh. + assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.bash), 'bash body references no gsd hook script file'); + assert.ok(!/hooks\/gsd-[\w-]+\.(js|cjs|sh)/.test(entry.powershell), 'powershell body references no gsd hook script file'); + }); + + test('produces valid JSON', () => { + const json = JSON.stringify(buildCopilotHookConfig()); + assert.doesNotThrow(() => JSON.parse(json), 'config round-trips through JSON'); + }); + }); + + describe('writeCopilotHookConfig', () => { + let tmpHookDir; + + beforeEach(() => { + tmpHookDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-hook-')); + }); + + afterEach(() => { + cleanup(tmpHookDir); + }); + + test('writes hooks/gsd-session.json under the config dir', () => { + const written = writeCopilotHookConfig(tmpHookDir); + const expected = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE); + assert.strictEqual(written, expected, 'returns the written path'); + assert.ok(fs.existsSync(expected), 'hook config file exists'); + const parsed = JSON.parse(fs.readFileSync(expected, 'utf8')); + assert.strictEqual(parsed.version, 1, 'written file has version 1'); + assert.ok(Array.isArray(parsed.hooks.sessionStart), 'written file has sessionStart array'); + }); + + test('is idempotent and overwrites the managed file in place', () => { + writeCopilotHookConfig(tmpHookDir); + const hookPath = path.join(tmpHookDir, 'hooks', GSD_COPILOT_HOOK_FILE); + fs.writeFileSync(hookPath, '{"stale":true}\n'); + writeCopilotHookConfig(tmpHookDir); + const parsed = JSON.parse(fs.readFileSync(hookPath, 'utf8')); + assert.strictEqual(parsed.stale, undefined, 'stale content replaced'); + assert.strictEqual(parsed.version, 1, 'managed content restored'); + }); + + test('preserves sibling user-authored hook files', () => { + const hooksDir = path.join(tmpHookDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const userHook = path.join(hooksDir, 'my-hook.json'); + fs.writeFileSync(userHook, '{"version":1,"hooks":{}}\n'); + writeCopilotHookConfig(tmpHookDir); + assert.ok(fs.existsSync(userHook), 'user hook file untouched'); + }); + }); +}); + // ─── Copilot uninstall skill removal ─────────────────────────────────────────── describe('Copilot uninstall skill removal', () => { @@ -1321,6 +1430,23 @@ describe('E2E: Copilot full install verification', () => { 'Should contain GSD Configuration close marker'); }); + test('emits AGENTS.md at the repo root with GSD markers (#786)', () => { + const agentsMdPath = path.join(tmpDir, 'AGENTS.md'); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md should exist at repo root for local install'); + const content = fs.readFileSync(agentsMdPath, 'utf-8'); + assert.ok(content.includes(''), 'AGENTS.md has GSD close marker'); + }); + + test('emits a Copilot lifecycle hook config (#786)', () => { + const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json'); + assert.ok(fs.existsSync(hookPath), '.github/hooks/gsd-session.json should exist'); + const cfg = JSON.parse(fs.readFileSync(hookPath, 'utf-8')); + assert.strictEqual(cfg.version, 1, 'hook config has version 1'); + assert.ok(Array.isArray(cfg.hooks.sessionStart), 'hook config has sessionStart array'); + assert.strictEqual(cfg.hooks.sessionStart[0].type, 'command', 'sessionStart is a command hook'); + }); + test('creates manifest with correct structure', () => { const manifestPath = path.join(tmpDir, '.github', 'gsd-file-manifest.json'); assert.ok(fs.existsSync(manifestPath), 'gsd-file-manifest.json should exist'); @@ -1429,6 +1555,16 @@ describe('E2E: Copilot uninstall verification', () => { } }); + test('removes the Copilot lifecycle hook config (#786)', () => { + const hookPath = path.join(tmpDir, '.github', 'hooks', 'gsd-session.json'); + assert.ok(!fs.existsSync(hookPath), 'gsd-session.json should not exist after uninstall'); + }); + + test('removes GSD-only AGENTS.md (#786)', () => { + const agentsMdPath = path.join(tmpDir, 'AGENTS.md'); + assert.ok(!fs.existsSync(agentsMdPath), 'GSD-only AGENTS.md should be removed after uninstall'); + }); + describe('preserves non-GSD content', () => { let td; @@ -1463,6 +1599,90 @@ describe('E2E: Copilot uninstall verification', () => { assert.ok(fs.existsSync(customAgentPath), 'Non-GSD agent file should be preserved after uninstall'); }); + + test('preserves user-authored content in AGENTS.md on uninstall (#786)', () => { + // After install, AGENTS.md exists with the GSD block. Prepend user content. + const agentsMdPath = path.join(td, 'AGENTS.md'); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md created by install'); + const gsdBlock = fs.readFileSync(agentsMdPath, 'utf-8'); + fs.writeFileSync(agentsMdPath, '# My Project Notes\n\nKeep these.\n\n' + gsdBlock); + // Uninstall strips only the GSD section + runCopilotUninstall(td); + assert.ok(fs.existsSync(agentsMdPath), 'AGENTS.md preserved (had user content)'); + const after = fs.readFileSync(agentsMdPath, 'utf-8'); + assert.ok(after.includes('# My Project Notes'), 'user content preserved'); + assert.ok(!after.includes(' diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index a27c96887..63212fbc4 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -162,8 +162,17 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) */ export function getGlobalSkillsBase(runtime: string): string | null { if (runtime === 'cline') return null; + if (runtime === 'hermes') { + const configDir = getGlobalConfigDir(runtime); + return path.join(configDir, 'skills', 'gsd'); + } + // Kilo Code discovers global skills from ~/.kilo/skills/ (HOME-relative), + // independent of the XDG-based config dir (~/.config/kilo) used for commands. + // See: https://kilo.ai/docs/customize/skills + // "Global skills are located in the `.kilo` directory within your Home + // directory: ~/.kilo/skills/" + if (runtime === 'kilo') return path.join(os.homedir(), '.kilo', 'skills'); const configDir = getGlobalConfigDir(runtime); - if (runtime === 'hermes') return path.join(configDir, 'skills', 'gsd'); return path.join(configDir, 'skills'); } diff --git a/tests/bug-783-kilo-global-skills-base.test.cjs b/tests/bug-783-kilo-global-skills-base.test.cjs new file mode 100644 index 000000000..90a29c832 --- /dev/null +++ b/tests/bug-783-kilo-global-skills-base.test.cjs @@ -0,0 +1,105 @@ +'use strict'; +// Regression guard for bug #783. +// +// getGlobalSkillsBase('kilo') was returning ~/.config/kilo/skills (the XDG +// config dir) instead of ~/.kilo/skills — where Kilo Code actually discovers +// global skills per its docs: +// https://kilo.ai/docs/customize/skills +// "Global skills are located in the `.kilo` directory within your Home +// directory: ~/.kilo/skills/" +// +// The fix adds a special case in getGlobalSkillsBase() that resolves kilo's +// skills dir from HOME (not from the XDG config dir). The config dir at +// ~/.config/kilo is still CORRECT for commands (command/) and must stay +// unchanged — this test verifies both roles are separate. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const { + getGlobalConfigDir, + getGlobalSkillsBase, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); + +// Helper: temporarily override env vars for a test, restoring them afterwards. +function withEnv(overrides, fn) { + const saved = {}; + for (const [key, value] of Object.entries(overrides)) { + saved[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + try { + return fn(); + } finally { + for (const [key] of Object.entries(overrides)) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + } +} + +// Clear all kilo-relevant env vars so tests are hermetic. +const kiloEnvClears = { + KILO_CONFIG_DIR: undefined, + XDG_CONFIG_HOME: undefined, +}; + +describe('bug #783: kilo global skills dir is ~/.kilo/skills, not ~/.config/kilo/skills', () => { + test('getGlobalSkillsBase("kilo") resolves to ~/.kilo/skills', () => { + withEnv(kiloEnvClears, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); + + test('getGlobalConfigDir("kilo") still resolves to ~/.config/kilo (config dir unchanged)', () => { + withEnv(kiloEnvClears, () => { + assert.strictEqual( + getGlobalConfigDir('kilo'), + path.join(os.homedir(), '.config', 'kilo'), + ); + }); + }); + + test('kilo skills dir and config dir are decoupled (not equal, not nested)', () => { + withEnv(kiloEnvClears, () => { + const skillsBase = getGlobalSkillsBase('kilo'); + const configDir = getGlobalConfigDir('kilo'); + + assert.notStrictEqual(skillsBase, configDir, 'skills dir must differ from config dir'); + assert.ok( + !skillsBase.startsWith(configDir + path.sep), + `skills dir (${skillsBase}) must not be nested under config dir (${configDir})`, + ); + assert.ok( + !configDir.startsWith(skillsBase + path.sep), + `config dir (${configDir}) must not be nested under skills dir (${skillsBase})`, + ); + }); + }); + + test('getGlobalSkillsBase("kilo") is NOT affected by KILO_CONFIG_DIR override', () => { + // Skills always live in ~/.kilo/skills regardless of XDG/config-dir overrides. + withEnv({ KILO_CONFIG_DIR: '/tmp/custom-kilo-config', XDG_CONFIG_HOME: undefined }, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); + + test('getGlobalSkillsBase("kilo") is NOT affected by XDG_CONFIG_HOME override', () => { + withEnv({ KILO_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/custom-xdg' }, () => { + assert.strictEqual( + getGlobalSkillsBase('kilo'), + path.join(os.homedir(), '.kilo', 'skills'), + ); + }); + }); +}); From 74a818308eeb1485ca94059d3c7f83f8673b72b6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:07:14 -0400 Subject: [PATCH 3/9] feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface (#805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#785): write .cursor/commands/ as Cursor 1.6 slash-command surface Cursor 1.6 (released 2025-09-12) introduced plain-markdown slash commands in `.cursor/commands/.md` — no frontmatter, invocable via `/` in the Agent input. GSD previously emitted only `~/.cursor/skills/` for Cursor. This PR wires a second artifact kind for `cursor` in `runtime-artifact-layout.cts`: `convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir)`. The new kind applies the same `convertClaudeToCursorMarkdown` transforms (tool renames, brand substitution, slash-command normalisation) and then strips YAML frontmatter so the output is plain prose. Skills output is unchanged. `stageCommandsForRuntimeFlat` in `install-profiles.cts` stages each source `.md` as a flat `.md` in a temp dir; the existing `_copyStaged` commands path then prefixes and copies to `/commands/`. `.cursor/mcp.json` is explicitly OUT OF SCOPE: GSD ships no MCP server; the `mcpServers` schema cannot be usefully populated by the installer. Co-Authored-By: Claude Sonnet 4.6 * refactor(#785): address review nit --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/785-cursor-slash-commands.md | 5 ++ bin/install.js | 40 +++++++++++++ docs/FEATURES.md | 16 ++++-- src/install-profiles.cts | 57 +++++++++++++++++++ src/runtime-artifact-layout.cts | 44 ++++++++++++++- tests/cursor-conversion.test.cjs | 72 ++++++++++++++++++++++++ tests/install-runtime-artifacts.test.cjs | 25 ++++++++ tests/runtime-artifact-layout.test.cjs | 63 +++++++++++++++++++-- 8 files changed, 309 insertions(+), 13 deletions(-) create mode 100644 .changeset/785-cursor-slash-commands.md diff --git a/.changeset/785-cursor-slash-commands.md b/.changeset/785-cursor-slash-commands.md new file mode 100644 index 000000000..62007389f --- /dev/null +++ b/.changeset/785-cursor-slash-commands.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 803 +--- +`gsd install --cursor` now writes `.cursor/commands/gsd-.md` in addition to the existing `.cursor/skills/` surface. Cursor 1.6 introduced plain-markdown slash commands (no frontmatter) in `.cursor/commands/`; they appear in the `/` menu in the Agent input. Each command file is generated from the same source as the skill but with frontmatter stripped and Cursor-specific content transforms applied (`convertClaudeCommandToCursorCommand`). The skills surface is unchanged — both surfaces are written on every install. diff --git a/bin/install.js b/bin/install.js index 1439003aa..6e7b5e61e 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2196,6 +2196,29 @@ function convertClaudeCommandToCursorSkill(content, skillName) { return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n\n${adapter}\n\n${body.trimStart()}`; } +/** + * Convert a Claude Code command to a Cursor 1.6 slash command (#785). + * + * Cursor slash commands live in `.cursor/commands/.md` and are + * plain markdown — no YAML frontmatter, no adapter header. The filename + * becomes the command name (e.g. `gsd-help.md` → `/gsd-help`). + * + * Applies the same `convertClaudeToCursorMarkdown` transforms as the skill + * converter (tool renames, brand substitution, slash-command normalisation), + * then strips the YAML frontmatter block so only the prose body remains. + * + * @param {string} content raw Claude Code command markdown (may have frontmatter) + * @param {string} _commandName the target command name (unused; present for + * API symmetry with other converters so the runtime-artifact-layout stage + * function can call it uniformly) + * @returns {string} plain markdown body, no frontmatter + */ +function convertClaudeCommandToCursorCommand(content, _commandName) { + const converted = convertClaudeToCursorMarkdown(content); + const { body } = extractFrontmatterAndBody(converted); + return body.trimStart(); +} + /** * Convert Claude Code agent markdown to Cursor agent format. * Strips frontmatter fields Cursor doesn't support (color, skills), @@ -8603,6 +8626,22 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd-*'); } + + // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) + if (isCursor) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } } } else if (isOpencode || isKilo) { // OpenCode/Kilo: flat structure in command/ directory @@ -10895,6 +10934,7 @@ module.exports = { computePathPrefix, getCodexSkillAdapterHeader, convertClaudeCommandToCursorSkill, + convertClaudeCommandToCursorCommand, convertClaudeAgentToCursorAgent, convertClaudeToGeminiMarkdown, convertSlashCommandsToGeminiMentions, diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f879a60bd..839c29642 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1013,12 +1013,16 @@ fix(03-01): correct auth token expiry **Runtime Transformations:** -| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Trae | Cline | Augment | CodeBuddy | Qwen Code | -|--------|------------|----------|--------|-------|-------|---------|-------------|------|-------|---------|-----------|-----------| -| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills | Rules | Skills | Skills | Skills | -| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Rules | Skills | Skills | Skills | -| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | -| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config | +| Aspect | Claude Code | OpenCode | Gemini | Kilo | Codex | Copilot | Antigravity | Cursor | Trae | Cline | Augment | CodeBuddy | Qwen Code | +|--------|------------|----------|--------|-------|-------|---------|-------------|--------|------|-------|---------|-----------|-----------| +| Commands | Slash commands | Slash commands | Slash commands | Slash commands | Skills (TOML) | Slash commands | Skills | Skills + Slash commands | Skills | Rules | Skills | Skills | Skills | +| Agent format | Claude native | `mode: subagent` | Claude native | `mode: subagent` | Skills | Tool mapping | Skills | Skills | Skills | Rules | Skills | Skills | Skills | +| Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | +| Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | Config | `.clinerules` | Config | Config | Config | + +**Cursor artifact surfaces:** `gsd install --cursor` writes two artifact kinds: +- `~/.cursor/skills/gsd-/SKILL.md` — rich skills with YAML frontmatter, Cursor tool-name mapping, and adapter context header (existing surface) +- `~/.cursor/commands/gsd-.md` — plain markdown slash commands (no frontmatter) invocable via `/` in the Agent input (Cursor 1.6+, added in #785) **Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported. diff --git a/src/install-profiles.cts b/src/install-profiles.cts index e5ff8d4b2..8a838d3ac 100644 --- a/src/install-profiles.cts +++ b/src/install-profiles.cts @@ -363,6 +363,62 @@ function stageSkillsForRuntimeAsSkills( return stageDir; } +/** + * Stage converted command files as flat `.md` files. + * + * Analogous to `stageSkillsForRuntimeAsSkills` but for runtimes that use a + * flat commands directory (e.g. Cursor's `.cursor/commands/.md`). + * Each source `.md` is passed through `converter` and written as a single flat + * `${stem}.md` file in the staging directory (no subdirectory, no prefix). + * + * The `_copyStaged` commands branch in install.js will add the prefix when + * copying staged files to the destination directory, so staged files must be + * named with just the stem (e.g. `help.md` not `gsd-help.md`). + * + * The `converter` receives `(content, ${prefix}${stem})` so it can embed the + * full command name (e.g. 'gsd-help') into the document body if needed. + * + * Used by the `convertedCommandsKind` layout descriptor in + * runtime-artifact-layout.cts (#785 — Cursor 1.6 slash commands). + * + * @param srcCommandsDir source commands directory (e.g. commands/gsd/) + * @param resolvedProfile profile filter — '*' for all, Set for subset + * @param converter (content, commandName) → string pure converter + * @param prefix command name prefix (for converter arg), e.g. 'gsd-' + */ +function stageCommandsForRuntimeFlat( + srcCommandsDir: string, + resolvedProfile: ResolvedProfile, + converter: (content: string, commandName: string) => string, + prefix: string, +): string { + if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir; + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-commands-')); + try { + const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + if (resolvedProfile.skills !== '*' && !(resolvedProfile.skills).has(stem)) continue; + const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'); + // Pass the full command name (with prefix) to the converter so it can + // reference the installed command name in the body (e.g. for descriptions). + // The staged file itself is named without the prefix; _copyStaged adds it. + const commandName = `${prefix}${stem}`; + const converted = converter(content, commandName); + fs.writeFileSync(path.join(stageDir, `${stem}.md`), converted); + } + } catch (err) { + try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw err; + } + STAGED_DIRS.add(stageDir); + ensureExitCleanup(); + return stageDir; +} + // --------------------------------------------------------------------------- // Profile marker persistence // --------------------------------------------------------------------------- @@ -535,6 +591,7 @@ export = { stageSkillsForProfile, stageAgentsForProfile, stageSkillsForRuntimeAsSkills, + stageCommandsForRuntimeFlat, STAGED_DIRS, readActiveProfile, writeActiveProfile, diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 0a0d836ac..fff12cf62 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -21,6 +21,7 @@ const { stageSkillsForProfile, stageAgentsForProfile, stageSkillsForRuntimeAsSkills, + stageCommandsForRuntimeFlat, } = installProfiles; // In .cts (CommonJS output) files, `require` is available as a global. @@ -225,6 +226,40 @@ function skillsKind( }; } +/** + * Build a converted-commands kind descriptor for runtimes that use a flat + * commands directory with per-file conversion (e.g. Cursor 1.6 slash commands). + * + * Unlike `commandsKind` (which passes raw source files through), this kind + * applies `converterName` from bin/install.js exports to each file during + * staging, writing flat `${prefix}${stem}.md` files to the staged directory. + * + * The staged files are then written by `_copyStaged` (commands branch) which + * handles prefix logic via the existing layout machinery. + * + * @param destSubpath destination subpath within configDir (e.g. 'commands') + * @param prefix filename prefix, e.g. 'gsd-' + * @param converterName name of converter function in bin/install.js exports + * @param configDir runtime config dir (for .gsd-source marker resolution) + */ +function convertedCommandsKind( + destSubpath: string, + prefix: string, + converterName: string, + configDir: string, +): ArtifactKind { + return { + kind: 'commands', + destSubpath, + prefix, + stage: (resolved) => { + const installExports = getInstallExports(); + const converter = installExports[converterName] as (content: string, commandName: string) => string; + return stageCommandsForRuntimeFlat(findInstallSourceRoot(configDir), resolved, converter, prefix); + }, + }; +} + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -257,7 +292,14 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'cursor': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)]; + // Cursor 1.6+ supports two artifact surfaces: + // 1. skills/gsd-/SKILL.md — rich skills with frontmatter + adapter header + // 2. commands/gsd-.md — plain markdown slash commands (no frontmatter) + // accessed via '/' in the Agent input (#785) + kinds = [ + skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir), + convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir), + ]; break; case 'gemini': diff --git a/tests/cursor-conversion.test.cjs b/tests/cursor-conversion.test.cjs index c238e2e46..92a961a31 100644 --- a/tests/cursor-conversion.test.cjs +++ b/tests/cursor-conversion.test.cjs @@ -4,6 +4,9 @@ * Ensures Cursor frontmatter names are emitted as plain identifiers * (without surrounding quotes), so Cursor does not treat quotes as * literal parts of skill/subagent names. + * + * Also covers convertClaudeCommandToCursorCommand (#785 — Cursor 1.6 + * slash commands via .cursor/commands/). */ process.env.GSD_TEST_MODE = '1'; @@ -14,6 +17,7 @@ const assert = require('node:assert/strict'); const { convertClaudeCommandToCursorSkill, convertClaudeAgentToCursorAgent, + convertClaudeCommandToCursorCommand, } = require('../bin/install.js'); describe('convertClaudeCommandToCursorSkill', () => { @@ -79,3 +83,71 @@ Planner body assert.ok(!result.includes('name: "gsd-planner"'), 'quoted agent name is not emitted'); }); }); + +// ─── convertClaudeCommandToCursorCommand (#785) ─────────────────────────────── + +describe('convertClaudeCommandToCursorCommand (#785 — Cursor 1.6 .cursor/commands/)', () => { + test('strips YAML frontmatter — output is plain markdown', () => { + const input = `--- +name: help +description: Show help for GSD commands +--- + +# GSD Help + +Use \`/gsd-help\` to see available commands. +`; + + const result = convertClaudeCommandToCursorCommand(input); + assert.ok(!result.startsWith('---'), 'cursor commands must not have YAML frontmatter'); + assert.ok(!result.includes('name: help'), 'name field must be stripped'); + assert.ok(!result.includes('description:'), 'description field must be stripped'); + assert.ok(result.includes('GSD Help'), 'body content must be preserved'); + }); + + test('applies convertClaudeToCursorMarkdown transforms (Bash → Shell, Claude Code → Cursor)', () => { + const input = `--- +name: quick +description: Quick task +--- + +Use Bash( to run commands. +This runs in Claude Code. +`; + + const result = convertClaudeCommandToCursorCommand(input); + assert.ok(result.includes('Shell('), 'Bash( should be renamed to Shell('); + assert.ok(!result.includes('Claude Code'), 'Claude Code brand reference should be replaced'); + assert.ok(result.includes('Cursor'), 'should reference Cursor instead'); + }); + + test('normalizes gsd: colon slash commands to gsd- hyphen form', () => { + const input = `--- +name: plan-phase +description: Plan a phase +--- + +Next step: /gsd:execute-phase 17 +`; + + const result = convertClaudeCommandToCursorCommand(input); + assert.ok(result.includes('/gsd-execute-phase 17'), 'colon form should become hyphen form'); + assert.ok(!result.includes('/gsd:execute-phase'), 'colon form should be removed'); + }); + + test('handles input with no frontmatter gracefully', () => { + const input = `# No Frontmatter Command + +Some body content. +`; + + const result = convertClaudeCommandToCursorCommand(input); + assert.ok(!result.startsWith('---'), 'output must not start with ---'); + assert.ok(result.includes('No Frontmatter Command'), 'body should be preserved'); + }); + + test('is exported from install.js', () => { + assert.strictEqual(typeof convertClaudeCommandToCursorCommand, 'function', + 'convertClaudeCommandToCursorCommand must be exported from install.js'); + }); +}); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 76fd3d566..3b6a1d805 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -131,6 +131,31 @@ describe('installRuntimeArtifacts — gemini commands layout', () => { }); }); +describe('installRuntimeArtifacts — cursor commands layout (#785)', () => { + test('cursor: skills/ AND commands/ both created; commands/gsd-help.md is plain markdown', (t) => { + const configDir = createTempDir('gsd-ial-cursor-cmds-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cursor', configDir, 'global', RESOLVED_CORE); + + // Existing skills kind still present + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), + 'skills/gsd-help/SKILL.md must exist'); + + // New commands kind (#785) + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ must exist (#785)'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), + 'commands/gsd-help.md must exist (#785)'); + + // Cursor commands are plain markdown — no YAML frontmatter + const helpContent = fs.readFileSync(path.join(commandsDir, 'gsd-help.md'), 'utf8'); + assert.ok(!helpContent.startsWith('---'), 'cursor commands must not start with YAML frontmatter'); + }); +}); + describe('installRuntimeArtifacts — cline no-op', () => { test('cline: no kinds — call succeeds, no dirs created', (t) => { const configDir = createTempDir('gsd-ial-cline-'); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 01c72657e..673f7680a 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -59,15 +59,23 @@ describe('resolveRuntimeArtifactLayout — claude global', () => { }); describe('resolveRuntimeArtifactLayout — cursor', () => { - test('returns correct layout for cursor', () => { + test('returns correct layout for cursor — skills + commands kinds (#785)', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); assert.strictEqual(layout.runtime, 'cursor'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have a skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + assert.strictEqual(typeof skillsKind.stage, 'function'); + + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have a commands kind (#785 Cursor 1.6 slash commands)'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); }); }); @@ -263,6 +271,13 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => { assert.ok(kindNames.includes('agents'), 'should have agents kind'); }); + test('cursor has both skills and commands kinds (#785)', () => { + const layout = resolveRuntimeArtifactLayout('cursor', '/tmp/x'); + const kindNames = layout.kinds.map(k => k.kind); + assert.ok(kindNames.includes('skills'), 'cursor must have skills kind'); + assert.ok(kindNames.includes('commands'), 'cursor must have commands kind (#785 Cursor 1.6)'); + }); + test('claude global has only skills kind', () => { const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); assert.strictEqual(layout.kinds.length, 1); @@ -394,3 +409,39 @@ describe('stage — opencode commands kind', () => { } }); }); + +describe('stage — cursor commands kind (#785)', () => { + test('cursor commands kind stage returns directory with converted .md files', () => { + const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'cursor should have a commands kind (#785)'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + assert.ok(entries.length >= 1, 'at least one command file should be staged'); + + // Cursor commands are plain markdown — no YAML frontmatter + for (const entry of entries) { + const content = fs.readFileSync(path.join(stagedDir, entry), 'utf8'); + assert.ok(!content.startsWith('---'), `${entry}: cursor commands must not start with YAML frontmatter`); + } + }); + + test('cursor commands stage applies Cursor-specific content transforms', () => { + const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'cursor should have a commands kind (#785)'); + + const stagedDir = commandsKind.stage(PROFILE_FULL); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + + // Verify all staged files are .md only (no subdirectory SKILL.md layout) + const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); + for (const entry of entries) { + assert.ok(entry.isFile(), `${entry.name}: cursor commands dir must contain only flat files`); + assert.ok(entry.name.endsWith('.md'), `${entry.name}: must be .md file`); + } + }); +}); From eefef2ec19efb5cdfac69b7ecd58dac772adb1a3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:20:55 -0400 Subject: [PATCH 4/9] =?UTF-8?q?feat(#787):=20elevate=20Cline=20=E2=80=94?= =?UTF-8?q?=20.clinerules/=20dir=20form,=20PreToolUse=20hook,=20AGENTS.md?= =?UTF-8?q?=20(#803)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md Migrate the installer's Cline output from a single-file .clinerules to the .clinerules/ directory form (.clinerules/gsd.md), which is the prerequisite for Cline's v3.36 hooks (a path cannot be both a file and a directory). Add a .clinerules/hooks/PreToolUse lifecycle hook implementing Cline's JSON stdin -> {cancel,errorMessage,contextModification} protocol; it guards .planning/ artifacts and fails open. On global installs, merge GSD instructions into the cross-tool ~/.agents/AGENTS.md target (marker-delimited, merge-safe). A legacy single-file .clinerules is migrated in place; --uninstall removes the new artifacts and strips the AGENTS.md GSD block. Also fixes the uninstall targetDir for Cline local installs (it pointed at ./.cline instead of the project root) and re-runs writeManifest after the Cline artifacts are written so they are hash-tracked. Self-contained: implemented independently of the #782 Cline skills work. Co-Authored-By: Claude Opus 4.8 * fix(#787): address review findings - Scope PreToolUse hook path-walk to PATH_KEY fields only (eliminates false positive when doc body content mentions .planning/) - Use lstatSync + isSymbolicLink() for migration guard so GSD never writes through a user's symlinked .clinerules into an external directory - Add regression tests for both cases Co-Authored-By: Claude Sonnet 4.6 * chore(#787): set changeset pr: 803 Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/787-cline-hooks-agents.md | 5 + bin/install.js | 307 +++++++++++++++-- docs/how-to/install-on-your-runtime.md | 21 +- tests/cline-install.test.cjs | 16 +- ...ler-migration-install-integration.test.cjs | 5 +- tests/issue-787-cline-hooks-agents.test.cjs | 313 ++++++++++++++++++ 6 files changed, 634 insertions(+), 33 deletions(-) create mode 100644 .changeset/787-cline-hooks-agents.md create mode 100644 tests/issue-787-cline-hooks-agents.test.cjs diff --git a/.changeset/787-cline-hooks-agents.md b/.changeset/787-cline-hooks-agents.md new file mode 100644 index 000000000..f6eefd754 --- /dev/null +++ b/.changeset/787-cline-hooks-agents.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 803 +--- +Elevate the Cline runtime to hook parity. The installer now emits the Cline `.clinerules/` directory form (`.clinerules/gsd.md`) instead of a single `.clinerules` file, adds a `.clinerules/hooks/PreToolUse` lifecycle hook (Cline v3.36+ JSON stdin → `{cancel,errorMessage,contextModification}` protocol; guards `.planning/` artifacts and fails open), and merges GSD instructions into the cross-tool global `~/.agents/AGENTS.md` target on global installs. A legacy single-file `.clinerules` is migrated to the directory form in place, and `--uninstall` removes the new artifacts and strips the GSD block from `~/.agents/AGENTS.md`. (#787) diff --git a/bin/install.js b/bin/install.js index 6e7b5e61e..006f3d85d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5064,6 +5064,213 @@ function stripGsdFromCopilotInstructions(content) { return content; } +// ── Cline directory-form rules + hooks + AGENTS.md (issue #787) ──────────────── +// +// Cline v3.36 added a hooks system and a `.clinerules/` directory form. Because +// `.clinerules` cannot be both a file AND a directory, emitting hooks under +// `.clinerules/hooks/` requires migrating the rules content into the directory +// form (`.clinerules/gsd.md`). Sources adjudicated: +// - https://cline.bot/blog/cline-v3-36-hooks +// - https://docs.cline.bot/customization/cline-rules + +const GSD_AGENTS_MD_MARKER = ''; +const GSD_AGENTS_MD_CLOSE_MARKER = ''; + +/** + * The GSD instruction body shared by the Cline directory-form rules file and + * the cross-tool AGENTS.md block. Self-contained — references only the gsd-core + * engine layout, not the (separate) #782 Cline skills directory. + */ +function buildClineRulesBody() { + return [ + '# GSD Core — Git. Ship. Done.', + '', + '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when', + ' the user runs a `/gsd-*` command.', + '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.', + '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.', + '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.', + '- Do not apply GSD workflows unless the user explicitly asks for them.', + '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next', + ' step to the user using Cline\'s ask_user tool after completing it.', + ].join('\n') + '\n'; +} + +/** AGENTS.md body for the cross-tool global instruction target (`~/.agents/AGENTS.md`). */ +function buildClineAgentsMdBody() { + return buildClineRulesBody(); +} + +/** + * The Cline PreToolUse hook script (issue #787). + * + * Cline invokes hooks as executable scripts named exactly after the event with + * no extension, passing the operation context as JSON on stdin and reading a + * JSON decision from stdout ({ cancel, errorMessage, contextModification }). + * + * This hook is a self-standing planning-artifact guard: it cancels write-class + * tool calls that target `.planning/` (GSD-owned artifacts), and otherwise + * allows the operation. It FAILS OPEN — any parse/IO error allows the call so a + * hook bug can never wedge the user. No dependency on the #782 skills work. + */ +function buildClinePreToolUseHook() { + return `#!/usr/bin/env node +'use strict'; +/* GSD-managed Cline PreToolUse hook — gsd-core issue #787. + * Protocol: JSON on stdin -> JSON decision on stdout. + * Honored fields: { cancel, errorMessage, contextModification }. + * Fails open: any error allows the operation. */ +let raw = ''; +process.stdin.setEncoding('utf8'); +process.stdin.on('data', (c) => { raw += c; }); +process.stdin.on('end', () => { + const allow = () => process.stdout.write(JSON.stringify({ cancel: false })); + let input; + try { input = JSON.parse(raw || '{}'); } catch { return allow(); } + try { + const tool = String( + input.toolName || input.tool_name || input.tool || + (input.toolInput && input.toolInput.name) || (input.tool_input && input.tool_input.name) || '' + ).toLowerCase(); + const isWrite = /write|edit|replace|create|delete|remove|append|apply|patch|insert|mkdir/.test(tool); + // Collect only PATH-bearing field values (not free-form content), so a doc + // that merely mentions ".planning/" in its body is never falsely blocked. + const paths = []; + const PATH_KEY = /^(path|file|file_?path|filepath|target_?path|target|dir|directory|uri|filename)$/i; + const walk = (v, depth) => { + if (depth > 5 || paths.length > 64) return; + if (Array.isArray(v)) { for (const x of v) walk(x, depth + 1); return; } + if (v && typeof v === 'object') { + for (const k of Object.keys(v)) { + const val = v[k]; + if (typeof val === 'string' && PATH_KEY.test(k)) paths.push(val); + else walk(val, depth + 1); + } + } + }; + walk(input, 0); + const isPlanningPath = (s) => /(^|[\\\\/])\\.planning([\\\\/]|$)/.test(s); + if (isWrite && paths.some(isPlanningPath)) { + return process.stdout.write(JSON.stringify({ + cancel: true, + errorMessage: + 'GSD: .planning/ artifacts are managed by GSD workflows. Edit them only through a /gsd-* command, not directly.', + })); + } + } catch { /* fall through to allow */ } + return allow(); +}); +`; +} + +/** + * Merge the GSD AGENTS.md block into an existing file (or create it), preserving + * any user content. Mirrors mergeCopilotInstructions: marker-delimited, idempotent. + */ +function mergeGsdAgentsMd(filePath, gsdContent) { + const gsdBlock = GSD_AGENTS_MD_MARKER + '\n' + gsdContent.trim() + '\n' + GSD_AGENTS_MD_CLOSE_MARKER; + + if (!fs.existsSync(filePath)) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, gsdBlock + '\n'); + return; + } + + const existing = fs.readFileSync(filePath, 'utf8'); + const openIndex = existing.indexOf(GSD_AGENTS_MD_MARKER); + const closeIndex = existing.indexOf(GSD_AGENTS_MD_CLOSE_MARKER); + + if (openIndex !== -1 && closeIndex !== -1) { + const before = existing.substring(0, openIndex).trimEnd(); + const after = existing.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart(); + let newContent = ''; + if (before) newContent += before + '\n\n'; + newContent += gsdBlock; + if (after) newContent += '\n\n' + after; + newContent += '\n'; + fs.writeFileSync(filePath, newContent); + return; + } + + fs.writeFileSync(filePath, existing.trimEnd() + '\n\n' + gsdBlock + '\n'); +} + +/** + * Strip the GSD block from AGENTS.md content. Returns null if the file became + * empty (was GSD-only), the unchanged content if no markers were found, or the + * cleaned content otherwise. + */ +function stripGsdFromAgentsMd(content) { + const openIndex = content.indexOf(GSD_AGENTS_MD_MARKER); + const closeIndex = content.indexOf(GSD_AGENTS_MD_CLOSE_MARKER); + if (openIndex !== -1 && closeIndex !== -1) { + const before = content.substring(0, openIndex).trimEnd(); + const after = content.substring(closeIndex + GSD_AGENTS_MD_CLOSE_MARKER.length).trimStart(); + const cleaned = (before + (before && after ? '\n\n' : '') + after).trim(); + if (!cleaned) return null; + return cleaned + '\n'; + } + return content; +} + +/** + * Write the full Cline runtime artifact set (directory-form rules + PreToolUse + * hook) into targetDir, migrating a legacy single-file `.clinerules` if present. + * For global installs, also merge the cross-tool ~/.agents/AGENTS.md target. + * + * Returns the list of manifest-relative paths written under targetDir (so the + * caller can hash-track them). + */ +function writeClineArtifacts(targetDir, isGlobalInstall) { + const written = []; + const clinerulesDir = path.join(targetDir, '.clinerules'); + + // Migrate a pre-#787 single-file `.clinerules` — a path cannot be both a + // file and a directory, so the legacy file must be removed first. The legacy + // file is GSD-authored (the installer wrote its full contents with no user + // merge surface), so replacing it with the newer directory form is the + // intended upgrade. Use lstat so a symlink is unlinked in place rather than + // followed (which would write GSD files through the link into an external dir). + try { + if (fs.existsSync(clinerulesDir)) { + const st = fs.lstatSync(clinerulesDir); + if (st.isFile() || st.isSymbolicLink()) { + fs.unlinkSync(clinerulesDir); + console.log(` ${green}✓${reset} Migrated legacy .clinerules to directory form`); + } + } + } catch { /* best-effort migration */ } + + fs.mkdirSync(clinerulesDir, { recursive: true }); + fs.writeFileSync(path.join(clinerulesDir, 'gsd.md'), buildClineRulesBody()); + written.push('.clinerules/gsd.md'); + console.log(` ${green}✓${reset} Wrote .clinerules/gsd.md`); + + const hooksDir = path.join(clinerulesDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + const hookPath = path.join(hooksDir, 'PreToolUse'); + fs.writeFileSync(hookPath, buildClinePreToolUseHook()); + try { fs.chmodSync(hookPath, 0o755); } catch { /* Windows: hooks unsupported anyway */ } + written.push('.clinerules/hooks/PreToolUse'); + console.log(` ${green}✓${reset} Wrote .clinerules/hooks/PreToolUse`); + + // Global cross-tool instruction target. Cline reads ~/.agents/AGENTS.md + // (docs.cline.bot/customization/cline-rules). Merge-safe so we never clobber + // a user's or another tool's AGENTS.md. Tracked via markers (like copilot), + // not the per-configDir manifest, since it lives outside configDir. + if (isGlobalInstall) { + try { + const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md'); + mergeGsdAgentsMd(agentsPath, buildClineAgentsMdBody()); + console.log(` ${green}✓${reset} Merged GSD instructions into ~/.agents/AGENTS.md`); + } catch (err) { + console.warn(` ${yellow}⚠${reset} Could not write ~/.agents/AGENTS.md: ${err.message}`); + } + } + + return written; +} + /** * #786 — Build the GSD-managed GitHub Copilot lifecycle hook config object. * @@ -6896,10 +7103,14 @@ function uninstall(isGlobal, runtime = 'claude') { const isCodebuddy = runtime === 'codebuddy'; const dirName = getDirName(runtime); - // Get the target directory based on runtime and install type + // Get the target directory based on runtime and install type. Cline local + // installs write to the project root (.clinerules/ lives at the root, not in + // a .cline/ subdir), mirroring the install() path resolution (#787). const targetDir = isGlobal ? getGlobalConfigDir(runtime, explicitConfigDir) - : path.join(process.cwd(), dirName); + : runtime === 'cline' + ? process.cwd() + : path.join(process.cwd(), dirName); const locationLabel = isGlobal ? targetDir.replace(os.homedir(), '~') @@ -7036,6 +7247,55 @@ function uninstall(isGlobal, runtime = 'claude') { // existence early-return, since it lives outside targetDir (#786). } + // 1b-cline. Non-layout Cline side-effects (issue #787): remove the + // directory-form rules + PreToolUse hook, and strip the GSD block from the + // global cross-tool ~/.agents/AGENTS.md target. + if (runtime === 'cline') { + const clinerulesDir = path.join(targetDir, '.clinerules'); + for (const rel of ['gsd.md', path.join('hooks', 'PreToolUse')]) { + const p = path.join(clinerulesDir, rel); + try { + if (fs.existsSync(p)) { + fs.unlinkSync(p); + removedCount++; + } + } catch { /* best-effort */ } + } + // Also remove a legacy single-file .clinerules left by pre-#787 installs. + try { + if (fs.existsSync(clinerulesDir) && fs.statSync(clinerulesDir).isFile()) { + fs.unlinkSync(clinerulesDir); + removedCount++; + } + } catch { /* best-effort */ } + // Prune now-empty GSD-created directories (leave any user-added rule files). + for (const dir of [path.join(clinerulesDir, 'hooks'), clinerulesDir]) { + try { + if (fs.existsSync(dir) && fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0) { + fs.rmdirSync(dir); + } + } catch { /* best-effort */ } + } + if (isGlobal) { + const agentsPath = path.join(os.homedir(), '.agents', 'AGENTS.md'); + try { + if (fs.existsSync(agentsPath)) { + const content = fs.readFileSync(agentsPath, 'utf8'); + const cleaned = stripGsdFromAgentsMd(content); + if (cleaned === null) { + fs.unlinkSync(agentsPath); + removedCount++; + console.log(` ${green}✓${reset} Removed ~/.agents/AGENTS.md (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(agentsPath, cleaned); + removedCount++; + console.log(` ${green}✓${reset} Cleaned GSD section from ~/.agents/AGENTS.md`); + } + } + } catch { /* best-effort */ } + } + } + // 1c. Claude local: remove commands/gsd/ (primary local install location). // The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to // flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside @@ -7800,11 +8060,15 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } } - // Track .clinerules file in manifest for Cline installs + // Track Cline directory-form artifacts in the manifest (issue #787): the + // rules file and the PreToolUse hook. (~/.agents/AGENTS.md is tracked via its + // marker block, not the per-configDir manifest, since it lives outside it.) if (isCline) { - const clinerulesDest = path.join(configDir, '.clinerules'); - if (fs.existsSync(clinerulesDest)) { - manifest.files['.clinerules'] = fileHash(clinerulesDest); + for (const rel of ['.clinerules/gsd.md', '.clinerules/hooks/PreToolUse']) { + const dest = path.join(configDir, rel); + if (fs.existsSync(dest)) { + manifest.files[rel] = fileHash(dest); + } } } @@ -9529,22 +9793,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { } if (configIntent.installSurface === 'cline-rules') { - // Cline uses .clinerules — generate a rules file with GSD system instructions - const clinerulesDest = path.join(targetDir, '.clinerules'); - const clinerules = [ - '# GSD Core — Git. Ship. Done.', - '', - '- GSD workflows live in `gsd-core/workflows/`. Load the relevant workflow when', - ' the user runs a `/gsd-*` command.', - '- GSD agents live in `agents/`. Use the matching agent when spawning subagents.', - '- GSD tools are at `gsd-core/bin/gsd-tools.cjs`. Run with `node`.', - '- Planning artifacts live in `.planning/`. Never edit them outside a GSD workflow.', - '- Do not apply GSD workflows unless the user explicitly asks for them.', - '- When a GSD command triggers a deliverable (feature, fix, docs), offer the next', - ' step to the user using Cline\'s ask_user tool after completing it.', - ].join('\n') + '\n'; - fs.writeFileSync(clinerulesDest, clinerules); - console.log(` ${green}✓${reset} Wrote .clinerules`); + // Cline uses the `.clinerules/` directory form (issue #787): GSD rules live + // at .clinerules/gsd.md and a PreToolUse lifecycle hook at + // .clinerules/hooks/PreToolUse. Global installs also get ~/.agents/AGENTS.md. + writeClineArtifacts(targetDir, isGlobal); + // Re-run the manifest pass: these artifacts are written *after* the earlier + // writeManifest() call, so a second pass is needed to hash-track them. + writeManifest(targetDir, runtime, { mode: _effectiveInstallMode }); persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } @@ -11004,6 +11259,14 @@ module.exports = { convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, convertClaudeAgentToClineAgent, + buildClineRulesBody, + buildClineAgentsMdBody, + buildClinePreToolUseHook, + writeClineArtifacts, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, writeManifest, saveLocalPatches, reportLocalPatches, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index d5de73f47..3bae71c01 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -205,7 +205,7 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds ### Cline -Cline uses a rules-based integration — GSD installs as `.clinerules` rather than slash commands. +Cline uses a rules-based integration — GSD installs as Cline rules rather than slash commands. ```bash # Global install (all projects) @@ -215,7 +215,24 @@ npx @opengsd/gsd-core@latest --cline --global npx @opengsd/gsd-core@latest --cline --local ``` -Global installs write to `~/.cline/`. Local installs write to `./.cline/`. Rules are loaded automatically by Cline — no custom slash commands are registered. +GSD writes the [`.clinerules/` directory form](https://docs.cline.bot/customization/cline-rules): + +- **`.clinerules/gsd.md`** — the GSD rule file. Cline loads every `.md`/`.txt` file in + the `.clinerules/` directory automatically; no custom slash commands are registered. +- **`.clinerules/hooks/PreToolUse`** — a [lifecycle hook](https://cline.bot/blog/cline-v3-36-hooks) + (Cline v3.36+). It is an executable script that receives the tool-call context as JSON on + stdin and returns a JSON decision (`cancel` / `errorMessage` / `contextModification`). The + GSD hook guards `.planning/` artifacts from direct edits and otherwise allows the operation; + it fails open, so a hook error never blocks you. Cline runs hooks on macOS and Linux only. + +Global installs additionally merge GSD instructions into **`~/.agents/AGENTS.md`**, the +cross-tool global instruction file Cline reads. The block is marker-delimited, so your own +`AGENTS.md` content (and other tools' entries) is preserved, and `--uninstall` strips only the +GSD block. + +> Cline's *global* hook directory (`~/Documents/Cline/Rules/Hooks/`) is not yet populated by the +> installer — project-scope hooks (`.clinerules/hooks/`) and the global `AGENTS.md` instruction +> target cover the common cases. --- diff --git a/tests/cline-install.test.cjs b/tests/cline-install.test.cjs index adbde38d9..19365ff87 100644 --- a/tests/cline-install.test.cjs +++ b/tests/cline-install.test.cjs @@ -142,17 +142,19 @@ describe('Cline install (local)', () => { cleanup(tmpDir); }); - test('install creates .clinerules file', () => { + test('install creates .clinerules directory with gsd.md (#787 directory form)', () => { install(false, 'cline'); - const clinerules = path.join(tmpDir, '.clinerules'); - assert.ok(fs.existsSync(clinerules), '.clinerules must exist after cline install'); + const clinerulesDir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.existsSync(clinerulesDir), '.clinerules must exist after cline install'); + assert.ok(fs.statSync(clinerulesDir).isDirectory(), '.clinerules must be a directory (#787)'); + assert.ok(fs.existsSync(path.join(clinerulesDir, 'gsd.md')), '.clinerules/gsd.md must exist'); }); - test('.clinerules contains GSD instructions', () => { + test('.clinerules/gsd.md contains GSD instructions', () => { install(false, 'cline'); - const clinerules = path.join(tmpDir, '.clinerules'); - const content = fs.readFileSync(clinerules, 'utf8'); - assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules must reference GSD'); + const ruleFile = path.join(tmpDir, '.clinerules', 'gsd.md'); + const content = fs.readFileSync(ruleFile, 'utf8'); + assert.ok(content.includes('GSD') || content.includes('gsd'), '.clinerules/gsd.md must reference GSD'); }); test('install creates gsd-core engine directory', () => { diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index 857bc101f..f9802657a 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -232,10 +232,11 @@ function assertFreshInstallContract(runtime, targetDir) { `${runtime} should install commands/gsd entries` ); } else if (contract.surface === 'clinerules') { + // #787: Cline now uses the .clinerules/ directory form (rules at gsd.md). assert.match( - fs.readFileSync(path.join(targetDir, '.clinerules'), 'utf8'), + fs.readFileSync(path.join(targetDir, '.clinerules', 'gsd.md'), 'utf8'), /GSD workflows live in `gsd-core\/workflows\/`/, - 'Cline should install root .clinerules guidance' + 'Cline should install .clinerules/gsd.md guidance' ); } diff --git a/tests/issue-787-cline-hooks-agents.test.cjs b/tests/issue-787-cline-hooks-agents.test.cjs new file mode 100644 index 000000000..5f33bfa48 --- /dev/null +++ b/tests/issue-787-cline-hooks-agents.test.cjs @@ -0,0 +1,313 @@ +// allow-test-rule: source-text-is-the-product +// The Cline rules markdown, the PreToolUse hook script, and the AGENTS.md block +// ARE the deployed contract that the Cline runtime loads/executes — testing their +// text/behavior tests the shipped artifact. Per CONTRIBUTING.md exception matrix. + +/** + * Issue #787 — elevate Cline: write hooks (.clinerules/hooks/) + AGENTS.md. + * + * Verifies the installer now emits the Cline directory-form rules, a + * PreToolUse lifecycle hook (Cline JSON stdin → {cancel,errorMessage, + * contextModification} protocol), and a global ~/.agents/AGENTS.md instruction + * target. Self-contained: does NOT depend on the #782 Cline skills work. + * + * Primary sources adjudicated: + * - https://cline.bot/blog/cline-v3-36-hooks + * hooks live at .clinerules/hooks/ (project) and + * ~/Documents/Cline/Rules/Hooks/ (global); executable scripts named + * exactly after the event with no extension; JSON stdin → JSON stdout + * with cancel / errorMessage / contextModification. + * - https://docs.cline.bot/customization/cline-rules + * Cline processes all .md/.txt files inside a .clinerules/ directory and + * reads cross-tool global instructions from ~/.agents/AGENTS.md. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { spawnSync } = require('node:child_process'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); + +const { + install, + uninstall, + buildClineRulesBody, + buildClinePreToolUseHook, + buildClineAgentsMdBody, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, +} = require('../bin/install.js'); + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +describe('#787 Cline pure helpers', () => { + test('buildClineRulesBody returns GSD directory-form rules markdown', () => { + const body = buildClineRulesBody(); + assert.equal(typeof body, 'string'); + assert.match(body, /GSD workflows live in `gsd-core\/workflows\/`/); + assert.ok(body.endsWith('\n'), 'rules body should end with a trailing newline'); + }); + + test('buildClinePreToolUseHook returns a syntactically valid Node script', () => { + const script = buildClinePreToolUseHook(); + assert.match(script, /^#!\/usr\/bin\/env node/, 'must carry a node shebang'); + // Cline protocol fields must be present in the emitted decision surface. + assert.match(script, /cancel/); + assert.match(script, /errorMessage/); + const tmp = createTempDir('gsd-787-hookcheck-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, script); + const res = spawnSync(process.execPath, ['--check', p], { encoding: 'utf8' }); + assert.equal(res.status, 0, `node --check failed: ${res.stderr}`); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook allows a normal tool call (cancel:false)', () => { + const tmp = createTempDir('gsd-787-hookrun-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ toolName: 'read_file', toolInput: { path: 'src/index.ts' } }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook cancels a write into .planning/ with an errorMessage', () => { + const tmp = createTempDir('gsd-787-hookguard-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ toolName: 'write_to_file', toolInput: { path: '.planning/ROADMAP.md', content: 'x' } }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + const out = JSON.parse(res.stdout); + assert.equal(out.cancel, true); + assert.match(out.errorMessage, /\.planning/); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook does NOT cancel a write to a non-planning path whose CONTENT mentions .planning/', () => { + const tmp = createTempDir('gsd-787-hookfp-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { + input: JSON.stringify({ + toolName: 'write_to_file', + toolInput: { path: 'docs/guide.md', content: 'Edit your .planning/ROADMAP.md via /gsd commands.' }, + }), + encoding: 'utf8', + }); + assert.equal(res.status, 0); + assert.equal(JSON.parse(res.stdout).cancel, false, 'content mentioning .planning must not trigger a cancel'); + } finally { + cleanup(tmp); + } + }); + + test('PreToolUse hook fails open on malformed stdin', () => { + const tmp = createTempDir('gsd-787-hookbad-'); + try { + const p = path.join(tmp, 'PreToolUse'); + fs.writeFileSync(p, buildClinePreToolUseHook()); + const res = spawnSync(process.execPath, [p], { input: 'not json{', encoding: 'utf8' }); + assert.equal(res.status, 0); + assert.equal(JSON.parse(res.stdout).cancel, false); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd creates a marker-delimited block when no file exists', () => { + const tmp = createTempDir('gsd-787-agents-new-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.ok(content.includes(GSD_AGENTS_MD_CLOSE_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(tmp); + } + }); + + test('mergeGsdAgentsMd preserves pre-existing user content', () => { + const tmp = createTempDir('gsd-787-agents-merge-'); + try { + const p = path.join(tmp, 'AGENTS.md'); + fs.writeFileSync(p, '# My rules\n\nKeep me.\n'); + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const content = fs.readFileSync(p, 'utf8'); + assert.match(content, /Keep me\./); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + // Idempotent: second merge does not duplicate the block. + mergeGsdAgentsMd(p, buildClineAgentsMdBody()); + const twice = fs.readFileSync(p, 'utf8'); + const occurrences = twice.split(GSD_AGENTS_MD_MARKER).length - 1; + assert.equal(occurrences, 1, 'GSD block must not duplicate on re-merge'); + assert.match(twice, /Keep me\./); + } finally { + cleanup(tmp); + } + }); + + test('stripGsdFromAgentsMd returns null when file was GSD-only, else cleaned content', () => { + const onlyGsd = `${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + assert.equal(stripGsdFromAgentsMd(onlyGsd), null); + const mixed = `# Keep\n\n${GSD_AGENTS_MD_MARKER}\nhi\n${GSD_AGENTS_MD_CLOSE_MARKER}\n`; + const cleaned = stripGsdFromAgentsMd(mixed); + assert.match(cleaned, /# Keep/); + assert.ok(!cleaned.includes(GSD_AGENTS_MD_MARKER)); + }); +}); + +// ─── Local install: directory form + hook ─────────────────────────────────────── + +describe('#787 Cline local install — directory form + PreToolUse hook', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-local-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('writes .clinerules/ as a directory containing gsd.md', () => { + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), '.clinerules must be a directory'); + const ruleFile = path.join(dir, 'gsd.md'); + assert.ok(fs.existsSync(ruleFile), '.clinerules/gsd.md must exist'); + assert.match(fs.readFileSync(ruleFile, 'utf8'), /gsd-core\/workflows\//); + }); + + test('writes an executable PreToolUse hook with no extension', () => { + install(false, 'cline'); + const hook = path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse'); + assert.ok(fs.existsSync(hook), '.clinerules/hooks/PreToolUse must exist'); + if (process.platform !== 'win32') { + const mode = fs.statSync(hook).mode; + assert.ok((mode & 0o111) !== 0, 'PreToolUse must be executable'); + } + }); + + test('migrates a legacy single-file .clinerules into the directory form', () => { + // Simulate a pre-#787 install that wrote a .clinerules FILE. + fs.writeFileSync(path.join(tmpDir, '.clinerules'), '# legacy file\n'); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.statSync(dir).isDirectory(), 'legacy file must be replaced by a directory'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('does not follow a symlinked .clinerules (writes the real directory in place)', () => { + if (process.platform === 'win32') return; // symlink perms differ on Windows + // Point .clinerules at an external directory via symlink; install must NOT + // write GSD files through the link. + const external = path.join(tmpDir, 'external-target'); + fs.mkdirSync(external); + fs.symlinkSync(external, path.join(tmpDir, '.clinerules')); + install(false, 'cline'); + const dir = path.join(tmpDir, '.clinerules'); + assert.ok(fs.lstatSync(dir).isDirectory() && !fs.lstatSync(dir).isSymbolicLink(), + '.clinerules must be a real directory, not the symlink'); + assert.ok(!fs.existsSync(path.join(external, 'gsd.md')), 'must not write through the symlink target'); + assert.ok(fs.existsSync(path.join(dir, 'gsd.md'))); + }); + + test('manifest tracks the new directory-form artifacts', () => { + install(false, 'cline'); + const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); + assert.ok(fs.existsSync(manifestPath)); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + assert.ok(manifest.files['.clinerules/gsd.md'], 'manifest should track .clinerules/gsd.md'); + assert.ok(manifest.files['.clinerules/hooks/PreToolUse'], 'manifest should track the hook'); + }); +}); + +// ─── Global install: ~/.agents/AGENTS.md (subprocess, HOME-isolated) ───────────── + +describe('#787 Cline global install — ~/.agents/AGENTS.md', () => { + function runGlobalClineInstall() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-787-cline-global-')); + const env = { ...process.env, HOME: root, USERPROFILE: root }; + delete env.GSD_TEST_MODE; + const res = spawnSync( + process.execPath, + [INSTALL_SCRIPT, '--cline', '--global', '--config-dir', path.join(root, '.cline')], + { cwd: root, encoding: 'utf8', env }, + ); + return { root, res }; + } + + test('writes ~/.agents/AGENTS.md with a GSD marker block', () => { + const { root, res } = runGlobalClineInstall(); + try { + assert.equal(res.status, 0, `installer failed: ${res.stderr}`); + const agents = path.join(root, '.agents', 'AGENTS.md'); + assert.ok(fs.existsSync(agents), '~/.agents/AGENTS.md must exist after a global Cline install'); + const content = fs.readFileSync(agents, 'utf8'); + assert.ok(content.includes(GSD_AGENTS_MD_MARKER)); + assert.match(content, /GSD/); + } finally { + cleanup(root); + } + }); +}); + +// ─── Uninstall symmetry ───────────────────────────────────────────────────────── + +describe('#787 Cline uninstall removes managed artifacts', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-787-cline-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('local uninstall removes .clinerules/gsd.md and the hook', () => { + install(false, 'cline'); + assert.ok(fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md'))); + uninstall(false, 'cline'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'gsd.md')), 'gsd.md should be removed'); + assert.ok(!fs.existsSync(path.join(tmpDir, '.clinerules', 'hooks', 'PreToolUse')), 'hook should be removed'); + }); +}); From 3025a6846e71bc2fcccab9da16394f7d61c5d5e8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:27:46 -0400 Subject: [PATCH 5/9] fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution (#814) * fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution getGlobalConfigDir('copilot') resolved the global config directory using only --config-dir > COPILOT_CONFIG_DIR > ~/.copilot, ignoring the COPILOT_HOME env var. Per GitHub's Copilot CLI docs, COPILOT_HOME overrides the default ~/.copilot location (and user-level hooks are read from $COPILOT_HOME/hooks/), so a global --copilot install wrote all artifacts (skills, agents, copilot-instructions.md, the gsd-session.json hook) to ~/.copilot even when the user relocated their Copilot home, making them undiscoverable by Copilot CLI. Mirror the codex/CODEX_HOME branch: precedence is now --config-dir > COPILOT_CONFIG_DIR > COPILOT_HOME > ~/.copilot. Uninstall uses the same resolver, so it stays symmetric. Also: document COPILOT_HOME in the installer --help notes, the USER-GUIDE env-var table, and the installer-migrations Copilot row; and clear COPILOT_HOME in the two default-path test suites so they stay hermetic now that the resolver honors it. Closes #812 Co-Authored-By: Claude Opus 4.8 * chore(#812): add changeset for PR #814 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/812-copilot-home.md | 5 +++ bin/install.js | 2 +- docs/USER-GUIDE.md | 2 +- docs/installer-migrations.md | 2 +- src/runtime-homes.cts | 4 ++- ...6-global-skills-base-runtime-path.test.cjs | 2 +- tests/copilot-install.test.cjs | 36 +++++++++++++++++++ tests/install.test.cjs | 2 +- 8 files changed, 49 insertions(+), 6 deletions(-) create mode 100644 .changeset/812-copilot-home.md diff --git a/.changeset/812-copilot-home.md b/.changeset/812-copilot-home.md new file mode 100644 index 000000000..4a6cddc48 --- /dev/null +++ b/.changeset/812-copilot-home.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 814 +--- +Honor the `COPILOT_HOME` environment variable when resolving the GitHub Copilot global config directory. Previously a global `--copilot` install ignored `COPILOT_HOME` and wrote all artifacts (skills, agents, `copilot-instructions.md`, the session hook) to `~/.copilot` even when the user had relocated their Copilot home, making them undiscoverable by Copilot CLI. Resolution now follows `--config-dir` > `COPILOT_CONFIG_DIR` > `COPILOT_HOME` > `~/.copilot`, mirroring the existing `CODEX_HOME` handling. Uninstall uses the same resolver and stays symmetric. (#812) diff --git a/bin/install.js b/bin/install.js index 006f3d85d..a6d3fb26c 100755 --- a/bin/install.js +++ b/bin/install.js @@ -503,7 +503,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 161552d2d..e7af7be47 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -749,7 +749,7 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-next npx @opengsd/gsd-core@latest --wind | Gemini CLI | `~/.gemini` | `GEMINI_CONFIG_DIR` | | OpenCode | `XDG_CONFIG_HOME/opencode` | `OPENCODE_CONFIG_DIR` | | Codex | (per Codex CLI) | `--config-dir` flag | -| Copilot | `~/.copilot` | `COPILOT_CONFIG_DIR` | +| Copilot | `~/.copilot` | `COPILOT_CONFIG_DIR` (or `COPILOT_HOME`) | | Cursor | `~/.cursor` | `CURSOR_CONFIG_DIR` | | Windsurf | `~/.codeium/windsurf` | `WINDSURF_CONFIG_DIR` | | Antigravity | auto-detected | `ANTIGRAVITY_CONFIG_DIR` | diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 88f4e96b0..0cacbc98b 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -368,7 +368,7 @@ for the new shape before changing migration behavior. | Kilo | OpenCode-style flat markdown commands in `command/gsd-*.md`; agents in `agents/gsd-*.md`; config updates in `kilo.json` or `kilo.jsonc` | Global `KILO_CONFIG_DIR`, `dirname(KILO_CONFIG)`, `XDG_CONFIG_HOME/kilo`, or `~/.config/kilo`; local `./.kilo` | GSD owns generated command/agent files and GSD entries in structured config only | [Custom subagents](https://docs.kilo.ai/docs/customize/custom-subagents); docs not versioned, checked 2026-05-11 | | Gemini CLI | TOML slash commands in `commands/gsd/*.toml`; agents in `agents/gsd-*.md`; `settings.json` feature flag, hooks, and statusline | Global `GEMINI_CONFIG_DIR` or `~/.gemini`; local `./.gemini` | GSD owns generated commands/agents/hooks and only GSD settings entries; local command copy may be skipped when global GSD commands already exist | [Custom commands](https://google-gemini.github.io/gemini-cli/docs/cli/custom-commands.html), [configuration](https://google-gemini.github.io/gemini-cli/docs/cli/configuration.html); docs checked 2026-05-11 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/`; `[agents.gsd-*]` and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, `agents.gsd-*` config sections, `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | -| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR` or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | +| GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | | Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agent` | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. | | Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); docs not versioned, checked 2026-05-11 | | Windsurf | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/` | Global `WINDSURF_CONFIG_DIR` or `~/.codeium/windsurf`; local `./.windsurf` | GSD owns generated skills/agents and GSD rule files or references; no hook/statusline ownership | Windsurf public rule docs were source-limited in search results as of 2026-05-11; installer targets the common workspace rules convention `./.windsurf/rules` and must be rechecked before migrations rewrite rules | diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 63212fbc4..49f2f0661 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -96,7 +96,9 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) // ── Copilot (VS Code) ──────────────────────────────────────────────────── case 'copilot': - return env['COPILOT_CONFIG_DIR'] ? expandTilde(env['COPILOT_CONFIG_DIR']) : path.join(home, '.copilot'); + if (env['COPILOT_CONFIG_DIR']) return expandTilde(env['COPILOT_CONFIG_DIR']); + if (env['COPILOT_HOME']) return expandTilde(env['COPILOT_HOME']); + return path.join(home, '.copilot'); // ── Antigravity ────────────────────────────────────────────────────────── case 'antigravity': diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index 383751d30..2ade7e4f1 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -63,7 +63,7 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => { test(`${runtime} default configDir`, () => { // Clear all env vars for this runtime const envKeys = ['CLAUDE_CONFIG_DIR','CURSOR_CONFIG_DIR','GEMINI_CONFIG_DIR', - 'CODEX_HOME','COPILOT_CONFIG_DIR','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR', + 'CODEX_HOME','COPILOT_CONFIG_DIR','COPILOT_HOME','ANTIGRAVITY_CONFIG_DIR','WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR','TRAE_CONFIG_DIR','QWEN_CONFIG_DIR','HERMES_HOME', 'CODEBUDDY_CONFIG_DIR','CLINE_CONFIG_DIR','OPENCODE_CONFIG_DIR','OPENCODE_CONFIG', 'KILO_CONFIG_DIR','KILO_CONFIG', diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index a5f55499d..2a15b6f7e 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -78,9 +78,11 @@ describe('getDirName (Copilot)', () => { describe('getGlobalConfigDir (Copilot)', () => { let originalCopilotConfigDir; + let originalCopilotHome; beforeEach(() => { originalCopilotConfigDir = process.env.COPILOT_CONFIG_DIR; + originalCopilotHome = process.env.COPILOT_HOME; }); afterEach(() => { @@ -89,10 +91,16 @@ describe('getGlobalConfigDir (Copilot)', () => { } else { delete process.env.COPILOT_CONFIG_DIR; } + if (originalCopilotHome !== undefined) { + process.env.COPILOT_HOME = originalCopilotHome; + } else { + delete process.env.COPILOT_HOME; + } }); test('returns ~/.copilot with no env var or explicit dir', () => { delete process.env.COPILOT_CONFIG_DIR; + delete process.env.COPILOT_HOME; const result = getGlobalConfigDir('copilot'); assert.strictEqual(result, path.join(os.homedir(), '.copilot')); }); @@ -114,6 +122,34 @@ describe('getGlobalConfigDir (Copilot)', () => { assert.strictEqual(result, '/explicit/path'); }); + test('respects COPILOT_HOME env var', () => { + delete process.env.COPILOT_CONFIG_DIR; + process.env.COPILOT_HOME = '/custom/copilot-home'; + const result = getGlobalConfigDir('copilot'); + assert.strictEqual(result, '/custom/copilot-home'); + }); + + test('COPILOT_HOME supports tilde expansion', () => { + delete process.env.COPILOT_CONFIG_DIR; + process.env.COPILOT_HOME = '~/my-copilot'; + const result = getGlobalConfigDir('copilot'); + assert.strictEqual(result, path.join(os.homedir(), 'my-copilot')); + }); + + test('COPILOT_CONFIG_DIR takes priority over COPILOT_HOME', () => { + process.env.COPILOT_CONFIG_DIR = '/config-dir-path'; + process.env.COPILOT_HOME = '/home-path'; + const result = getGlobalConfigDir('copilot'); + assert.strictEqual(result, '/config-dir-path'); + }); + + test('explicit dir takes priority over COPILOT_HOME', () => { + delete process.env.COPILOT_CONFIG_DIR; + process.env.COPILOT_HOME = '/home-path'; + const result = getGlobalConfigDir('copilot', '/explicit/path'); + assert.strictEqual(result, '/explicit/path'); + }); + test('does not break existing runtimes', () => { assert.strictEqual(getGlobalConfigDir('claude'), path.join(os.homedir(), '.claude')); assert.strictEqual(getGlobalConfigDir('codex'), path.join(os.homedir(), '.codex')); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 3007523f0..42949a22e 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -69,7 +69,7 @@ describe('getGlobalConfigDir — all runtimes default paths', () => { // Test the default (no env var, no explicit dir) for each runtime const ENV_KEYS = [ 'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'GEMINI_CONFIG_DIR', 'CODEX_HOME', - 'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', + 'GROK_AGENTS_HOME', 'COPILOT_CONFIG_DIR', 'COPILOT_HOME', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR', 'QWEN_CONFIG_DIR', 'HERMES_HOME', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR', 'OPENCODE_CONFIG_DIR', 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', 'ANTIGRAVITY_CONFIG_DIR', 'XDG_CONFIG_HOME', From 5618505f9a51256e095850534a41d483f20bbc34 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:34:32 -0400 Subject: [PATCH 6/9] feat(#788): expand Qwen Code hook-event coverage (#807) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#788): expand Qwen Code hook-event coverage to 4 new events Register SubagentStop, Stop, PreCompact (gsd-context-monitor.js) and UserPromptSubmit (gsd-prompt-guard.js) in the Qwen Code installer. Guard is isQwen-only — Claude Code and all other runtimes are unchanged. Uninstall loop extended to include the 4 new event names. Co-Authored-By: Claude Sonnet 4.6 * fix(#788): reconcile to 3 Qwen-only events — defer UserPromptSubmit gsd-prompt-guard exits unless tool_name is Write|Edit (PreToolUse payload shape); UserPromptSubmit carries raw user-prompt text with no tool_name field, so wiring it would be a silent no-op. Deferred to a follow-on issue. Artifacts made consistent: - bin/install.js: drop UserPromptSubmit registration block; uninstall loop drops UPS from event list - .changeset/788-qwen-hook-events.md: corrected to 3 events + rationale - docs/how-to/install-on-your-runtime.md: remove UPS row from hook table - tests/enh-788-qwen-hook-events.test.cjs: assert UPS NOT registered; fix idempotency suite to persist settings between installs; drop UPS-specific assertions Co-Authored-By: Claude Sonnet 4.6 * chore(#788): update changeset PR number to #807 Co-Authored-By: Claude Sonnet 4.6 * test(#788): prune stale install-bucket allowlist entry for enh-788 test --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/788-qwen-hook-events.md | 5 + bin/install.js | 53 ++++- docs/how-to/install-on-your-runtime.md | 13 ++ tests/enh-788-qwen-hook-events.test.cjs | 268 ++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 .changeset/788-qwen-hook-events.md create mode 100644 tests/enh-788-qwen-hook-events.test.cjs diff --git a/.changeset/788-qwen-hook-events.md b/.changeset/788-qwen-hook-events.md new file mode 100644 index 000000000..c04b19a68 --- /dev/null +++ b/.changeset/788-qwen-hook-events.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 807 +--- +Qwen Code installs now register three additional hook events that Qwen Code supports beyond Claude Code: `SubagentStop`, `Stop`, and `PreCompact` — all wired to `gsd-context-monitor.js` for context headroom tracking at subagent completion, model stop, and pre-compaction. These events are Qwen-only; Claude Code installs are unchanged. `UserPromptSubmit` is deferred: `gsd-prompt-guard` exits unless `tool_name` is `Write|Edit`, making it a no-op for that payload shape. (#788) diff --git a/bin/install.js b/bin/install.js index a6d3fb26c..4a76f7b5d 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7494,8 +7494,11 @@ function uninstall(isGlobal, runtime = 'claude') { } // Remove GSD hooks from settings — per-hook granularity to preserve - // user hooks that share an entry with a GSD hook (#1755 followup) - for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool']) { + // user hooks that share an entry with a GSD hook (#1755 followup). + // Includes the 3 Qwen-only events added in #788 (SubagentStop, Stop, + // PreCompact) — safe to iterate for all runtimes; non-Qwen installs + // simply find no entries and skip. + for (const eventName of ['SessionStart', 'PostToolUse', 'AfterTool', 'PreToolUse', 'BeforeTool', 'SubagentStop', 'Stop', 'PreCompact']) { if (settings.hooks && settings.hooks[eventName]) { const before = JSON.stringify(settings.hooks[eventName]); settings.hooks[eventName] = settings.hooks[eventName] @@ -10303,6 +10306,52 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else if (!hasPhaseBoundaryHook && !phaseBoundaryCommand) { console.warn(` ${yellow}⚠${reset} Skipped phase boundary hook — Bash executable path unavailable (#3393)`); } + + // ── Qwen-only extended hook events (#788) ──────────────────────────────── + // Qwen Code exposes 15 hook events — a superset of Claude Code. Three + // additional events are registered for Qwen installs: + // SubagentStop — subagent lifecycle completion (context headroom tracking) + // Stop — model stop / final-response moment (context headroom) + // PreCompact — fires before conversation compaction (most critical + // moment to surface context headroom warnings) + // + // Wire gsd-context-monitor to all three — the same hook already used for + // PostToolUse — so no new hook files are needed. + // + // Note: UserPromptSubmit is NOT wired here. That event carries the raw + // user prompt text, not a tool invocation, so gsd-prompt-guard (which + // exits unless tool_name is Write/Edit) would be a silent no-op. A + // dedicated handler for UserPromptSubmit is deferred to a follow-on issue. + // + // Guard: isQwen is defined at the top of install() (line ~8254). + if (isQwen) { + // SubagentStop, Stop, PreCompact — route through the context monitor so + // agents get context-headroom warnings at subagent completion, model stop, + // and pre-compaction (the most critical moment to surface headroom info). + for (const qwenEvent of ['SubagentStop', 'Stop', 'PreCompact']) { + if (!settings.hooks[qwenEvent]) { + settings.hooks[qwenEvent] = []; + } + const alreadyHasContextMonitor = settings.hooks[qwenEvent].some(entry => + entry.hooks && entry.hooks.some(h => h.command && h.command.includes('gsd-context-monitor')) + ); + if (!alreadyHasContextMonitor && fs.existsSync(contextMonitorFile) && contextMonitorCommand) { + settings.hooks[qwenEvent].push({ + hooks: [ + { + type: 'command', + command: contextMonitorCommand, + timeout: 10 + } + ] + }); + console.log(` ${green}✓${reset} Configured ${qwenEvent} context monitor hook (Qwen Code)`); + } else if (!alreadyHasContextMonitor && !fs.existsSync(contextMonitorFile)) { + console.warn(` ${yellow}⚠${reset} Skipped ${qwenEvent} hook — gsd-context-monitor.js not found at target`); + } + } + } + // ── end Qwen-only extended hook events ──────────────────────────────────── } // Compute the update-banner hook command alongside the others so diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 3bae71c01..3b636f236 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -262,6 +262,19 @@ Skills land in `~/.qwen/skills/gsd-*/SKILL.md`. QWEN_CONFIG_DIR=~/.qwen-alt npx @opengsd/gsd-core@latest --qwen --global ``` +**Hook coverage** + +Qwen Code supports 15 hook events. GSD registers the following events automatically on install: + +| Event | Hook | Purpose | +|---|---|---| +| `SessionStart` | `gsd-check-update.js`, `gsd-session-state.sh` | Update check, session orientation | +| `PostToolUse` | `gsd-context-monitor.js`, `gsd-read-injection-scanner.js`, `gsd-phase-boundary.sh`, `gsd-graphify-update.sh` | Context monitoring, read-time scan, phase boundary detection | +| `PreToolUse` | `gsd-prompt-guard.js`, `gsd-read-guard.js`, `gsd-workflow-guard.js`, `gsd-worktree-path-guard.js`, `gsd-validate-commit.sh` | Prompt guard, read-before-edit, workflow + worktree safety, commit validation | +| `SubagentStop` | `gsd-context-monitor.js` | Context headroom tracking after subagent completion | +| `Stop` | `gsd-context-monitor.js` | Context headroom tracking before model stop | +| `PreCompact` | `gsd-context-monitor.js` | Context awareness before conversation compaction | + --- ### Augment Code diff --git a/tests/enh-788-qwen-hook-events.test.cjs b/tests/enh-788-qwen-hook-events.test.cjs new file mode 100644 index 000000000..39d2e0d05 --- /dev/null +++ b/tests/enh-788-qwen-hook-events.test.cjs @@ -0,0 +1,268 @@ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +/** + * Enhancement #788: Expand Qwen Code hook-event coverage. + * + * Qwen Code supports 15 hook events; gsd previously registered only + * SessionStart and PostToolUse. This suite asserts that a Qwen install + * registers the 3 new high-value events: + * - SubagentStop — subagent lifecycle finalisation (context tracking) + * - Stop — model stop / final-response hook (context tracking) + * - PreCompact — pre-compaction awareness (context tracking) + * + * All three are wired to gsd-context-monitor.js — the same hook used for + * PostToolUse — so context headroom warnings surface at these moments too. + * + * Note: UserPromptSubmit is NOT wired — gsd-prompt-guard exits unless + * tool_name is Write|Edit (PreToolUse shape), so it would be a no-op for + * the UserPromptSubmit payload. Deferred to a follow-on issue. + * + * Also asserts the inverse: Claude Code installs do NOT gain these events + * (strict isQwen scope guard). + * + * Source: https://qwenlm.github.io/qwen-code-docs/en/users/features/hooks/ + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { install, uninstall, validateHookFields } = require('../bin/install.js'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Extract all hook commands registered under `eventName` from settings. */ +function hooksForEvent(settings, eventName) { + if (!settings || !settings.hooks || !Array.isArray(settings.hooks[eventName])) return []; + return settings.hooks[eventName].flatMap(entry => + (entry && Array.isArray(entry.hooks) ? entry.hooks : []) + .map(h => h && h.command) + .filter(Boolean) + ); +} + +// Stub JS hook files that the installer checks with fs.existsSync() so hook +// registration guards pass even when hooks/dist/ isn't built. +const HOOKS_SRC = path.join(__dirname, '..', 'hooks'); +const STUB_HOOKS = [ + 'gsd-context-monitor.js', + 'gsd-prompt-guard.js', + 'gsd-check-update.js', +]; + +function stubHooksIntoTarget(targetDir) { + const hooksDest = path.join(targetDir, 'hooks'); + fs.mkdirSync(hooksDest, { recursive: true }); + for (const hookFile of STUB_HOOKS) { + const src = path.join(HOOKS_SRC, hookFile); + const dest = path.join(hooksDest, hookFile); + if (fs.existsSync(src)) { + fs.copyFileSync(src, dest); + } else { + // Minimal stub so existsSync passes + fs.writeFileSync(dest, '#!/usr/bin/env node\n// stub\n'); + } + try { fs.chmodSync(dest, 0o755); } catch { /* Windows */ } + } +} + +/** + * Persist in-memory settings to disk, simulating what finishInstall() does + * (finishInstall is not exported). Required for tests that call install() + * twice and need the second call to read the first call's hook registrations. + */ +function persistSettings(settingsPath, settings) { + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(validateHookFields(settings), null, 2) + '\n', 'utf8'); +} + +// ─── Suite 1: Qwen — new events are registered ─────────────────────────────── + +describe('enh-788: Qwen install registers 3 new hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-qwen-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + // Pre-populate hook files so installer registration guards (fs.existsSync) + // pass and hooks are actually registered in settings.json. + stubHooksIntoTarget(targetDir); + + const result = install(false, 'qwen'); + settings = result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('install returns a settings object (not null)', () => { + assert.ok(settings !== null && typeof settings === 'object', + 'Qwen install must return a non-null settings object'); + }); + + test('SubagentStop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.ok(cmds.length > 0, + `Expected SubagentStop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('Stop event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.ok(cmds.length > 0, + `Expected Stop hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('PreCompact event is registered with at least one hook', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.ok(cmds.length > 0, + `Expected PreCompact hooks; got hooks: ${JSON.stringify(settings && settings.hooks)}`); + }); + + test('UserPromptSubmit is NOT registered (handler not yet implemented for that payload shape)', () => { + // gsd-prompt-guard exits unless tool_name is Write|Edit — it is a no-op + // for UserPromptSubmit payloads. Registration is deferred until a + // dedicated hook can process the user-prompt payload shape. + const cmds = hooksForEvent(settings, 'UserPromptSubmit'); + assert.strictEqual(cmds.length, 0, + `UserPromptSubmit should NOT be registered yet; got: ${JSON.stringify(cmds)}`); + }); + + test('SubagentStop / Stop / PreCompact all use gsd-context-monitor', () => { + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.ok( + cmds.some(c => c.includes('gsd-context-monitor')), + `Event ${event} should use gsd-context-monitor; got commands: ${JSON.stringify(cmds)}` + ); + } + }); +}); + +// ─── Suite 2: Claude install does NOT get the new events ───────────────────── + +describe('enh-788: Claude install does NOT register Qwen-only hook events', () => { + let tmpDir; + let previousCwd; + let settings; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-claude-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const result = install(false, 'claude'); + settings = result && result.settings; + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('Claude install does not register SubagentStop', () => { + const cmds = hooksForEvent(settings, 'SubagentStop'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have SubagentStop; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register Stop', () => { + const cmds = hooksForEvent(settings, 'Stop'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have Stop; got: ${JSON.stringify(cmds)}`); + }); + + test('Claude install does not register PreCompact', () => { + const cmds = hooksForEvent(settings, 'PreCompact'); + assert.strictEqual(cmds.length, 0, + `Claude should NOT have PreCompact; got: ${JSON.stringify(cmds)}`); + }); +}); + +// ─── Suite 3: Idempotency — persisted reinstall does not duplicate hooks ────── + +describe('enh-788: Qwen install is idempotent across persisted reinstalls', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-idem-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('re-running after persisted first install does not duplicate hook entries', () => { + // First install: get settings and persist to disk (simulating finishInstall) + const result1 = install(false, 'qwen'); + persistSettings(result1.settingsPath, result1.settings); + + // Second install: reads the persisted settings.json — dedup guards apply + process.chdir(tmpDir); + const result2 = install(false, 'qwen'); + const s2 = result2.settings; + + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(s2, event); + assert.strictEqual(cmds.length, 1, + `Event ${event} should have exactly 1 hook command after idempotent reinstall; got ${cmds.length}: ${JSON.stringify(cmds)}`); + } + }); +}); + +// ─── Suite 4: Uninstall removes the new event registrations ────────────────── + +describe('enh-788: Qwen uninstall removes new hook event entries', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-788-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + + const targetDir = path.join(tmpDir, '.qwen'); + fs.mkdirSync(targetDir, { recursive: true }); + stubHooksIntoTarget(targetDir); + + // Install and persist to disk so uninstall has a settings.json to clean + const result = install(false, 'qwen'); + persistSettings(result.settingsPath, result.settings); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('settings.json hook entries are removed on uninstall', () => { + uninstall(false, 'qwen'); + const settingsPath = path.join(tmpDir, '.qwen', 'settings.json'); + if (!fs.existsSync(settingsPath)) return; // file removed entirely is fine + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + for (const event of ['SubagentStop', 'Stop', 'PreCompact']) { + const cmds = hooksForEvent(settings, event); + assert.strictEqual(cmds.length, 0, + `After uninstall, ${event} should have 0 hooks; got: ${JSON.stringify(cmds)}`); + } + }); +}); From 5e4e7de1ff7d678566ccdb315f4ea8abba4afc78 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:48:40 -0400 Subject: [PATCH 7/9] enhancement(#782): emit gsd skills to ~/.cline/skills for Cline >= v3.48 (#809) Cline added a global skills system (~/.cline/skills//SKILL.md) in v3.48.0, but gsd treated Cline as rules-only and emitted zero skills (getGlobalSkillsBase('cline')=null, empty artifact kinds). This makes gsd emit skills for Cline at global scope, alongside the existing .clinerules. - runtime-homes: getGlobalSkillsBase('cline') -> ~/.cline/skills (was null) - runtime-artifact-layout: cline emits a skills kind for GLOBAL scope only (local stays .clinerules-only), mirroring claude's scope dispatch - install.js: convertClaudeCommandToClineSkill emits name+description-only SKILL.md frontmatter (Cline/agentskills.io spec; no Claude-specific allowed-tools/argument-hint/agent), hyphen-normalized + .cline/-rewritten body; global cline routed through the skills path while .clinerules is still written; _applyRuntimeRewrites cline case handles custom CLINE_CONFIG_DIR; convertClaudeToCliineMarkdown also rewrites bare ~/.claude and CLAUDE_CONFIG_DIR - docs: install-on-your-runtime.md documents Cline global skills vs local rules - tests: converter (name+description-only), global emission, skills+.clinerules coexistence, scope-aware layout, custom-dir paths, idempotency Closes #782 Co-authored-by: Claude Opus 4.8 --- .changeset/782-cline-skills-emission.md | 5 + bin/install.js | 77 ++- docs/how-to/install-on-your-runtime.md | 20 +- src/runtime-artifact-layout.cts | 2 +- src/runtime-homes.cts | 10 +- ...6-global-skills-base-runtime-path.test.cjs | 18 +- tests/bug-782-cline-skills-emission.test.cjs | 647 ++++++++++++++++++ tests/install-runtime-artifacts.test.cjs | 13 +- tests/profile-output.test.cjs | 11 +- tests/runtime-artifact-layout.test.cjs | 20 +- 10 files changed, 787 insertions(+), 36 deletions(-) create mode 100644 .changeset/782-cline-skills-emission.md create mode 100644 tests/bug-782-cline-skills-emission.test.cjs diff --git a/.changeset/782-cline-skills-emission.md b/.changeset/782-cline-skills-emission.md new file mode 100644 index 000000000..076ac792b --- /dev/null +++ b/.changeset/782-cline-skills-emission.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 809 +--- +**Cline global installs now emit skills, not just rules:** gsd writes skills to `~/.cline/skills//SKILL.md` for Cline ≥ v3.48.0 (see [Cline skills docs](https://docs.cline.bot/customization/skills)), in addition to the existing `.clinerules` file. Each `SKILL.md` carries `name`/`description` frontmatter (agentskills.io) with paths rewritten to the `.cline/` convention. Local installs remain `.clinerules`-only. The `.clinerules` rules file continues to be emitted for compatibility, and upgrading over an existing rules-only install emits the new skills on the next run. diff --git a/bin/install.js b/bin/install.js index 4a76f7b5d..0b7e1f878 100755 --- a/bin/install.js +++ b/bin/install.js @@ -503,7 +503,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx ${pkg.name} [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--profile=${reset} Install a named skill profile. Profiles:\n core — 7 main-loop skills incl. phase (~130 desc tokens)\n standard — ~13 skills incl. phase, review, config (~700)\n full — all 66 skills (default)\n Composable: --profile=core,audit installs union of closures.\n Profile is persisted and respected by \`gsd update\`.\n ${cyan}--minimal${reset} Alias for --profile=core (back-compat).\n Cuts cold-start overhead from ~12k tokens to ~700.\n Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx ${pkg.name}\n\n ${dim}# Install for Claude Code globally${reset}\n npx ${pkg.name} --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx ${pkg.name} --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx ${pkg.name} --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx ${pkg.name} --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx ${pkg.name} --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx ${pkg.name} --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx ${pkg.name} --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx ${pkg.name} --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx ${pkg.name} --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx ${pkg.name} --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx ${pkg.name} --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx ${pkg.name} --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx ${pkg.name} --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx ${pkg.name} --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx ${pkg.name} --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx ${pkg.name} --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx ${pkg.name} --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx ${pkg.name} --hermes --local\n\n ${dim}# Install for Cline globally${reset}\n npx ${pkg.name} --cline --global\n\n ${dim}# Install for Cline locally${reset}\n npx ${pkg.name} --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx ${pkg.name} --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx ${pkg.name} --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx ${pkg.name} --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx ${pkg.name} --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx ${pkg.name} --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx ${pkg.name} --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / COPILOT_HOME / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -2608,9 +2608,15 @@ function convertClaudeToCliineMarkdown(content) { converted = converted.replace(/\.\/CLAUDE\.md/g, '.clinerules'); converted = converted.replace(/`CLAUDE\.md`/g, '`.clinerules`'); converted = converted.replace(/\bCLAUDE\.md\b/g, '.clinerules'); + // Slash forms first (most specific — superset of bare forms) converted = converted.replace(/\.claude\/skills\//g, '.cline/skills/'); converted = converted.replace(/\.\/\.claude\//g, './.cline/'); converted = converted.replace(/\.claude\//g, '.cline/'); + // Bare forms (no trailing slash) — after slash forms to avoid double-rewrite + converted = converted.replace(/~\/\.claude\b/g, '~/.cline'); + converted = converted.replace(/\$HOME\/\.claude\b/g, '$HOME/.cline'); + // Environment variable name rewrite + converted = converted.replace(/\bCLAUDE_CONFIG_DIR\b/g, 'CLINE_CONFIG_DIR'); converted = converted.replace(/\*\*Known Claude Code bug \(classifyHandoffIfNeeded\):\*\*[^\n]*\n/g, ''); converted = converted.replace(/- \*\*classifyHandoffIfNeeded false failure:\*\*[^\n]*\n/g, ''); converted = converted.replace(/\bClaude Code\b/g, 'Cline'); @@ -2627,6 +2633,43 @@ function convertClaudeAgentToClineAgent(content) { return `${cleanFrontmatter}\n${body}`; } +/** + * Convert a Claude command (.md) to a Cline skill (SKILL.md). + * Emits ONLY name + description frontmatter per the Cline skills spec + * (https://docs.cline.bot/customization/skills) — no allowed-tools, + * argument-hint, agent, or other Claude-specific fields. + * Body is hyphen-normalised then converted via convertClaudeToCliineMarkdown + * (.claude/→.cline/, "Claude Code"→"Cline", etc.). + * Cline uses Claude-Code-compatible tool names, so no adapter header is needed. + * Targets ~/.cline/skills//SKILL.md for Cline >= v3.48.0. + */ +function convertClaudeCommandToClineSkill(content, skillName, runtime = null, cmdNames = null) { + const { frontmatter, body } = extractFrontmatterAndBody(content); + if (!frontmatter) return content; + + // Hyphen-normalise /gsd: → gsd- references in the body, then + // apply Cline-specific markdown rewrites (.claude/→.cline/, etc.). + const names = cmdNames || readGsdCommandNames(); + const normalizedBody = transformContentToHyphen(body, names); + const clineBody = convertClaudeToCliineMarkdown(normalizedBody); + + // Extract description; fall back to a generic string if absent. + let description = extractFrontmatterField(frontmatter, 'description'); + if (!description) description = `Run GSD workflow ${skillName}.`; + description = toSingleLine(description); + // Cline documented max is 1024 code points (not UTF-16 code units). + // Use Array.from to iterate by code point so that multibyte characters + // (e.g. emoji, astral-plane chars) are never split, which would produce + // lone surrogates and corrupt the YAML output. + const cp = Array.from(description); + const shortDescription = cp.length > 1024 + ? cp.slice(0, 1021).join('') + '...' + : description; + + const fm = `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---`; + return `${fm}\n${clineBody}`; +} + // ── End Cline converters ───────────────────────────────────────────────────── function convertSlashCommandsToCodexSkillMentions(content) { @@ -6220,7 +6263,7 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = ' if (runtime) { const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope); const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); - if (!skillsKindEntry) return false; // runtime has no skills layout (e.g. cline) + if (!skillsKindEntry) return false; // runtime has no skills layout at this scope (e.g. cline local) const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName); } else { @@ -6299,6 +6342,22 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { content = processAttribution(content, getCommitAttribution(runtime)); break; + case 'cline': + // Slash forms: both the original ~/.claude/ (safety net) and the stage-time + // converted ~/.cline/ (from convertClaudeToCliineMarkdown) → pathPrefix + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.cline\//g, pathPrefix); + content = content.replace(/\$HOME\/\.cline\//g, pathPrefix); + // Bare forms (no trailing slash) + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/~\/\.cline\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.cline\b/g, normalizedPathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + case 'cursor': content = content.replace(/~\/\.claude\//g, pathPrefix); content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); @@ -8843,7 +8902,8 @@ function install(isGlobal, runtime = 'claude', options = {}) { // // Non-layout side-effects preserved inline: // Hermes: writeHermesCategoryDescription (not a layout kind) - // Cline: no-op (cline layout has empty kinds[]) + // Cline global: skills emitted via layout; .clinerules still written below (#782) + // Cline local: no skills (only .clinerules) — falls through to cline-rules surface // Gemini: conflict-detection logic (not expressible in layout) // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind) // Claude local: copyWithPathReplacement + stale-skills cleanup @@ -8851,9 +8911,11 @@ function install(isGlobal, runtime = 'claude', options = {}) { // Layout-driven path for all skills-based runtimes (full and minimal modes). // applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts) // handles per-runtime path + branding rewrites, including Qwen/Hermes. + // Cline global: emit skills to ~/.cline/skills/ (Cline >= v3.48.0 — #782). const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isAugment || isTrae || isCodebuddy || isQwen || isHermes || - (runtime === 'claude' && isGlobal); + (runtime === 'claude' && isGlobal) || + (isCline && isGlobal); if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) @@ -8925,8 +8987,9 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('command/gsd-*'); } } else if (isCline) { - // Cline is rules-based — commands are embedded in .clinerules (generated below). - // No skills/commands directory needed. Engine is installed via copyWithPathReplacement. + // Cline local install: rules-based only — commands are embedded in .clinerules (generated below). + // No skills/commands directory needed for local installs. + // Global installs are handled above by _isSkillsRuntime (#782). console.log(` ${green}✓${reset} Cline: commands will be available via .clinerules`); } else if (isGemini) { // #3037: when running --local --gemini and a GSD-managed user-scope @@ -11307,6 +11370,7 @@ module.exports = { convertClaudeCommandToCodebuddySkill, convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, + convertClaudeCommandToClineSkill, convertClaudeAgentToClineAgent, buildClineRulesBody, buildClineAgentsMdBody, @@ -11349,6 +11413,7 @@ module.exports = { uninstallRuntimeArtifacts, parseConfigDirFromArgs, cleanupLegacyGsdCc, + _applyRuntimeRewrites, }; // Main logic — only run when not loaded as a module for testing diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 3b636f236..9ae83a56e 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -205,13 +205,13 @@ WINDSURF_CONFIG_DIR=~/.codeium/windsurf-alt npx @opengsd/gsd-core@latest --winds ### Cline -Cline uses a rules-based integration — GSD installs as Cline rules rather than slash commands. +GSD gives Cline both skills (≥ v3.48.0) and the `.clinerules/` directory integration — no custom slash commands are registered. ```bash -# Global install (all projects) +# Global install (all projects — skills + rules directory) npx @opengsd/gsd-core@latest --cline --global -# Local install (this project only) +# Local install (this project only — rules directory only) npx @opengsd/gsd-core@latest --cline --local ``` @@ -225,10 +225,16 @@ GSD writes the [`.clinerules/` directory form](https://docs.cline.bot/customizat GSD hook guards `.planning/` artifacts from direct edits and otherwise allows the operation; it fails open, so a hook error never blocks you. Cline runs hooks on macOS and Linux only. -Global installs additionally merge GSD instructions into **`~/.agents/AGENTS.md`**, the -cross-tool global instruction file Cline reads. The block is marker-delimited, so your own -`AGENTS.md` content (and other tools' entries) is preserved, and `--uninstall` strips only the -GSD block. +**Global install additionally:** + +- Emits each GSD command as **`~/.cline/skills//SKILL.md`**. Cline ≥ v3.48.0 loads + skills from `~/.cline/skills/` automatically — no configuration needed. +- Merges GSD instructions into **`~/.agents/AGENTS.md`**, the cross-tool global instruction + file Cline reads. The block is marker-delimited, so your own `AGENTS.md` content (and other + tools' entries) is preserved, and `--uninstall` strips only the GSD block. + +**Local install** writes the `.clinerules/` directory into the current project only. No skills +directory is created for local scope. > Cline's *global* hook directory (`~/Documents/Cline/Rules/Hooks/`) is not yet populated by the > installer — project-scope hooks (`.clinerules/hooks/`) and the global `AGENTS.md` instruction diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index fff12cf62..f0fe2ae59 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -343,7 +343,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'cline': - kinds = []; + kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : []; break; case 'opencode': diff --git a/src/runtime-homes.cts b/src/runtime-homes.cts index 49f2f0661..5171bdd1b 100644 --- a/src/runtime-homes.cts +++ b/src/runtime-homes.cts @@ -11,9 +11,9 @@ * Runtime-specific notes: * hermes — GSD skills nest under skills/gsd// (not the flat * skills// layout used by all other runtimes). - * cline — Rules-based; commands are embedded in .clinerules. Cline does - * not use a skills/ directory. getGlobalSkillDir() returns null - * for cline so the caller can emit an appropriate warning. + * cline — Skills-capable since v3.48.0 (#782). SKILL.md files live at + * ~/.cline/skills//SKILL.md (same flat layout as cursor/codex). + * .clinerules is also emitted (rules-based compatibility layer). */ import os from 'node:os'; @@ -160,10 +160,9 @@ export function getGlobalConfigDir(runtime: string, explicitDir?: string | null) * Return the global skills base directory for the given runtime. * Most runtimes: /skills * Hermes: /skills/gsd (nested category layout — #2841) - * Cline: null (rules-based, no skills directory) + * Cline ≥ v3.48.0: /skills (SKILL.md-based global skills — #782) */ export function getGlobalSkillsBase(runtime: string): string | null { - if (runtime === 'cline') return null; if (runtime === 'hermes') { const configDir = getGlobalConfigDir(runtime); return path.join(configDir, 'skills', 'gsd'); @@ -180,7 +179,6 @@ export function getGlobalSkillsBase(runtime: string): string | null { /** * Return the full path to a specific skill's directory for the given runtime. - * Returns null for runtimes that don't use a skills directory (cline). */ export function getGlobalSkillDir(runtime: string, skillName: string): string | null { const base = getGlobalSkillsBase(runtime); diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index 2ade7e4f1..dc9637dd1 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -164,8 +164,13 @@ describe('bug #3126: runtime-homes getGlobalSkillsBase', () => { ); }); }); - test('cline: returns null (rules-based, no skills directory)', () => { - assert.strictEqual(getGlobalSkillsBase('cline'), null); + test('cline: returns ~/.cline/skills (skills-capable since v3.48.0 — #782)', () => { + withEnv('CLINE_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillsBase('cline'), + path.join(os.homedir(), '.cline', 'skills'), + ); + }); }); }); @@ -186,8 +191,13 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => { ); }); }); - test('cline: returns null', () => { - assert.strictEqual(getGlobalSkillDir('cline', 'gsd-executor'), null); + test('cline: returns ~/.cline/skills/gsd-executor (skills-capable since v3.48.0 — #782)', () => { + withEnv('CLINE_CONFIG_DIR', undefined, () => { + assert.strictEqual( + getGlobalSkillDir('cline', 'gsd-executor'), + path.join(os.homedir(), '.cline', 'skills', 'gsd-executor'), + ); + }); }); }); diff --git a/tests/bug-782-cline-skills-emission.test.cjs b/tests/bug-782-cline-skills-emission.test.cjs new file mode 100644 index 000000000..d60e0731d --- /dev/null +++ b/tests/bug-782-cline-skills-emission.test.cjs @@ -0,0 +1,647 @@ +'use strict'; +/** + * Regression tests for bug #782 — Cline skills emission. + * + * gsd now emits skills to ~/.cline/skills//SKILL.md for Cline >= v3.48. + * Skills discovery: https://docs.cline.bot/customization/skills + * + * (a) Converter unit test: convertClaudeCommandToClineSkill + * (b) Integration test: installRuntimeArtifacts for cline writes SKILL.md files + * (c) .clinerules/gsd.md still written by the install path (#787 dir form) + * (d) Idempotency: running install twice leaves skills + .clinerules/ intact + * (e) Full install() global: both skills AND .clinerules/gsd.md are written + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); + +const { + convertClaudeCommandToClineSkill, + convertClaudeToCliineMarkdown, + installRuntimeArtifacts, + install, + _applyRuntimeRewrites, +} = require('../bin/install.js'); + +const { + resolveRuntimeArtifactLayout, +} = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── (a) Converter unit test ───────────────────────────────────────────────── + +const SAMPLE_COMMAND = `--- +name: gsd:execute-phase +description: Execute all tasks in the current phase using Cline tools. +allowed-tools: + - Read + - Write + - Bash +--- + +## Objective + +Run all tasks in the current phase. + +See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use \`/gsd-help\` or Claude Code for details. +`; + +// A command that exercises all three Claude-specific frontmatter fields that +// must NOT leak into the emitted Cline SKILL.md. +const RICH_COMMAND = `--- +name: gsd:validate-phase +description: Retroactively audit and fill Nyquist validation gaps for a completed phase +argument-hint: "[phase number]" +agent: researcher +allowed-tools: + - Read + - Write + - Edit + - Bash + - Glob + - Grep + - Agent + - AskUserQuestion +--- + +## Objective + +Audit Nyquist validation coverage. See ~/.claude/skills/gsd-help/SKILL.md for reference. +Use Claude Code for details. +`; + +/** + * Extract frontmatter block (between --- delimiters) from output. + * Returns the raw text between the first --- and the closing ---. + * Uses \r?\n to handle both LF and CRLF line endings (Windows parity). + */ +function parseFrontmatter(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + return m ? m[1] : null; +} + +describe('convertClaudeCommandToClineSkill — unit', () => { + test('emits frontmatter with name: gsd-', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'frontmatter must contain name field'); + assert.ok(nameMatch[1].includes('gsd-execute-phase'), 'name must start with gsd-execute-phase'); + }); + + test('emits non-empty description in frontmatter', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'frontmatter must contain description field'); + assert.ok(descMatch[1].trim().length > 0, 'description must not be empty'); + }); + + test('body uses .cline/ paths not .claude/', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // The body reference to ~/.claude/ should be rewritten to ~/.cline/ + assert.ok(!result.includes('~/.claude/skills'), 'body must not contain ~/.claude/skills'); + assert.ok(result.includes('.cline/skills'), 'body must contain .cline/skills'); + }); + + test('body replaces "Claude Code" with "Cline"', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + assert.ok(!result.includes('Claude Code'), 'Claude Code must be replaced with Cline'); + assert.ok(result.includes('Cline'), 'result must contain Cline branding'); + }); + + test('no stray .claude/ paths in frontmatter or body', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + // Should not contain .claude/ anywhere (except inside CLAUDE.md→.clinerules rewrites + // but those are already handled by convertClaudeToCliineMarkdown) + assert.ok(!result.includes('/.claude/'), 'no /.claude/ paths in output'); + }); + + // ── Fix 1 (code-review): frontmatter must be ONLY name + description ────── + + test('frontmatter emits ONLY name and description — no allowed-tools (SAMPLE_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(SAMPLE_COMMAND, 'gsd-execute-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('frontmatter emits ONLY name and description — no allowed-tools/argument-hint/agent (RICH_COMMAND)', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const fm = parseFrontmatter(result); + assert.ok(fm !== null, 'result must have YAML frontmatter'); + assert.ok(!fm.includes('allowed-tools'), 'frontmatter must NOT contain allowed-tools'); + assert.ok(!fm.includes('argument-hint'), 'frontmatter must NOT contain argument-hint'); + assert.ok(!fm.includes('agent:'), 'frontmatter must NOT contain agent:'); + }); + + test('name == gsd-validate-phase for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const nameMatch = result.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + // yamlIdentifier may quote the value; strip surrounding quotes for comparison + const nameVal = nameMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.strictEqual(nameVal, 'gsd-validate-phase', `name must be gsd-validate-phase, got: ${nameVal}`); + }); + + test('description is non-empty and <= 1024 chars for RICH_COMMAND', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'must have description field'); + const desc = descMatch[1].replace(/^['"]|['"]$/g, '').trim(); + assert.ok(desc.length > 0, 'description must be non-empty'); + assert.ok(desc.length <= 1024, `description must be <= 1024 chars, got ${desc.length}`); + }); + + test('description truncated to <=1024 chars when source description is very long', () => { + const longDesc = 'A'.repeat(2000); + const longDescCommand = `---\nname: gsd:test\ndescription: ${longDesc}\n---\n\nBody text.\n`; + const result = convertClaudeCommandToClineSkill(longDescCommand, 'gsd-test'); + const descMatch = result.match(/^description:\s*'?(.*?)'?$/m); + assert.ok(descMatch, 'must have description field'); + // The raw description value (unquoted) should be <=1024 chars + // The result string after the --- block will have the quoted form; check raw length + // by checking the whole result doesn't have the full 2000-char string + assert.ok(!result.includes('A'.repeat(1025)), 'description must be truncated to 1024 chars'); + }); + + test('returns content unchanged when source has no frontmatter', () => { + const noFm = 'Just a body, no frontmatter here.\n'; + const result = convertClaudeCommandToClineSkill(noFm, 'gsd-test'); + assert.strictEqual(result, noFm, 'content without frontmatter must be returned unchanged'); + }); + + test('RICH_COMMAND body uses .cline/ paths and Cline branding', () => { + const result = convertClaudeCommandToClineSkill(RICH_COMMAND, 'gsd-validate-phase'); + assert.ok(!result.includes('~/.claude/'), 'body must not contain ~/.claude/'); + assert.ok(result.includes('.cline/'), 'body must contain .cline/ paths'); + assert.ok(!result.includes('Claude Code'), 'body must not contain "Claude Code"'); + assert.ok(result.includes('Cline'), 'body must reference Cline'); + }); +}); + +// ─── (b) + (c) + (d) Integration tests ──────────────────────────────────────── + +describe('installRuntimeArtifacts — cline skills emission', () => { + test('cline global: writes gsd-prefixed skill dirs under skills/', (t) => { + const configDir = createTempDir('gsd-cline-skills-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const layout = resolveRuntimeArtifactLayout('cline', configDir, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'cline must have a skills kind after #782'); + + const skillsDir = path.join(configDir, skillsKind.destSubpath); + assert.ok(fs.existsSync(skillsDir), 'skills/ directory must be created'); + + const helpSkillDir = path.join(skillsDir, `${skillsKind.prefix}help`); + assert.ok( + fs.existsSync(path.join(helpSkillDir, 'SKILL.md')), + `gsd-help/SKILL.md must exist under ${skillsKind.destSubpath}/` + ); + }); + + test('cline global: SKILL.md has valid cline frontmatter (name + description)', (t) => { + const configDir = createTempDir('gsd-cline-fm-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const helpSkill = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(helpSkill), 'gsd-help/SKILL.md must exist'); + + const content = fs.readFileSync(helpSkill, 'utf8'); + // Must have YAML frontmatter + assert.ok(content.startsWith('---'), 'SKILL.md must start with YAML frontmatter'); + assert.ok(content.includes('name:'), 'frontmatter must have name field'); + assert.ok(content.includes('description:'), 'frontmatter must have description field'); + // name must be gsd-help + const nameMatch = content.match(/^name:\s*(.+)$/m); + assert.ok(nameMatch, 'must have name field'); + assert.ok(nameMatch[1].includes('gsd-help'), `name must include gsd-help, got: ${nameMatch[1]}`); + }); + + test('cline global: SKILL.md uses .cline/ paths not .claude/', (t) => { + const configDir = createTempDir('gsd-cline-paths-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + // Check all installed skill files for stray .claude/ references + const skills = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-')); + assert.ok(skills.length > 0, 'at least one gsd- skill must be installed'); + + for (const skillName of skills) { + const skillFile = path.join(skillsDir, skillName, 'SKILL.md'); + if (!fs.existsSync(skillFile)) continue; + const content = fs.readFileSync(skillFile, 'utf8'); + assert.ok( + !content.includes('~/.claude/'), + `${skillName}/SKILL.md must not contain ~/.claude/ — found stray path` + ); + assert.ok( + !content.includes('/.claude/'), + `${skillName}/SKILL.md must not contain /.claude/ — found stray path` + ); + } + }); + + test('cline global: skill count matches resolved profile', (t) => { + const configDir = createTempDir('gsd-cline-count-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const count = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + if (RESOLVED_CORE.skills !== '*') { + assert.strictEqual(count, RESOLVED_CORE.skills.size, + `installed skill count (${count}) must match profile size (${RESOLVED_CORE.skills.size})`); + } else { + assert.ok(count > 0, 'must install at least 1 skill'); + } + }); +}); + +describe('installRuntimeArtifacts — cline idempotency', () => { + test('cline: running install twice leaves skills intact (idempotency)', (t) => { + const configDir = createTempDir('gsd-cline-idempotent-'); + t.after(() => cleanup(configDir)); + + // First install + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const skillsDir = path.join(configDir, 'skills'); + const countAfterFirst = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + // Second install (upgrade over existing) + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE); + + const countAfterSecond = fs.readdirSync(skillsDir) + .filter(n => n.startsWith('gsd-') && fs.statSync(path.join(skillsDir, n)).isDirectory()) + .length; + + assert.strictEqual(countAfterFirst, countAfterSecond, + `skill count must be stable across installs: first=${countAfterFirst} second=${countAfterSecond}`); + }); +}); + +// ─── (e) Full install() global — coexistence regression ─────────────────────── +// +// Issue #782 explicitly requires that a global Cline install writes BOTH: +// - skills//SKILL.md (skills for Cline >= v3.48) +// - .clinerules/gsd.md (rules dir form introduced by #787) +// +// installRuntimeArtifacts() tests cover skills in isolation; this test exercises +// the FULL install() code path to ensure neither artifact is silently dropped. + +describe('install() global cline — coexistence: skills AND .clinerules', () => { + let tmpGlobalDir; + let originalClineConfigDir; + + beforeEach(() => { + originalClineConfigDir = process.env.CLINE_CONFIG_DIR; + tmpGlobalDir = createTempDir('gsd-cline-global-'); + // Redirect CLINE_CONFIG_DIR to the temp dir so install() never touches ~/.cline + process.env.CLINE_CONFIG_DIR = tmpGlobalDir; + }); + + afterEach(() => { + if (originalClineConfigDir !== undefined) { + process.env.CLINE_CONFIG_DIR = originalClineConfigDir; + } else { + delete process.env.CLINE_CONFIG_DIR; + } + cleanup(tmpGlobalDir); + }); + + test('global cline install writes at least one gsd-* SKILL.md under skills/', () => { + captureConsole(() => install(true, 'cline')); + + const skillsDir = path.join(tmpGlobalDir, 'skills'); + assert.ok( + fs.existsSync(skillsDir), + `skills/ directory must exist under ${tmpGlobalDir} after global cline install` + ); + + // gsd-help is present in every profile (core, standard, full) + const helpSkillFile = path.join(skillsDir, 'gsd-help', 'SKILL.md'); + assert.ok( + fs.existsSync(helpSkillFile), + `skills/gsd-help/SKILL.md must exist under ${tmpGlobalDir} — skills emission broken for global cline` + ); + }); + + test('global cline install writes .clinerules/gsd.md to the global config dir', () => { + captureConsole(() => install(true, 'cline')); + + // For a global Cline install, targetDir = getGlobalDir('cline') = CLINE_CONFIG_DIR. + // The cline-rules surface (#787) writes the .clinerules/ DIRECTORY form: + // .clinerules/gsd.md (rule file) + // .clinerules/hooks/PreToolUse (lifecycle hook) + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok( + fs.existsSync(clinerulesMd), + `.clinerules/gsd.md must exist at ${clinerulesMd} — coexistence with skills broken for global cline (#782+#787)` + ); + }); + + test('global cline .clinerules/gsd.md contains GSD instructions', () => { + captureConsole(() => install(true, 'cline')); + + // #787 dir form: rule content lives in .clinerules/gsd.md, not a flat .clinerules file + const clinerulesMd = path.join(tmpGlobalDir, '.clinerules', 'gsd.md'); + assert.ok(fs.existsSync(clinerulesMd), '.clinerules/gsd.md must exist'); + const content = fs.readFileSync(clinerulesMd, 'utf8'); + assert.ok( + content.includes('GSD') || content.includes('gsd'), + '.clinerules/gsd.md must reference GSD' + ); + }); +}); + +// ─── Fix 3 regression: converter rewrites bare ~/.claude and CLAUDE_CONFIG_DIR ── +// +// convertClaudeToCliineMarkdown must also handle bare ~/.claude (no trailing +// slash) and the CLAUDE_CONFIG_DIR env-var name. surface.md contains these; +// the emitted Cline SKILL.md must contain no such stale Claude refs. + +describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR (Fix 3)', () => { + const surfacePath = path.join(__dirname, '..', 'commands', 'gsd', 'surface.md'); + + test('no bare ~/.claude in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + // ~/.claude followed by a word-boundary (not a /) must be gone + assert.ok( + !/~\/\.claude\b/.test(result), + 'converted surface.md must not contain bare ~/.claude' + ); + }); + + test('no CLAUDE_CONFIG_DIR in converted surface.md', () => { + const raw = fs.readFileSync(surfacePath, 'utf8'); + const result = convertClaudeToCliineMarkdown(raw); + assert.ok( + !result.includes('CLAUDE_CONFIG_DIR'), + 'converted surface.md must not contain CLAUDE_CONFIG_DIR' + ); + }); + + test('CLAUDE_CONFIG_DIR rewritten to CLINE_CONFIG_DIR', () => { + const input = 'Use CLAUDE_CONFIG_DIR or $HOME/.claude to configure'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(result.includes('CLINE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must become CLINE_CONFIG_DIR'); + assert.ok(!result.includes('CLAUDE_CONFIG_DIR'), 'CLAUDE_CONFIG_DIR must be gone'); + }); + + test('bare ~/.claude rewritten to ~/.cline', () => { + const input = 'Config dir: (~/.claude), skills at ~/.claude/skills'; + const result = convertClaudeToCliineMarkdown(input); + assert.ok(!result.includes('~/.claude'), 'bare ~/.claude must be rewritten'); + assert.ok(result.includes('~/.cline'), 'must rewrite to ~/.cline'); + }); + + test('installRuntimeArtifacts cline global: gsd-surface SKILL.md has no bare ~/.claude or CLAUDE_CONFIG_DIR', (t) => { + const configDir = createTempDir('gsd-cline-surface-fix3-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md'); + assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist for full profile'); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + assert.ok( + !/~\/\.claude\b/.test(content), + 'gsd-surface SKILL.md must not contain bare ~/.claude (Fix 3)' + ); + assert.ok( + !content.includes('CLAUDE_CONFIG_DIR'), + 'gsd-surface SKILL.md must not contain CLAUDE_CONFIG_DIR (Fix 3)' + ); + }); +}); + +// ─── Fix 1 regression: custom CLINE_CONFIG_DIR → embedded paths use custom dir ── +// +// _applyRuntimeRewrites for cline must rewrite ~/.cline/ → pathPrefix. +// For default global installs, pathPrefix = "$HOME/.cline/" (unchanged). +// For custom installs (CLINE_CONFIG_DIR=/custom), pathPrefix = "/custom/" and +// all embedded ~/.cline/ refs in SKILL.md must become /custom/... + +describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', () => { + test('default pathPrefix ($HOME/.cline/) leaves ~/.cline refs as $HOME/.cline', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '$HOME/.cline/'); + assert.ok(result.includes('$HOME/.cline/'), 'default prefix must map ~/.cline/ to $HOME/.cline/'); + assert.ok(!result.includes('~/.cline'), 'no tilde form should remain after rewrite'); + }); + + test('custom pathPrefix rewrites ~/.cline/ → custom path in SKILL.md body', () => { + const content = 'See ~/.cline/skills/gsd-help/SKILL.md for reference.\nBare: ~/.cline\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'custom prefix must appear in output'); + assert.ok(!result.includes('~/.cline'), 'no tilde cline form should remain after custom rewrite'); + }); + + test('custom pathPrefix rewrites residual ~/.claude/ safety net', () => { + const content = 'Residual: ~/.claude/skills\n'; + const result = _applyRuntimeRewrites(content, 'cline', '/custom/cline-dir/'); + assert.ok(result.includes('/custom/cline-dir/'), 'safety-net ~/.claude/ also rewritten to custom prefix'); + assert.ok(!result.includes('~/.claude/'), 'no ~/.claude/ should remain'); + }); + + test('installRuntimeArtifacts cline with CLINE_CONFIG_DIR custom: SKILL.md embeds custom path', (t) => { + const configDir = createTempDir('gsd-cline-custom-dir-'); + t.after(() => cleanup(configDir)); + + const MANIFEST_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').loadSkillsManifest( + path.join(__dirname, '..', 'commands', 'gsd') + ); + const RESOLVED_FULL = require('../gsd-core/bin/lib/install-profiles.cjs').resolveProfile({ + modes: ['full'], manifest: MANIFEST_FULL, + }); + + installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL); + + // gsd-surface SKILL.md references config paths; with a custom configDir + // (not under $HOME), pathPrefix will be the absolute custom path. + const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md'); + assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist'); + + const content = fs.readFileSync(surfaceSkill, 'utf8'); + // With a custom dir (path under /tmp, not ~/.cline), the output must NOT + // contain ~/.cline/ or $HOME/.cline/ — it must embed the actual configDir path. + assert.ok( + !content.includes('~/.cline/'), + `gsd-surface SKILL.md must not contain ~/.cline/ when configDir=${configDir} (Fix 1)` + ); + // The custom path must appear somewhere in the file + // (configDir is a /tmp/... path so pathPrefix = configDir+'/'). + // Production normalizes backslashes to forward slashes via + // path.resolve(configDir).replace(/\\/g, '/'), so compare against that + // form — otherwise this assertion fails on Windows where mkdtempSync + // returns a backslash path (e.g. C:\Users\...) but the emitted content + // already has forward slashes (C:/Users/...). + const expectedPath = path.resolve(configDir).replace(/\\/g, '/'); + assert.ok( + content.includes(expectedPath), + `gsd-surface SKILL.md must embed custom configDir path ${expectedPath} (Fix 1)` + ); + }); +}); + +// ─── Fix 4 regression: description truncation is code-point-aware ──────────── +// +// Naive UTF-16 slicing (`str.slice(0, 1021)`) can split a surrogate pair when +// the cut falls between the high and low surrogate of a multibyte character +// (e.g. emoji U+1F600, which is encoded as two UTF-16 code units). The fix +// uses Array.from() to split by code point, guaranteeing that the truncated +// value never contains a lone surrogate. + +describe('convertClaudeCommandToClineSkill — code-point-aware truncation (Fix 4)', () => { + /** + * Build a frontmatter+body command string whose description is: + * - exactly `prefixLen` ASCII chars + * - followed by `emojiCount` repetitions of '😀' (U+1F600, 2 UTF-16 units) + * - total UTF-16 length is prefixLen + emojiCount * 2 + */ + function makeEmojiCommand(prefixLen, emojiCount) { + const desc = 'A'.repeat(prefixLen) + '😀'.repeat(emojiCount); + return `---\nname: gsd:emoji-test\ndescription: ${desc}\n---\n\nBody.\n`; + } + + test('emitted description is <= 1024 code points when source overflows', () => { + // 1020 ASCII chars + 4 emoji = 1020 + 8 UTF-16 units = 1028 UTF-16 units > 1024. + // Code-point count = 1020 + 4 = 1024 — exactly at the boundary BEFORE adding '...'. + // After truncation to 1021 code points + '...' → 1024 code points total. + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + // Extract raw description value (strip surrounding YAML quotes if present) + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + const codePoints = Array.from(rawDesc); + assert.ok( + codePoints.length <= 1024, + `emitted description must be <= 1024 code points, got ${codePoints.length}` + ); + }); + + test('emitted description ends with "..." when truncated', () => { + const cmd = makeEmojiCommand(1020, 10); // 1030 code points → must truncate + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(rawDesc.endsWith('...'), `truncated description must end with "...", got: ${rawDesc.slice(-10)}`); + }); + + test('emitted description has no lone surrogate (no split emoji)', () => { + // Place emojis exactly at positions 1021–1025 (code points) so that a naive + // UTF-16 slice at 1021 code units would cut inside the second emoji's surrogate pair. + // 1019 ASCII chars + 6 emoji = 1025 code points (>1024, triggers truncation). + // UTF-16 length = 1019 + 12 = 1031. Naive slice(0,1021) yields 1019 ASCII + + // the HIGH surrogate of emoji[0] — a lone surrogate. + const cmd = makeEmojiCommand(1019, 6); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + // Verify no lone surrogate: every char's code point must be outside [0xD800, 0xDFFF]. + const hasLoneSurrogate = [...rawDesc].some(c => { + const cp = c.codePointAt(0); + return cp >= 0xD800 && cp <= 0xDFFF; + }); + assert.ok(!hasLoneSurrogate, 'emitted description must not contain a lone surrogate'); + + // Also round-trip through Buffer to confirm the string is valid UTF-8 encodable. + assert.doesNotThrow( + () => Buffer.from(rawDesc, 'utf8').toString('utf8'), + 'emitted description must round-trip through Buffer without error' + ); + }); + + test('short description (<= 1024 code points) is not truncated', () => { + // 10 ASCII + 5 emoji = 15 code points — well under the limit. + const cmd = makeEmojiCommand(10, 5); + const result = convertClaudeCommandToClineSkill(cmd, 'gsd-emoji-test'); + + const descMatch = result.match(/^description:\s*(.+)$/m); + assert.ok(descMatch, 'emitted SKILL.md must have a description field'); + const rawDesc = descMatch[1].trim().replace(/^['"]|['"]$/g, ''); + + assert.ok(!rawDesc.endsWith('...'), 'short description must NOT be truncated with "..."'); + // Must contain the original emoji characters intact + assert.ok(rawDesc.includes('😀'), 'short description must preserve emoji characters'); + }); +}); + +// ─── Fix 2 regression: cline local scope emits no skills ───────────────────── +// +// resolveRuntimeArtifactLayout('cline', dir, 'local') must return 0 kinds. +// installRuntimeArtifacts('cline', dir, 'local') must not write any skills. + +describe('resolveRuntimeArtifactLayout — cline scope-aware (Fix 2)', () => { + test('cline local: kinds.length === 0 (no skills for local scope)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'local'); + assert.strictEqual(layout.kinds.length, 0, 'cline local must have 0 kinds'); + }); + + test('cline global: kinds.length === 1 (skills kind)', () => { + const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1, 'cline global must have 1 skills kind'); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); + + test('installRuntimeArtifacts cline local: no skills/ dir created', (t) => { + const configDir = createTempDir('gsd-cline-local-noskills-'); + t.after(() => cleanup(configDir)); + + assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'local', RESOLVED_CORE)); + const skillsDir = path.join(configDir, 'skills'); + assert.ok( + !fs.existsSync(skillsDir), + `skills/ must NOT be created for cline local install (Fix 2), but found ${skillsDir}` + ); + }); +}); diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 3b6a1d805..b6e6ca68b 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -156,14 +156,19 @@ describe('installRuntimeArtifacts — cursor commands layout (#785)', () => { }); }); -describe('installRuntimeArtifacts — cline no-op', () => { - test('cline: no kinds — call succeeds, no dirs created', (t) => { +describe('installRuntimeArtifacts — cline skills (#782)', () => { + test('cline: global install writes gsd-prefixed skill dirs under skills/', (t) => { const configDir = createTempDir('gsd-ial-cline-'); t.after(() => cleanup(configDir)); assert.doesNotThrow(() => installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE)); - assert.ok(!fs.existsSync(path.join(configDir, 'skills'))); - assert.ok(!fs.existsSync(path.join(configDir, 'commands'))); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ must be created for global cline install'); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), + 'gsd-help/SKILL.md must exist' + ); }); }); diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index ec3c15b27..3179146da 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -269,7 +269,8 @@ describe('generate-dev-preferences command', () => { assert.strictEqual(out.command_path, path.join(codexHome, 'skills', 'gsd-dev-preferences', 'SKILL.md')); }); - test('errors for cline unless --output is supplied', () => { + test('uses runtime-aware skills dir for cline by default (#782)', () => { + // Cline >= v3.48.0 is skills-capable: ~/.cline/skills//SKILL.md const analysis = { profile_version: '1.0', dimensions: { @@ -277,14 +278,16 @@ describe('generate-dev-preferences command', () => { }, }; const analysisPath = path.join(tmpDir, 'analysis.json'); + const clineHome = path.join(tmpDir, 'cline-home'); fs.writeFileSync(analysisPath, JSON.stringify(analysis)); const result = runGsdTools( ['generate-dev-preferences', '--analysis', analysisPath, '--raw'], tmpDir, - { GSD_RUNTIME: 'cline' } + { CLINE_CONFIG_DIR: clineHome, GSD_RUNTIME: 'cline' } ); - assert.ok(!result.success, 'cline should require explicit --output'); - assert.ok(result.error.includes('does not use a skills directory'), 'should explain unsupported runtime'); + assert.ok(result.success, `cline skills output should succeed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.command_path, path.join(clineHome, 'skills', 'gsd-dev-preferences', 'SKILL.md')); }); }); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 673f7680a..ecd975566 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -210,8 +210,19 @@ describe('resolveRuntimeArtifactLayout — codebuddy', () => { }); describe('resolveRuntimeArtifactLayout — cline', () => { - test('returns correct layout for cline', () => { - const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR); + test('returns correct layout for cline global (skills-capable since v3.48.0 — #782)', () => { + const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'global'); + assert.strictEqual(layout.runtime, 'cline'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); + + test('cline local: no skills kinds (global-only, #782)', () => { + const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR, 'local'); assert.strictEqual(layout.runtime, 'cline'); assert.strictEqual(layout.configDir, FAKE_DIR); assert.strictEqual(layout.kinds.length, 0); @@ -253,9 +264,10 @@ describe('resolveRuntimeArtifactLayout edge-cases', () => { assert.strictEqual(layout.kinds[0].prefix, ''); }); - test('cline has no kinds', () => { + test('cline has one skills kind (#782)', () => { const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x'); - assert.strictEqual(layout.kinds.length, 0); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); }); test('gemini has one commands kind', () => { From 10087a48f589e0db17509cd6b8934852c32830f2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 15:52:22 -0400 Subject: [PATCH 8/9] feat(#790): emit Augment slash commands (~/.augment/commands/) (#808) Co-authored-by: Claude Sonnet 4.6 --- .changeset/790-augment-commands.md | 7 + bin/install.js | 69 ++++++++- docs/how-to/install-on-your-runtime.md | 2 +- src/runtime-artifact-layout.cts | 5 +- tests/enh-790-augment-commands.test.cjs | 177 ++++++++++++++++++++++++ tests/runtime-artifact-layout.test.cjs | 14 +- 6 files changed, 264 insertions(+), 10 deletions(-) create mode 100644 .changeset/790-augment-commands.md create mode 100644 tests/enh-790-augment-commands.test.cjs diff --git a/.changeset/790-augment-commands.md b/.changeset/790-augment-commands.md new file mode 100644 index 000000000..94e609ac0 --- /dev/null +++ b/.changeset/790-augment-commands.md @@ -0,0 +1,7 @@ +--- +type: Added +pr: 801 +--- +**Augment (Auggie) installs now emit slash command definitions alongside skills.** A global `--augment` install writes `commands/gsd-.md` files to `~/.augment/commands/` in addition to the existing `skills/gsd-/SKILL.md` files, matching the integration depth of other fully-elevated runtimes and allowing Auggie users to invoke GSD as slash commands (`/gsd-phase`, `/gsd-ship`, etc.) without manual configuration (#790). Content rewrites (path normalisation and Augment-specific branding) are applied at install time. Uninstall removes the `gsd-*` command files while preserving user-owned commands. `mcpServers` registration is explicitly excluded — gsd ships no MCP server and does not register third-party servers. + + diff --git a/bin/install.js b/bin/install.js index 0b7e1f878..04257db81 100755 --- a/bin/install.js +++ b/bin/install.js @@ -6320,6 +6320,46 @@ function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) { walkAndRewrite(stagedDir); } +/** + * Apply per-runtime content rewrites to flat .md files in a staged commands dir. + * Used for runtimes that have a commandsKind in their layout and need content rewrites + * (e.g. augment — replaces ~/.claude/ paths and applies branding conversions). + * + * IMPORTANT: `stageSkillsForProfile()` returns the original source directory unchanged + * on a full/default profile (skills === '*'). This function MUST NOT mutate that source + * directory. It always copies to a temp dir first, rewrites there, and returns the new + * path so the caller installs from the temp copy, not the source. + * + * @param {string} stagedDir directory of staged flat .md command files (may be source dir) + * @param {string} runtime + * @param {string} pathPrefix + * @returns {string} path to a temp dir with rewritten files (caller is responsible for cleanup) + */ +function applyRuntimeContentRewritesForCommandsInPlace(stagedDir, runtime, pathPrefix) { + if (!fs.existsSync(stagedDir)) return stagedDir; + // Always copy to a temp dir — stageSkillsForProfile() returns the original source + // dir on full/default profile (skills === '*'), so writing in-place would corrupt the + // package source. A temp copy is unconditional to keep the code simple and safe. + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-cmd-rewrites-')); + try { + for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + let content = fs.readFileSync(path.join(stagedDir, entry.name), 'utf8'); + content = _applyRuntimeRewrites(content, runtime, pathPrefix); + // For augment commands, apply the markdown conversion so tool references + // and skill paths use Augment equivalents. + if (runtime === 'augment') { + content = convertClaudeToAugmentMarkdown(content); + } + fs.writeFileSync(path.join(tempDir, entry.name), content); + } + } catch (err) { + try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch { /* best-effort */ } + throw err; + } + return tempDir; +} + /** * Apply the per-runtime rewrite table to a single content string. * Extracted so it can be unit-tested independently of the filesystem walk. @@ -6718,8 +6758,14 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { for (const kind of layout.kinds) { const staged = kind.stage(resolvedProfile); + // stagedForCopy: the directory to copy from (may differ from staged if rewrites + // produce a temp copy — see applyRuntimeContentRewritesForCommandsInPlace). + let stagedForCopy = staged; if (kind.kind === 'skills') { applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix); + } else if (kind.kind === 'commands') { + // Returns a temp dir with rewritten content so source files are never mutated. + stagedForCopy = applyRuntimeContentRewritesForCommandsInPlace(staged, runtime, pathPrefix); } const dest = path.join(layout.configDir, kind.destSubpath); fs.mkdirSync(dest, { recursive: true }); @@ -6741,8 +6787,8 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { if (kind.prefix === '') { // Hermes: wipes entire dest dir — preserve anything not in staged. - const stagedNames = fs.existsSync(staged) - ? new Set(fs.readdirSync(staged, { withFileTypes: true }) + const stagedNames = fs.existsSync(stagedForCopy) + ? new Set(fs.readdirSync(stagedForCopy, { withFileTypes: true }) .filter(e => e.isDirectory()).map(e => e.name)) : new Set(); for (const entry of fs.readdirSync(dest, { withFileTypes: true })) { @@ -6763,7 +6809,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { } _removeGsdEntries(dest, kind); - _copyStaged(staged, dest, kind); + _copyStaged(stagedForCopy, dest, kind); // Restore user-owned dirs after the prune+copy for (const [dirName, snap] of toPreserve) { @@ -6773,7 +6819,7 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { // For non-skills kinds (commands, agents): no user content to preserve; // just prune stale gsd-* entries and copy new ones. _removeGsdEntries(dest, kind); - _copyStaged(staged, dest, kind); + _copyStaged(stagedForCopy, dest, kind); } } } @@ -8955,6 +9001,21 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('skills/gsd-*'); } + // Augment: also verify commands/ (emitted alongside skills/) + if (isAugment) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } // Cursor only: also report the commands/ output (#785 — Cursor 1.6 slash commands) if (isCursor) { diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 9ae83a56e..85ec443b4 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -289,7 +289,7 @@ Qwen Code supports 15 hook events. GSD registers the following events automatica npx @opengsd/gsd-core@latest --augment --global ``` -Skills land in `~/.augment/`. GSD installs skills and agents. No hook or statusline ownership. +Skills land in `~/.augment/skills/` and slash command definitions land in `~/.augment/commands/`. GSD installs skills, agents, and commands (`/gsd-phase`, `/gsd-ship`, etc.). No hook or statusline ownership. --- diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index f0fe2ae59..24a504bf6 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -323,7 +323,10 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'augment': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)]; + kinds = [ + commandsKind('commands', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir), + ]; break; case 'trae': diff --git a/tests/enh-790-augment-commands.test.cjs b/tests/enh-790-augment-commands.test.cjs new file mode 100644 index 000000000..ba2e53d74 --- /dev/null +++ b/tests/enh-790-augment-commands.test.cjs @@ -0,0 +1,177 @@ +'use strict'; +/** + * Regression guard — enh(#790): Augment commands/ emitted alongside skills/. + * + * Verifies that a global Augment install writes: + * - commands/gsd-.md (slash command definitions) + * - skills/gsd-/SKILL.md (existing skill definitions) + * + * mcpServers in settings.json is explicitly excluded: gsd ships no MCP server + * and registering third-party servers is out of scope for the installer. + * + * Ref: https://docs.augmentcode.com/cli/reference — ~/.augment/commands/.md + */ + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { installRuntimeArtifacts } = require('../bin/install.js'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-790 — augment layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout augment returns 2 kinds', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + assert.strictEqual(layout.kinds.length, 2, 'augment must have exactly 2 artifact kinds'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['commands', 'skills']); + }); + + test('augment commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + }); + + test('augment skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('augment', '/tmp/fake-augment-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-790 — installRuntimeArtifacts augment emits both commands and skills', () => { + test('global augment install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh790-augment-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // Commands dir + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + // Skills dir (pre-existing behavior preserved) + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('commands/gsd-help.md has Augment-compatible content (no raw ~/.claude/ refs)', (t) => { + const configDir = createTempDir('gsd-enh790-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(helpCmd), 'gsd-help.md must exist'); + const content = fs.readFileSync(helpCmd, 'utf8'); + // Should not have raw ~/.claude/ references after path rewrite + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh790-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + const skillsDir = path.join(configDir, 'skills'); + const cmdCount = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + // Regression guard: stageSkillsForProfile returns the real source dir on full profile + // (skills === '*'). applyRuntimeContentRewritesForCommandsInPlace must copy to temp + // before rewriting — it must NEVER write back to the source tree. + const { resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh790-full-'); + t.after(() => cleanup(configDir)); + + // Record source file content before install + const srcHelpPath = path.join(__dirname, '..', 'commands', 'gsd', 'help.md'); + const srcContentBefore = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_FULL); + + // Source file must be identical after install + const srcContentAfter = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(srcContentBefore, srcContentAfter, + 'source commands/gsd/help.md must not be mutated by the install'); + + // Installed command file must have rewrites applied (Augment path substitution) + const installedHelp = path.join(configDir, 'commands', 'gsd-help.md'); + assert.ok(fs.existsSync(installedHelp), 'installed gsd-help.md must exist'); + const installedContent = fs.readFileSync(installedHelp, 'utf8'); + assert.ok(!installedContent.includes('~/.claude/'), 'installed command must not have raw ~/.claude/ refs'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-790 — uninstallRuntimeArtifacts removes augment commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh790-uninstall-'); + t.after(() => cleanup(configDir)); + + const { uninstallRuntimeArtifacts } = require('../bin/install.js'); + + // Pre-create: a GSD command + a user-owned command + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('augment', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcpServers exclusion ──────────────────────────────────────────────────── + +describe('enh-790 — mcpServers excluded (gsd ships no MCP server)', () => { + test('augment install does not write settings.json mcpServers', (t) => { + const configDir = createTempDir('gsd-enh790-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('augment', configDir, 'global', RESOLVED_CORE); + + // No settings.json with mcpServers should be written by the layout + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + assert.ok(!settings.mcpServers, 'settings.json must not contain mcpServers (gsd ships no MCP server)'); + } + // If no settings.json at all, that is also correct + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index ecd975566..c2be44d97 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -145,15 +145,21 @@ describe('resolveRuntimeArtifactLayout — windsurf', () => { }); describe('resolveRuntimeArtifactLayout — augment', () => { - test('returns correct layout for augment', () => { + test('returns correct layout for augment (commands + skills)', () => { const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR); assert.strictEqual(layout.runtime, 'augment'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds.length, 2); + // commands kind first + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + // skills kind second + assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); }); From ea0d8f09b13b96c66b5f76563e93bafb80db88b1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 16:13:06 -0400 Subject: [PATCH 9/9] enh(#784): emit native skills for OpenCode + Kilo runtimes (#810) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#784): emit native skills for OpenCode + Kilo runtimes OpenCode and Kilo share a config schema and both discover on-demand skills from skills//SKILL.md. The installer previously emitted only flat commands (command/) and file-based agents (agents/) for these runtimes. Add a shared OpenCode-family skill writer that stages each GSD command as a spec-compliant SKILL.md (name matching the directory, description 1-1024 chars), wired through the runtime artifact layout so uninstall cleans skills/ automatically. Skills respect the active install profile. Co-Authored-By: Claude Opus 4.8 * fix(#784): correct skill body paths + preserve user dev-preferences Address adversarial-review findings: - Add opencode/kilo cases to _applyRuntimeRewrites so staged SKILL.md bodies are re-pointed from the converter's hardcoded default config dir to the actual install target (fixes --local / --config-dir installs; commands/agents already did this by applying pathPrefix pre-conversion). - Preserve user-owned skills/gsd-dev-preferences across reinstall in installOpencodeFamilySkills (snapshot+restore around the gsd-* prune), matching installRuntimeArtifacts. - Export installOpencodeFamilySkills and add regression tests. Co-Authored-By: Claude Opus 4.8 * fix(#784): guarantee command/skill body parity, fix kilo-alt double-rewrite Follow-up adversarial-review found the post-conversion path rewrite could double-rewrite custom Kilo dirs (kilo -> kilo-alt -> kilo-alt-alt) because the kilo pathPrefix is a $HOME (non-absolute) superset of the hardcoded default base. Restructure so OpenCode/Kilo skills mirror copyFlattenedCommands exactly: stage raw commands, apply pathPrefix BEFORE conversion via a new shared applyOpencodeFamilyPathPrefix() helper (now used by both the command and skill writers), then convert. This guarantees byte-for-byte command/ skill body parity for global, --local, and --config-dir installs and removes the prefix-overlap hazard. Drop the fragile _applyRuntimeRewrites opencode/ kilo case. Strengthen the path regression test. Co-Authored-By: Claude Opus 4.8 * refactor(#784): derive opencode/kilo skills from the same staged command set Pass the installer's _stageSkills() output directly to installOpencodeFamilySkills instead of re-staging via the layout, so the command/ and skills/ surfaces always cover the identical profile-resolved set — including the --minimal/--core-only alias path, which stages differently from a plain --profile=core. Verified: minimal install now emits 8 commands and 8 skills. Co-Authored-By: Claude Opus 4.8 * chore(#784): set changeset PR number to 810 Co-Authored-By: Claude Opus 4.8 * test(#784): fully escape backslashes in test helper (CodeQL js/incomplete-string-escaping) Replace the dot-only escape `replace(/[.]/g, '\\.')` with a complete regex-escape pattern `replace(/[\\.*+?^${}()|[\]]/g, '\\$&')` so all regex metacharacters (including backslash itself) in `defaultBase` are safely escaped before interpolation into `new RegExp(...)`. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/784-opencode-kilo-skills.md | 5 + bin/install.js | 201 +++++++++++++++++++++-- docs/how-to/install-on-your-runtime.md | 4 +- src/runtime-artifact-layout.cts | 14 +- tests/install-runtime-artifacts.test.cjs | 78 +++++++++ tests/runtime-artifact-layout.test.cjs | 67 ++++++-- tests/runtime-converters.test.cjs | 69 ++++++++ 7 files changed, 411 insertions(+), 27 deletions(-) create mode 100644 .changeset/784-opencode-kilo-skills.md diff --git a/.changeset/784-opencode-kilo-skills.md b/.changeset/784-opencode-kilo-skills.md new file mode 100644 index 000000000..0d75bdfad --- /dev/null +++ b/.changeset/784-opencode-kilo-skills.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 810 +--- +Emit native on-demand skills (`skills//SKILL.md`) for the OpenCode-family runtimes (OpenCode and Kilo) at install time, in addition to the existing flat `command/` and file-based `agents/` surfaces. OpenCode and Kilo share a config schema and both discover skills from `skills//SKILL.md`; the installer now stages each GSD command as a skill with minimal, spec-compliant frontmatter (`name` matching the directory, `description` 1–1024 chars) via a shared OpenCode-family skill writer. Skills respect the active install profile (core/minimal stage only their subset) and are removed on uninstall. (#784) diff --git a/bin/install.js b/bin/install.js index 04257db81..169b8c700 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5985,6 +5985,70 @@ function convertClaudeToKiloFrontmatter(content, { isAgent = false } = {}) { return `---\n${newFrontmatter}\n---${body}`; } +/** + * Shared SKILL.md writer for the OpenCode-family runtimes (OpenCode + Kilo), + * which share a config schema (Kilo derives from OpenCode). OpenCode discovers + * skills as `skills//SKILL.md` and Kilo follows the same layout + * (https://opencode.ai/docs/skills, https://kilo.ai/docs/customize/skills). + * + * The skill body reuses the runtime's command-frontmatter converter for tool, + * path, and `/gsd:`→`/gsd-` body rewrites, then rebuilds a minimal skill + * frontmatter: only `name` (lowercase-hyphen, must match the containing + * directory) and `description` (1–1024 chars) are emitted, per the OpenCode + * skill spec. The command's `tools:`/`permission:` block is intentionally + * dropped — OpenCode skills are loaded on-demand via the native skill tool and + * inherit the calling agent's permissions. + * + * @param {string} content - Claude command markdown (with YAML frontmatter) + * @param {string} skillName - Skill directory name (e.g. gsd-help) + * @param {(content: string) => string} frontmatterConverter - runtime command converter + * @returns {string} SKILL.md content + */ +function convertClaudeCommandToOpencodeFamilySkill(content, skillName, frontmatterConverter) { + const converted = frontmatterConverter(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${skillName}.`; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) { + description = maybeDescription; + } + } + description = toSingleLine(description); + // OpenCode skill descriptions must be 1–1024 characters. + if (description.length > 1024) { + description = `${description.slice(0, 1021)}...`; + } + // `name` must be lowercase alphanumeric with single-hyphen separators and + // match the containing directory name (the staged dir is `${skillName}/`). + const name = yamlIdentifier(skillName); + return `---\nname: ${name}\ndescription: ${yamlQuote(description)}\n---\n\n${body.trimStart()}`; +} + +/** + * Convert a Claude command (.md) to an OpenCode skill (SKILL.md). + * Thin wrapper over the shared OpenCode-family writer. + */ +function convertClaudeCommandToOpencodeSkill(content, skillName) { + return convertClaudeCommandToOpencodeFamilySkill( + content, + skillName, + (c) => convertClaudeToOpencodeFrontmatter(c), + ); +} + +/** + * Convert a Claude command (.md) to a Kilo skill (SKILL.md). + * Thin wrapper over the shared OpenCode-family writer (Kilo shares the schema). + */ +function convertClaudeCommandToKiloSkill(content, skillName) { + return convertClaudeCommandToOpencodeFamilySkill( + content, + skillName, + (c) => convertClaudeToKiloFrontmatter(c), + ); +} + /** * Convert Claude Code markdown command to Gemini TOML format * @param {string} content - Markdown file content with YAML frontmatter @@ -6037,6 +6101,31 @@ function convertClaudeToGeminiToml(content) { * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime ('claude', 'opencode', or 'kilo') */ +/** + * Apply OpenCode-family (`opencode`/`kilo`) `@file` path-prefix rewrites to a + * RAW Claude command/skill body, BEFORE the frontmatter converter runs. + * + * This is the single source of truth shared by copyFlattenedCommands (commands) + * and installOpencodeFamilySkills (skills) so the two surfaces produce identical + * path references. Applying pathPrefix pre-conversion (rather than rewriting an + * already-converted body) is what avoids the converter's hardcoded default + * config dir leaking into --local / --config-dir installs, and the + * prefix-overlap double-rewrite hazard for custom dirs like `kilo-alt`. (#784) + * + * @param {string} content - raw Claude command markdown + * @param {string} runtime - 'opencode' or 'kilo' + * @param {string} pathPrefix - trailing-slash install-target prefix + * @returns {string} + */ +function applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix) { + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); + content = content.replace(/~\/\.opencode\//g, pathPrefix); + content = content.replace(/~\/\.kilo\//g, pathPrefix); + return content; +} + function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { if (!fs.existsSync(srcDir)) { return; @@ -6069,16 +6158,7 @@ function copyFlattenedCommands(srcDir, destDir, prefix, pathPrefix, runtime) { const destPath = path.join(destDir, destName); let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const opencodeDirRegex = /~\/\.opencode\//g; - const kiloDirRegex = /~\/\.kilo\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(opencodeDirRegex, pathPrefix); - content = content.replace(kiloDirRegex, pathPrefix); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); content = runtime === 'kilo' ? convertClaudeToKiloFrontmatter(content) @@ -6495,7 +6575,11 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { break; default: - // Unknown runtime — no rewrites + // Unknown runtime — no rewrites. + // OpenCode/Kilo are intentionally absent: their skills are written by + // installOpencodeFamilySkills, which applies pathPrefix BEFORE the + // command→skill conversion (mirroring copyFlattenedCommands) rather than + // rewriting already-converted SKILL.md bodies. See #784. break; } @@ -6824,6 +6908,87 @@ function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { } } +/** + * Install the skills layout kind for an OpenCode-family runtime (OpenCode/Kilo). + * + * These runtimes do NOT go through installRuntimeArtifacts (their commands use a + * bespoke flattened-command writer), so this writes ONLY the skills kind + * alongside their existing command/ + agents/ surfaces. Uninstall is already + * layout-driven (uninstallRuntimeArtifacts iterates layout.kinds), so the + * skills/ dir is cleaned up automatically once the layout declares it. + * + * `rawCommandsDir` MUST be the SAME staged command directory the flattened + * command writer consumes (the caller passes its `_stageSkills()` output) so the + * command/ and skills/ surfaces always cover the identical, profile-resolved set + * — including the `--minimal`/`--core-only` alias path, which stages differently + * from a plain `--profile=core`. + * + * Mirrors copyFlattenedCommands exactly per file — pathPrefix rewrite → + * attribution → command→skill conversion — guaranteeing command/ and skills/ + * bodies match byte-for-byte for global, --local, and --config-dir installs. + * We deliberately do NOT use skillsKindEntry.stage(): that converts before any + * pathPrefix is known, so its bodies would carry the converter's hardcoded + * default config dir. (#784) + * + * @param {string} runtime - 'opencode' or 'kilo' + * @param {string} targetDir - resolved runtime config directory + * @param {string} rawCommandsDir - staged RAW Claude command dir (caller's _stageSkills output) + * @param {string} pathPrefix - computed config-path prefix for body rewrites + * @returns {number} number of gsd-* skill directories written + */ +function installOpencodeFamilySkills(runtime, targetDir, rawCommandsDir, pathPrefix) { + const layout = resolveRuntimeArtifactLayout(runtime, targetDir); + const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); + if (!skillsKindEntry) return 0; + const rawDir = rawCommandsDir; + if (!rawDir || !fs.existsSync(rawDir)) return 0; + + const converter = runtime === 'kilo' + ? convertClaudeCommandToKiloSkill + : convertClaudeCommandToOpencodeSkill; + + const dest = path.join(targetDir, skillsKindEntry.destSubpath); + fs.mkdirSync(dest, { recursive: true }); + + // Preserve user-owned GSD-prefixed skill dirs across the gsd-* prune. + // gsd-dev-preferences is generated by the user (via generate-dev-preferences) + // and lives at /skills/gsd-dev-preferences — _removeGsdEntries + // would otherwise wipe it. Mirrors the preservation in installRuntimeArtifacts + // (#2973). + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + const toPreserve = new Map(); // dirName -> Map + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + + _removeGsdEntries(dest, skillsKindEntry); + + let count = 0; + for (const entry of fs.readdirSync(rawDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + const skillName = `${skillsKindEntry.prefix}${stem}`; + let content = fs.readFileSync(path.join(rawDir, entry.name), 'utf8'); + content = applyOpencodeFamilyPathPrefix(content, runtime, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + content = converter(content, skillName); + const skillDir = path.join(dest, skillName); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); + count++; + } + + // Restore user-owned dirs after the prune+copy. + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); + } + + return count; +} + /** * Layout-driven uninstall orchestrator. * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to @@ -9047,6 +9212,17 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('command/gsd-*'); } + + // Also emit OpenCode-family skills (skills//SKILL.md). OpenCode and + // Kilo support native, on-demand skills in addition to flat commands — see + // resolveRuntimeArtifactLayout's opencode/kilo entries. Derive skills from + // the SAME staged command set (gsdSrc) so both surfaces match exactly. (#784) + const _skillCount = installOpencodeFamilySkills(runtime, targetDir, gsdSrc, pathPrefix); + if (_skillCount > 0) { + console.log(` ${green}✓${reset} Installed ${_skillCount} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } } else if (isCline) { // Cline local install: rules-based only — commands are embedded in .clinerules (generated below). // No skills/commands directory needed for local installs. @@ -11393,6 +11569,8 @@ module.exports = { convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, configureOpencodePermissions, neutralizeAgentReferences, GSD_CODEX_MARKER, @@ -11471,6 +11649,7 @@ module.exports = { ensureCodexHooksJsonSessionStart, readGsdCommandNames, installRuntimeArtifacts, + installOpencodeFamilySkills, uninstallRuntimeArtifacts, parseConfigDirFromArgs, cleanupLegacyGsdCc, diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 85ec443b4..1231e0336 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -110,7 +110,7 @@ GEMINI_CONFIG_DIR=~/.gemini-alt npx @opengsd/gsd-core@latest --gemini --global npx @opengsd/gsd-core@latest --opencode --global ``` -Skills land in `~/.config/opencode/` (XDG) or `~/.opencode/`. The installer converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. +The installer writes three surfaces under `~/.config/opencode/` (XDG) or `~/.opencode/`: flat slash commands in `command/`, file-based subagents in `agents/`, and on-demand skills in `skills//SKILL.md`. It converts agent frontmatter to OpenCode's schema — removing the `tools:` field and converting colour values to hex — and emits each skill with spec-compliant frontmatter (`name` matching the skill directory plus a `description`). Skills are loaded on demand via OpenCode's native skill tool; commands remain invokable as `/gsd-*`. See [Installing without Node.js — OpenCode transformations](#opencode--required-transformations) if you need to understand what changes. **Override the install directory:** @@ -126,7 +126,7 @@ OPENCODE_CONFIG_DIR=~/.config/opencode-alt npx @opengsd/gsd-core@latest --openco npx @opengsd/gsd-core@latest --kilo --global ``` -Skills land in `~/.config/kilo/` (XDG) or `~/.kilo/`. Uses the same OpenCode-style flat markdown command format. +The installer writes the same three surfaces under `~/.config/kilo/` (XDG) or `~/.kilo/` as for OpenCode — flat commands in `command/`, subagents in `agents/`, and skills in `skills//SKILL.md` — since Kilo derives from OpenCode and shares its config schema and skill layout. **Override the install directory:** diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 24a504bf6..1b2a495fe 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -350,11 +350,21 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'opencode': - kinds = [commandsKind('command', 'gsd-', configDir)]; + // OpenCode reads flat slash commands from command/ and on-demand skills + // from skills//SKILL.md (https://opencode.ai/docs/skills). Emit both. + kinds = [ + commandsKind('command', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToOpencodeSkill', 'opencode', configDir), + ]; break; case 'kilo': - kinds = [commandsKind('command', 'gsd-', configDir)]; + // Kilo derives from OpenCode and shares the skills//SKILL.md layout + // (https://kilo.ai/docs/customize/skills). Emit flat commands + skills. + kinds = [ + commandsKind('command', 'gsd-', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToKiloSkill', 'kilo', configDir), + ]; break; default: diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index b6e6ca68b..d3bee37bb 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -28,6 +28,7 @@ const { createTempDir, cleanup } = require('./helpers.cjs'); const { installRuntimeArtifacts, + installOpencodeFamilySkills, parseRuntimeInput, allRuntimes, } = require('../bin/install.js'); @@ -187,6 +188,83 @@ describe('installRuntimeArtifacts — opencode / kilo flat commands', () => { } }); +// ─── #784: installOpencodeFamilySkills — skills + path rewrite + preservation ─ + +// Stage the raw command set the way the installer's _stageSkills() does, so the +// skills writer receives the same input as the flattened-command writer. +function stageRawCommands(runtime, configDir) { + const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global'); + const commandsKind = layout.kinds.find((k) => k.kind === 'commands'); + return commandsKind.stage(RESOLVED_CORE); +} + +describe('installOpencodeFamilySkills — emits skills//SKILL.md (#784)', () => { + for (const runtime of ['opencode', 'kilo']) { + test(`${runtime}: writes gsd-help/SKILL.md with name + description`, (t) => { + const configDir = createTempDir(`gsd-ocs-${runtime}-`); + t.after(() => cleanup(configDir)); + + const raw = stageRawCommands(runtime, configDir); + const count = installOpencodeFamilySkills(runtime, configDir, raw, `${configDir}/`); + assert.ok(count >= 1, 'should report installed skills'); + + const skillMd = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), 'gsd-help/SKILL.md must exist'); + const content = fs.readFileSync(skillMd, 'utf8'); + assert.match(content, /^name: gsd-help$/m, 'name matches dir'); + assert.match(content, /^description: /m, 'description present'); + assert.ok(!/\/gsd:/.test(content), 'no /gsd: colon refs in body'); + }); + + test(`${runtime}: rewrites body paths to the actual install target (#784 path fix)`, (t) => { + const configDir = createTempDir(`gsd-ocp-${runtime}-`); + t.after(() => cleanup(configDir)); + + // Simulate a custom/local install: pathPrefix points at configDir, NOT the + // runtime's default global config dir. Body refs must use pathPrefix. + const pathPrefix = `${configDir}/`; + installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), pathPrefix); + + const defaultBase = runtime === 'kilo' ? '.config/kilo' : '.config/opencode'; + const help = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + // gsd-help references gsd-core workflow files via @/gsd-core/... + assert.ok( + help.includes(`${configDir}/gsd-core/`), + 'gsd-help body must reference the actual install target via pathPrefix', + ); + for (const skillName of fs.readdirSync(path.join(configDir, 'skills'))) { + const body = fs.readFileSync(path.join(configDir, 'skills', skillName, 'SKILL.md'), 'utf8'); + assert.ok( + !body.includes(`~/${defaultBase}/`), + `${skillName}: must not leak hardcoded ~/${defaultBase}/ — should use install target`, + ); + // Regression guard for the prefix-overlap double-rewrite (e.g. kilo-alt-alt). + assert.ok( + !new RegExp(`${defaultBase.replace(/[\\.*+?^${}()|[\]]/g, '\\$&')}-[^/\\s]*-`).test(body), + `${skillName}: must not contain a doubled config-dir suffix`, + ); + } + }); + + test(`${runtime}: preserves user-owned gsd-dev-preferences across reinstall (#784)`, (t) => { + const configDir = createTempDir(`gsd-ocd-${runtime}-`); + t.after(() => cleanup(configDir)); + + const userSkill = path.join(configDir, 'skills', 'gsd-dev-preferences'); + fs.mkdirSync(userSkill, { recursive: true }); + const marker = '---\nname: gsd-dev-preferences\ndescription: mine\n---\nKEEP ME\n'; + fs.writeFileSync(path.join(userSkill, 'SKILL.md'), marker); + + installOpencodeFamilySkills(runtime, configDir, stageRawCommands(runtime, configDir), `${configDir}/`); + + const after = fs.readFileSync(path.join(userSkill, 'SKILL.md'), 'utf8'); + assert.ok(after.includes('KEEP ME'), 'user-owned dev-preferences must survive reinstall'); + // GSD-managed skills should also be present. + assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'))); + }); + } +}); + // ─── Section 7: uninstallRuntimeArtifacts — all runtimes ───────────────────── describe('uninstallRuntimeArtifacts — removes gsd-owned entries, preserves foreign', () => { diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index c2be44d97..fedf9ed20 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -236,28 +236,44 @@ describe('resolveRuntimeArtifactLayout — cline', () => { }); describe('resolveRuntimeArtifactLayout — opencode', () => { - test('returns correct layout for opencode', () => { + test('returns commands + skills layout for opencode (#784)', () => { const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); assert.strictEqual(layout.runtime, 'opencode'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'command'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const commands = layout.kinds.find((k) => k.kind === 'commands'); + assert.ok(commands, 'should have a commands kind'); + assert.strictEqual(commands.destSubpath, 'command'); + assert.strictEqual(commands.prefix, 'gsd-'); + assert.strictEqual(typeof commands.stage, 'function'); + + const skills = layout.kinds.find((k) => k.kind === 'skills'); + assert.ok(skills, 'should have a skills kind'); + assert.strictEqual(skills.destSubpath, 'skills'); + assert.strictEqual(skills.prefix, 'gsd-'); + assert.strictEqual(typeof skills.stage, 'function'); }); }); describe('resolveRuntimeArtifactLayout — kilo', () => { - test('returns correct layout for kilo', () => { + test('returns commands + skills layout for kilo (#784)', () => { const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR); assert.strictEqual(layout.runtime, 'kilo'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'command'); - assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); - assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds.length, 2); + + const commands = layout.kinds.find((k) => k.kind === 'commands'); + assert.ok(commands, 'should have a commands kind'); + assert.strictEqual(commands.destSubpath, 'command'); + assert.strictEqual(commands.prefix, 'gsd-'); + assert.strictEqual(typeof commands.stage, 'function'); + + const skills = layout.kinds.find((k) => k.kind === 'skills'); + assert.ok(skills, 'should have a skills kind'); + assert.strictEqual(skills.destSubpath, 'skills'); + assert.strictEqual(skills.prefix, 'gsd-'); + assert.strictEqual(typeof skills.stage, 'function'); }); }); @@ -428,6 +444,33 @@ describe('stage — opencode commands kind', () => { }); }); +describe('stage — opencode/kilo skills kind (#784)', () => { + for (const runtime of ['opencode', 'kilo']) { + test(`${runtime} skills stage writes gsd-/SKILL.md with name + description`, () => { + const layout = resolveRuntimeArtifactLayout(runtime, FAKE_STAGE_DIR); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + const content = fs.readFileSync(skillMd, 'utf8'); + // OpenCode skill spec: name must match the dir, description required. + assert.ok(content.startsWith('---\n'), 'SKILL.md must open with frontmatter'); + assert.match(content, new RegExp(`^name: ${entry}$`, 'm'), `name must equal dir ${entry}`); + assert.match(content, /^description: /m, 'description frontmatter required'); + // No colon-namespace command leaks in the converted body. + assert.ok(!/\/gsd:/.test(content), 'body must not contain /gsd: colon refs'); + } + }); + } +}); + describe('stage — cursor commands kind (#785)', () => { test('cursor commands kind stage returns directory with converted .md files', () => { const layout = resolveRuntimeArtifactLayout('cursor', FAKE_STAGE_DIR); diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index a799a62b7..3edccd6e6 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -18,6 +18,8 @@ const { convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, convertClaudeToGeminiAgent, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, neutralizeAgentReferences, } = require('../bin/install.js'); @@ -344,3 +346,70 @@ describe('neutralizeAgentReferences', () => { assert.ok(result.includes('claude-code'), 'claude-code preserved'); }); }); + +// ─── OpenCode-family skill converters (SKILL.md) — #784 ────────────────────── + +const SKILL_SAMPLE_COMMAND = `--- +description: Show available GSD commands and usage guide +argument-hint: "[topic]" +allowed-tools: + - Read + - Bash +--- + +Run \`/gsd:help\` to see the guide. AskUserQuestion when unsure. +`; + +const SKILL_BETA_COMMAND = `--- +description: "[BETA] Offload plan phase to the cloud and import back." +--- + +Body for /gsd:ultraplan-phase. +`; + +describe('convertClaudeCommandToOpencodeSkill / convertClaudeCommandToKiloSkill (#784)', () => { + const cases = [ + { label: 'opencode', convert: convertClaudeCommandToOpencodeSkill }, + { label: 'kilo', convert: convertClaudeCommandToKiloSkill }, + ]; + + for (const { label, convert } of cases) { + describe(`${label} skill conversion`, () => { + test('emits SKILL.md frontmatter with name matching the skill dir', () => { + const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); + assert.ok(out.startsWith('---\n'), 'opens with frontmatter'); + assert.match(out, /^name: gsd-help$/m, 'name equals the skill name'); + }); + + test('preserves the description from the source command', () => { + const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); + assert.match(out, /^description: "Show available GSD commands and usage guide"$/m); + }); + + test('drops the command tools/permission block (skills inherit perms)', () => { + const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); + const fmEnd = out.indexOf('\n---', 4); + const fm = out.slice(0, fmEnd); + assert.ok(!/tools:/.test(fm), 'no tools block in skill frontmatter'); + assert.ok(!/permission:/.test(fm), 'no permission block in skill frontmatter'); + }); + + test('rewrites /gsd: colon refs to hyphen form in the body', () => { + const out = convert(SKILL_SAMPLE_COMMAND, 'gsd-help'); + assert.ok(!/\/gsd:/.test(out), 'no /gsd: colon refs remain'); + assert.match(out, /\/gsd-help/, 'colon ref rewritten to hyphen form'); + }); + + test('quotes descriptions with leading YAML flow indicators ([BETA])', () => { + const out = convert(SKILL_BETA_COMMAND, 'gsd-ultraplan-phase'); + assert.match(out, /^description: "\[BETA\] /m, 'leading [BETA] safely quoted'); + }); + + test('falls back to a synthetic description when none present', () => { + const out = convert('Body only, no frontmatter.', 'gsd-mystery'); + assert.match(out, /^name: gsd-mystery$/m); + assert.match(out, /^description: "Run GSD workflow gsd-mystery\."$/m); + }); + }); + } +});