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/.changeset/783-kilo-global-skills-base.md b/.changeset/783-kilo-global-skills-base.md new file mode 100644 index 000000000..5b439e4f0 --- /dev/null +++ b/.changeset/783-kilo-global-skills-base.md @@ -0,0 +1,7 @@ +--- +type: Fixed +pr: 806 +--- +**`getGlobalSkillsBase('kilo')` now resolves to `~/.kilo/skills`** — where Kilo Code actually discovers global skills — instead of `~/.config/kilo/skills`. Per [Kilo Code docs](https://kilo.ai/docs/customize/skills), global skills live in the `.kilo` directory within HOME (`~/.kilo/skills/`), independent of the XDG-based config dir at `~/.config/kilo`. The kilo.jsonc config dir (`~/.config/kilo`) and the `command/` path used by the installer are correct and unchanged. Blast radius: this corrects the resolved skills-base path used by doctor/status checks and agent-skills-block resolution (`init.cjs`); the installer writes commands (not skills) for Kilo, so no files were previously being written to the wrong location. + + 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/.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/.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/.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/.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/.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/.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 0dc80ffd2..2e4880ed9 100755 --- a/bin/install.js +++ b/bin/install.js @@ -102,6 +102,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']; @@ -477,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}--kimi${reset} Install for Kimi CLI 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 Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\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 / KIMI_CONFIG_DIR / 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 Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\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}--kimi${reset} Install for Kimi CLI 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 Kimi CLI globally${reset}\n npx ${pkg.name} --kimi --global\n\n ${dim}# Install for Kimi CLI under ~/.kimi-code${reset}\n npx ${pkg.name} --kimi --global --config-dir ~/.kimi-code\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 / KIMI_CONFIG_DIR / 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 Kimi CLI defaults to the first existing generic skills root: ${cyan}~/.config/agents/skills${reset}, then ${cyan}~/.agents/skills${reset}; if neither exists, GSD creates ${cyan}~/.config/agents${reset}.\n Use ${cyan}--config-dir ~/.kimi-code${reset} or ${cyan}KIMI_CONFIG_DIR=~/.kimi-code${reset} for brand-specific Kimi installs.\n`); process.exit(0); } @@ -2537,6 +2563,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), @@ -2926,9 +2975,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'); @@ -2945,6 +3000,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) { @@ -5382,6 +5474,264 @@ 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. + * + * 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. @@ -6002,6 +6352,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 @@ -6054,6 +6468,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; @@ -6086,16 +6525,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) @@ -6280,7 +6710,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 { @@ -6337,6 +6767,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. @@ -6359,6 +6829,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); @@ -6466,7 +6952,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; } @@ -6750,8 +7240,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' || kind.kind === 'kimi-agents') { 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 }); @@ -6773,8 +7269,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 })) { @@ -6795,7 +7291,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) { @@ -6805,11 +7301,92 @@ 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); } } } +/** + * 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 @@ -7197,10 +7774,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(), '~') @@ -7224,6 +7805,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}`); @@ -7301,6 +7900,72 @@ 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). + } + + // 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). @@ -7501,8 +8166,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] @@ -8077,11 +8745,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); + } } } @@ -8872,7 +9544,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 @@ -8880,9 +9553,12 @@ 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 || - isKimi || (runtime === 'claude' && isGlobal); + isKimi || + (runtime === 'claude' && isGlobal) || + (isCline && isGlobal); if (_isSkillsRuntime) { // Layout-driven install for skills-based runtimes (full and minimal modes) @@ -8942,6 +9618,37 @@ 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) { + 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 @@ -8957,9 +9664,21 @@ 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 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 @@ -9802,8 +10521,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 }; } @@ -9815,22 +10550,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 }; } @@ -10334,6 +11060,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 @@ -11236,6 +12008,7 @@ module.exports = { computePathPrefix, getCodexSkillAdapterHeader, convertClaudeCommandToCursorSkill, + convertClaudeCommandToCursorCommand, convertClaudeAgentToCursorAgent, convertClaudeToGeminiMarkdown, convertSlashCommandsToGeminiMentions, @@ -11270,6 +12043,8 @@ module.exports = { buildKimiAgentArtifacts, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, + convertClaudeCommandToOpencodeSkill, + convertClaudeCommandToKiloSkill, configureOpencodePermissions, neutralizeAgentReferences, GSD_CODEX_MARKER, @@ -11288,6 +12063,9 @@ module.exports = { GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, stripGsdFromCopilotInstructions, + GSD_COPILOT_HOOK_FILE, + buildCopilotHookConfig, + writeCopilotHookConfig, convertClaudeToAntigravityContent, convertClaudeCommandToAntigravitySkill, convertClaudeAgentToAntigravityAgent, @@ -11306,7 +12084,16 @@ module.exports = { convertClaudeCommandToCodebuddySkill, convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, + convertClaudeCommandToClineSkill, convertClaudeAgentToClineAgent, + buildClineRulesBody, + buildClineAgentsMdBody, + buildClinePreToolUseHook, + writeClineArtifacts, + mergeGsdAgentsMd, + stripGsdFromAgentsMd, + GSD_AGENTS_MD_MARKER, + GSD_AGENTS_MD_CLOSE_MARKER, writeManifest, saveLocalPatches, reportLocalPatches, @@ -11338,9 +12125,11 @@ module.exports = { ensureCodexHooksJsonSessionStart, readGsdCommandNames, installRuntimeArtifacts, + installOpencodeFamilySkills, uninstallRuntimeArtifacts, parseConfigDirFromArgs, cleanupLegacyGsdCc, + _applyRuntimeRewrites, }; // Main logic — only run when not loaded as a module for testing diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 61e8d187e..0e8b7aa33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -804,7 +804,7 @@ The migration-specific ownership and source snapshots live in | Gemini CLI | `~/.gemini` | `./.gemini` | `commands/gsd/*.toml` | `agents/gsd-*.md` | `settings.json` feature flag, hooks, and statusline | | Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file /agents/gsd.yaml`; no GSD hooks or 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/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/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/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 65ed9b87e..d7229890b 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:** @@ -215,6 +215,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 @@ -257,17 +264,40 @@ 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. +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 ``` -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 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 +> target cover the common cases. --- @@ -297,6 +327,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 @@ -305,7 +348,7 @@ QWEN_CONFIG_DIR=~/.qwen-alt npx @opengsd/gsd-core@latest --qwen --global 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/docs/installer-migrations.md b/docs/installer-migrations.md index 7de591e25..43d911ffb 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -370,7 +370,7 @@ for the new shape before changing migration behavior. | 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 | | Kimi CLI | Agent Skills in `skills/gsd-*/SKILL.md`; explicit custom agent YAML/prompt artifacts in `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*`; `gsd-core/` payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline | Global `KIMI_CONFIG_DIR`, explicit `--config-dir`, or first-existing generic skills root: `~/.config/agents` when `~/.config/agents/skills` exists or no generic skills root exists yet, otherwise `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not; local `--kimi --local` is guarded and writes no project-level artifacts | GSD owns only generated `skills/gsd-*`, `agents/gsd.*`, `agents/subagents/gsd-*`, installed `gsd-core/` payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed `gsd-local-patches/`; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. | [Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills.html), [Agents and Subagents](https://moonshotai.github.io/kimi-cli/en/customization/agents.html), [Tools](https://moonshotai.github.io/kimi-code/en/reference/tools.html); docs checked 2026-06-07 | | 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/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 d52ced4a9..deb2dacdb 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -22,6 +22,7 @@ const { stageSkillsForProfile, stageAgentsForProfile, stageSkillsForRuntimeAsSkills, + stageCommandsForRuntimeFlat, } = installProfiles; // In .cts (CommonJS output) files, `require` is available as a global. @@ -271,6 +272,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 // --------------------------------------------------------------------------- @@ -303,7 +338,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': @@ -327,7 +369,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': @@ -347,7 +392,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'cline': - kinds = []; + kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : []; break; case 'kimi': @@ -360,11 +405,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/src/runtime-homes.cts b/src/runtime-homes.cts index a21d2f343..18d33d75d 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). * kimi — Agent Skills are discovered from Kimi's generic user roots: * ~/.config/agents/skills (recommended) then ~/.agents/skills, * with Kimi selecting the first existing generic skills directory. @@ -135,7 +135,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': @@ -202,18 +204,25 @@ 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'); + } + // 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'); } /** * 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 383751d30..dc9637dd1 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', @@ -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/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'), + ); + }); + }); +}); 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/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 404a6baee..2a15b6f7e 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, @@ -75,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(() => { @@ -86,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')); }); @@ -111,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')); @@ -1040,6 +1079,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 +1466,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 +1591,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 +1635,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('