diff --git a/README.md b/README.md index f6a4b5427..2d4d15f29 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [Português](README.pt-BR.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) -**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Cline, and CodeBuddy.** +**A light-weight and powerful meta-prompting, context engineering and spec-driven development system for Claude Code, OpenCode, Gemini CLI, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Hermes Agent, Cline, and CodeBuddy.** **Solves context rot — the quality degradation that happens as Claude fills its context window.** @@ -104,11 +104,11 @@ npx get-shit-done-cc@latest ``` The installer prompts you to choose: -1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session) +1. **Runtime** — Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Cursor, Windsurf, Antigravity, Augment, Trae, Qwen Code, Hermes Agent, CodeBuddy, Cline, or all (interactive multi-select — pick multiple runtimes in a single install session) 2. **Location** — Global (all projects) or local (current project only) Verify with: -- Claude Code / Gemini / Copilot / Antigravity / Qwen Code: `/gsd-help` +- Claude Code / Gemini / Copilot / Antigravity / Qwen Code / Hermes Agent: `/gsd-help` - OpenCode / Kilo / Augment / Trae / CodeBuddy: `/gsd-help` - Codex: `$gsd-help` - Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists @@ -179,6 +179,10 @@ npx get-shit-done-cc --trae --local # Install to ./.trae/ npx get-shit-done-cc --qwen --global # Install to ~/.qwen/ npx get-shit-done-cc --qwen --local # Install to ./.qwen/ +# Hermes Agent +npx get-shit-done-cc --hermes --global # Install to ~/.hermes/ (honors $HERMES_HOME) +npx get-shit-done-cc --hermes --local # Install to ./.hermes/ + # CodeBuddy npx get-shit-done-cc --codebuddy --global # Install to ~/.codebuddy/ npx get-shit-done-cc --codebuddy --local # Install to ./.codebuddy/ @@ -192,7 +196,7 @@ npx get-shit-done-cc --all --global # Install to all directories ``` Use `--global` (`-g`) or `--local` (`-l`) to skip the location prompt. -Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--qwen`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt. +Use `--claude`, `--opencode`, `--gemini`, `--kilo`, `--codex`, `--copilot`, `--cursor`, `--windsurf`, `--antigravity`, `--augment`, `--trae`, `--qwen`, `--hermes`, `--codebuddy`, `--cline`, or `--all` to skip the runtime prompt. The GSD SDK CLI (`gsd-sdk`) is installed automatically (required by `/gsd-*` commands). Pass `--no-sdk` to skip the SDK install, or `--sdk` to force a reinstall. @@ -919,6 +923,7 @@ npx get-shit-done-cc --antigravity --global --uninstall npx get-shit-done-cc --augment --global --uninstall npx get-shit-done-cc --trae --global --uninstall npx get-shit-done-cc --qwen --global --uninstall +npx get-shit-done-cc --hermes --global --uninstall npx get-shit-done-cc --codebuddy --global --uninstall npx get-shit-done-cc --cline --global --uninstall @@ -935,6 +940,7 @@ npx get-shit-done-cc --antigravity --local --uninstall npx get-shit-done-cc --augment --local --uninstall npx get-shit-done-cc --trae --local --uninstall npx get-shit-done-cc --qwen --local --uninstall +npx get-shit-done-cc --hermes --local --uninstall npx get-shit-done-cc --codebuddy --local --uninstall npx get-shit-done-cc --cline --local --uninstall ``` diff --git a/bin/install.js b/bin/install.js index 1ad6f43b6..779a1ae55 100755 --- a/bin/install.js +++ b/bin/install.js @@ -93,6 +93,7 @@ const hasWindsurf = args.includes('--windsurf'); const hasAugment = args.includes('--augment'); const hasTrae = args.includes('--trae'); const hasQwen = args.includes('--qwen'); +const hasHermes = args.includes('--hermes'); const hasCodebuddy = args.includes('--codebuddy'); const hasCline = args.includes('--cline'); const hasBoth = args.includes('--both'); // Legacy flag, keeps working @@ -113,7 +114,7 @@ if (hasSdk && hasNoSdk) { // Runtime selection - can be set by flags or interactive prompt let selectedRuntimes = []; if (hasAll) { - selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy', 'cline']; + selectedRuntimes = ['claude', 'kilo', 'opencode', 'gemini', 'codex', 'copilot', 'antigravity', 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', 'cline']; } else if (hasBoth) { selectedRuntimes = ['claude', 'opencode']; } else { @@ -129,6 +130,7 @@ if (hasAll) { if (hasAugment) selectedRuntimes.push('augment'); if (hasTrae) selectedRuntimes.push('trae'); if (hasQwen) selectedRuntimes.push('qwen'); + if (hasHermes) selectedRuntimes.push('hermes'); if (hasCodebuddy) selectedRuntimes.push('codebuddy'); if (hasCline) selectedRuntimes.push('cline'); } @@ -180,6 +182,7 @@ function getDirName(runtime) { if (runtime === 'augment') return '.augment'; if (runtime === 'trae') return '.trae'; if (runtime === 'qwen') return '.qwen'; + if (runtime === 'hermes') return '.hermes'; if (runtime === 'codebuddy') return '.codebuddy'; if (runtime === 'cline') return '.cline'; return '.claude'; @@ -215,6 +218,7 @@ function getConfigDirFromHome(runtime, isGlobal) { if (runtime === 'augment') return "'.augment'"; if (runtime === 'trae') return "'.trae'"; if (runtime === 'qwen') return "'.qwen'"; + if (runtime === 'hermes') return "'.hermes'"; if (runtime === 'codebuddy') return "'.codebuddy'"; if (runtime === 'cline') return "'.cline'"; return "'.claude'"; @@ -389,6 +393,19 @@ function getGlobalDir(runtime, explicitDir = null) { return path.join(os.homedir(), '.qwen'); } + if (runtime === 'hermes') { + // Hermes Agent: --config-dir > HERMES_HOME > ~/.hermes + // Honors HERMES_HOME which Hermes users set for profile mode / Docker + // deploys (docs: https://hermes-agent.nousresearch.com/docs). + if (explicitDir) { + return expandTilde(explicitDir); + } + if (process.env.HERMES_HOME) { + return expandTilde(process.env.HERMES_HOME); + } + return path.join(os.homedir(), '.hermes'); + } + if (runtime === 'codebuddy') { // CodeBuddy: --config-dir > CODEBUDDY_CONFIG_DIR > ~/.codebuddy if (explicitDir) { @@ -431,7 +448,7 @@ const banner = '\n' + '\n' + ' Get Shit Done ' + dim + 'v' + pkg.version + reset + '\n' + ' A meta-prompting, context engineering and spec-driven\n' + - ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Cline and CodeBuddy by TÂCHES.\n'; + ' development system for Claude Code, OpenCode, Gemini, Kilo, Codex, Copilot, Antigravity, Cursor, Windsurf, Augment, Trae, Qwen Code, Hermes Agent, Cline and CodeBuddy by TÂCHES.\n'; // Parse --config-dir argument function parseConfigDirArg() { @@ -469,7 +486,7 @@ if (hasUninstall) { // Show help if requested if (hasHelp) { - console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--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}--minimal${reset} Install only the main-loop skills (new-project,\n discuss-phase, plan-phase, execute-phase, help, update)\n and zero gsd-* subagents. Cuts cold-start system-prompt\n overhead from ~12k tokens to ~700 — useful for local LLMs\n with 32K–128K context. Re-run \`gsd update\` (without --minimal)\n to expand to the full surface. Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); + console.log(` ${yellow}Usage:${reset} npx get-shit-done-cc [options]\n\n ${yellow}Options:${reset}\n ${cyan}-g, --global${reset} Install globally (to config directory)\n ${cyan}-l, --local${reset} Install locally (to current directory)\n ${cyan}--claude${reset} Install for Claude Code only\n ${cyan}--opencode${reset} Install for OpenCode only\n ${cyan}--gemini${reset} Install for Gemini only\n ${cyan}--kilo${reset} Install for Kilo only\n ${cyan}--codex${reset} Install for Codex only\n ${cyan}--copilot${reset} Install for Copilot only\n ${cyan}--antigravity${reset} Install for Antigravity only\n ${cyan}--cursor${reset} Install for Cursor only\n ${cyan}--windsurf${reset} Install for Windsurf only\n ${cyan}--augment${reset} Install for Augment only\n ${cyan}--trae${reset} Install for Trae only\n ${cyan}--qwen${reset} Install for Qwen Code only\n ${cyan}--hermes${reset} Install for Hermes Agent only\n ${cyan}--cline${reset} Install for Cline only\n ${cyan}--codebuddy${reset} Install for CodeBuddy only\n ${cyan}--all${reset} Install for all runtimes\n ${cyan}-u, --uninstall${reset} Uninstall GSD (remove all GSD files)\n ${cyan}-c, --config-dir ${reset} Specify custom config directory\n ${cyan}-h, --help${reset} Show this help message\n ${cyan}--force-statusline${reset} Replace existing statusline config\n ${cyan}--portable-hooks${reset} Emit \$HOME-relative hook paths in settings.json\n (for WSL/Docker bind-mount setups; also GSD_PORTABLE_HOOKS=1)\n ${cyan}--minimal${reset} Install only the main-loop skills (new-project,\n discuss-phase, plan-phase, execute-phase, help, update)\n and zero gsd-* subagents. Cuts cold-start system-prompt\n overhead from ~12k tokens to ~700 — useful for local LLMs\n with 32K–128K context. Re-run \`gsd update\` (without --minimal)\n to expand to the full surface. Alias: --core-only.\n\n ${yellow}Examples:${reset}\n ${dim}# Interactive install (prompts for runtime and location)${reset}\n npx get-shit-done-cc\n\n ${dim}# Install for Claude Code globally${reset}\n npx get-shit-done-cc --claude --global\n\n ${dim}# Install for Gemini globally${reset}\n npx get-shit-done-cc --gemini --global\n\n ${dim}# Install for Kilo globally${reset}\n npx get-shit-done-cc --kilo --global\n\n ${dim}# Install for Codex globally${reset}\n npx get-shit-done-cc --codex --global\n\n ${dim}# Install for Copilot globally${reset}\n npx get-shit-done-cc --copilot --global\n\n ${dim}# Install for Copilot locally${reset}\n npx get-shit-done-cc --copilot --local\n\n ${dim}# Install for Antigravity globally${reset}\n npx get-shit-done-cc --antigravity --global\n\n ${dim}# Install for Antigravity locally${reset}\n npx get-shit-done-cc --antigravity --local\n\n ${dim}# Install for Cursor globally${reset}\n npx get-shit-done-cc --cursor --global\n\n ${dim}# Install for Cursor locally${reset}\n npx get-shit-done-cc --cursor --local\n\n ${dim}# Install for Windsurf globally${reset}\n npx get-shit-done-cc --windsurf --global\n\n ${dim}# Install for Windsurf locally${reset}\n npx get-shit-done-cc --windsurf --local\n\n ${dim}# Install for Augment globally${reset}\n npx get-shit-done-cc --augment --global\n\n ${dim}# Install for Augment locally${reset}\n npx get-shit-done-cc --augment --local\n\n ${dim}# Install for Trae globally${reset}\n npx get-shit-done-cc --trae --global\n\n ${dim}# Install for Trae locally${reset}\n npx get-shit-done-cc --trae --local\n\n ${dim}# Install for Hermes Agent globally${reset}\n npx get-shit-done-cc --hermes --global\n\n ${dim}# Install for Hermes Agent locally${reset}\n npx get-shit-done-cc --hermes --local\n\n ${dim}# Install for Cline locally${reset}\n npx get-shit-done-cc --cline --local\n\n ${dim}# Install for CodeBuddy globally${reset}\n npx get-shit-done-cc --codebuddy --global\n\n ${dim}# Install for CodeBuddy locally${reset}\n npx get-shit-done-cc --codebuddy --local\n\n ${dim}# Install for all runtimes globally${reset}\n npx get-shit-done-cc --all --global\n\n ${dim}# Install to custom config directory${reset}\n npx get-shit-done-cc --kilo --global --config-dir ~/.kilo-work\n\n ${dim}# Install to current project only${reset}\n npx get-shit-done-cc --claude --local\n\n ${dim}# Uninstall GSD from Cursor globally${reset}\n npx get-shit-done-cc --cursor --global --uninstall\n\n ${yellow}Notes:${reset}\n The --config-dir option is useful when you have multiple configurations.\n It takes priority over CLAUDE_CONFIG_DIR / OPENCODE_CONFIG_DIR / GEMINI_CONFIG_DIR / KILO_CONFIG_DIR / CODEX_HOME / COPILOT_CONFIG_DIR / ANTIGRAVITY_CONFIG_DIR / CURSOR_CONFIG_DIR / WINDSURF_CONFIG_DIR / AUGMENT_CONFIG_DIR / TRAE_CONFIG_DIR / QWEN_CONFIG_DIR / HERMES_HOME / CLINE_CONFIG_DIR / CODEBUDDY_CONFIG_DIR environment variables.\n`); process.exit(0); } @@ -1176,7 +1193,7 @@ function skillFrontmatterName(skillDirName) { * Emits `name: gsd-` (hyphen) so Skill(skill="gsd-") calls and * tab autocomplete use the canonical command namespace. */ -function convertClaudeCommandToClaudeSkill(content, skillName) { +function convertClaudeCommandToClaudeSkill(content, skillName, runtime = null) { const { frontmatter, body } = extractFrontmatterAndBody(content); if (!frontmatter) return content; @@ -1196,6 +1213,10 @@ function convertClaudeCommandToClaudeSkill(content, skillName) { // Reconstruct frontmatter in Claude skill format const frontmatterName = skillFrontmatterName(skillName); let fm = `---\nname: ${frontmatterName}\ndescription: ${yamlQuote(description)}\n`; + // Hermes' SKILL.md spec lists `version` as a required frontmatter field. + // Track GSD's package version so Hermes' skill_view() reports a stable + // identifier per install. + if (runtime === 'hermes') fm += `version: ${yamlQuote(pkg.version)}\n`; if (argumentHint) fm += `argument-hint: ${yamlQuote(argumentHint)}\n`; if (agent) fm += `agent: ${agent}\n`; if (toolsBlock) fm += toolsBlock; @@ -5338,14 +5359,23 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti content = content.replace(/~\/\.qwen\//g, pathPrefix); content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); content = content.replace(/\.\/\.qwen\//g, `./${getDirName(runtime)}/`); + content = content.replace(/~\/\.hermes\//g, pathPrefix); + content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + content = content.replace(/\.\/\.hermes\//g, `./${getDirName(runtime)}/`); // Qwen reuses Claude skill format but needs runtime-specific content replacement if (runtime === 'qwen') { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); content = content.replace(/\.claude\//g, '.qwen/'); } + // Hermes Agent reuses Claude skill format; rewrite branding + paths. + if (runtime === 'hermes') { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/\.claude\//g, '.hermes/'); + } content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToClaudeSkill(content, skillName); + content = convertClaudeCommandToClaudeSkill(content, skillName, runtime); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); } @@ -5354,6 +5384,33 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti recurse(srcDir, prefix); } +/** + * Write the Hermes "gsd" category DESCRIPTION.md. + * Hermes' skill loader reads DESCRIPTION.md at the top of each skill category + * directory and surfaces it in the system prompt so the model knows when to + * reach for that category. Per spec in #2841 we collapse all 86 GSD commands + * under a single "gsd" category to keep system-prompt overhead bounded. + */ +function writeHermesCategoryDescription(categoryDir) { + fs.mkdirSync(categoryDir, { recursive: true }); + const body = [ + '---', + 'name: gsd', + `version: ${pkg.version}`, + 'description: Get Shit Done — disciplined planning, execution, and shipping workflows. Use any gsd-* skill in this category to drive a project through new-project → discuss-phase → plan-phase → execute-phase → ship.', + '---', + '', + '# Get Shit Done (GSD)', + '', + 'GSD is a structured development workflow. Skills in this category cover', + 'project initialization, phase planning, execution, code review, and shipping.', + '', + 'Invoke any `gsd-*` skill in this category to drive the corresponding step.', + '', + ].join('\n'); + fs.writeFileSync(path.join(categoryDir, 'DESCRIPTION.md'), body); +} + /** * Recursively install GSD commands as Antigravity skills. * Each command becomes a skill-name/ folder containing SKILL.md. @@ -5484,6 +5541,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; const isQwen = runtime === 'qwen'; + const isHermes = runtime === 'hermes'; const isCline = runtime === 'cline'; const dirName = getDirName(runtime); @@ -5515,6 +5573,9 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(/~\/\.qwen\//g, pathPrefix); content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); + content = content.replace(/~\/\.hermes\//g, pathPrefix); + content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); } content = processAttribution(content, getCommitAttribution(runtime)); @@ -5557,6 +5618,11 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); content = content.replace(/\.claude\//g, '.qwen/'); fs.writeFileSync(destPath, content); + } else if (isHermes) { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/\.claude\//g, '.hermes/'); + fs.writeFileSync(destPath, content); } else { fs.writeFileSync(destPath, content); } @@ -5608,6 +5674,13 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand jsContent = jsContent.replace(/CLAUDE\.md/g, 'QWEN.md'); jsContent = jsContent.replace(/\bClaude Code\b/g, 'Qwen Code'); fs.writeFileSync(destPath, jsContent); + } else if (isHermes && (entry.name.endsWith('.cjs') || entry.name.endsWith('.js'))) { + let jsContent = fs.readFileSync(srcPath, 'utf8'); + jsContent = jsContent.replace(/\.claude\/skills\//g, '.hermes/skills/'); + jsContent = jsContent.replace(/\.claude\//g, '.hermes/'); + jsContent = jsContent.replace(/CLAUDE\.md/g, 'HERMES.md'); + jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent'); + fs.writeFileSync(destPath, jsContent); } else { fs.copyFileSync(srcPath, destPath); } @@ -5786,6 +5859,7 @@ function uninstall(isGlobal, runtime = 'claude') { const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; const isQwen = runtime === 'qwen'; + const isHermes = runtime === 'hermes'; const isCodebuddy = runtime === 'codebuddy'; const dirName = getDirName(runtime); @@ -5810,6 +5884,7 @@ function uninstall(isGlobal, runtime = 'claude') { if (runtime === 'augment') runtimeLabel = 'Augment'; if (runtime === 'trae') runtimeLabel = 'Trae'; if (runtime === 'qwen') runtimeLabel = 'Qwen Code'; + if (runtime === 'hermes') runtimeLabel = 'Hermes Agent'; if (runtime === 'codebuddy') runtimeLabel = 'CodeBuddy'; console.log(` Uninstalling GSD from ${cyan}${runtimeLabel}${reset} at ${cyan}${locationLabel}${reset}\n`); @@ -5957,6 +6032,44 @@ function uninstall(isGlobal, runtime = 'claude') { } } + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); + fs.rmSync(legacyCommandsDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); + } + } else if (isHermes) { + // Hermes Agent: skills live under skills/gsd/ as a single category (per + // spec in #2841). Remove the whole gsd/ category directory; also clean up + // any pre-nested-layout flat skills/gsd-*/ left over from older installs. + const skillsDir = path.join(targetDir, 'skills'); + let skillCount = 0; + const nestedCategoryDir = path.join(skillsDir, 'gsd'); + if (fs.existsSync(nestedCategoryDir)) { + const entries = fs.readdirSync(nestedCategoryDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + skillCount++; + } + } + fs.rmSync(nestedCategoryDir, { recursive: true }); + } + if (fs.existsSync(skillsDir)) { + const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); + skillCount++; + } + } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Hermes Agent skills`); + } + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsDir)) { const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); @@ -6591,10 +6704,16 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { const isWindsurf = runtime === 'windsurf'; const isTrae = runtime === 'trae'; const isCline = runtime === 'cline'; + const isHermes = runtime === 'hermes'; const gsdDir = path.join(configDir, 'get-shit-done'); const commandsDir = path.join(configDir, 'commands', 'gsd'); const opencodeCommandDir = path.join(configDir, 'command'); - const codexSkillsDir = path.join(configDir, 'skills'); + // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). + // All other runtimes that use the Codex-style skills layout use a flat skills/ root. + const codexSkillsDir = isHermes + ? path.join(configDir, 'skills', 'gsd') + : path.join(configDir, 'skills'); + const codexSkillsManifestPrefix = isHermes ? 'skills/gsd/' : 'skills/'; const agentsDir = path.join(configDir, 'agents'); const manifest = { version: pkg.version, @@ -6631,7 +6750,14 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); for (const [rel, hash] of Object.entries(skillHashes)) { - manifest.files[`skills/${skillName}/${rel}`] = hash; + manifest.files[`${codexSkillsManifestPrefix}${skillName}/${rel}`] = hash; + } + } + // For Hermes, also hash the category DESCRIPTION.md so reinstall detects drift. + if (isHermes) { + const descPath = path.join(codexSkillsDir, 'DESCRIPTION.md'); + if (fs.existsSync(descPath)) { + manifest.files['skills/gsd/DESCRIPTION.md'] = fileHash(descPath); } } } @@ -6783,6 +6909,7 @@ function install(isGlobal, runtime = 'claude') { const isAugment = runtime === 'augment'; const isTrae = runtime === 'trae'; const isQwen = runtime === 'qwen'; + const isHermes = runtime === 'hermes'; const isCodebuddy = runtime === 'codebuddy'; const isCline = runtime === 'cline'; const dirName = getDirName(runtime); @@ -6833,6 +6960,7 @@ function install(isGlobal, runtime = 'claude') { if (isAugment) runtimeLabel = 'Augment'; if (isTrae) runtimeLabel = 'Trae'; if (isQwen) runtimeLabel = 'Qwen Code'; + if (isHermes) runtimeLabel = 'Hermes Agent'; if (isCodebuddy) runtimeLabel = 'CodeBuddy'; if (isCline) runtimeLabel = 'Cline'; @@ -6958,6 +7086,48 @@ function install(isGlobal, runtime = 'claude') { failures.push('skills/gsd-*'); } + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); + fs.rmSync(legacyCommandsDir, { recursive: true }); + console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); + restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); + } + } else if (isHermes) { + // Hermes Agent: nests all GSD skills under skills/gsd/ as a single + // category (per spec in #2841) so the 86 gsd-* skills collapse into a + // single entry in Hermes' system prompt instead of 86 top-level entries. + // The Claude skill pipeline writes each gsd-/SKILL.md inside the + // gsd/ category dir, alongside a DESCRIPTION.md that Hermes uses as the + // category summary. + const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + copyCommandsAsClaudeSkills(gsdSrc, hermesSkillsDir, 'gsd', pathPrefix, runtime, isGlobal); + writeHermesCategoryDescription(hermesSkillsDir); + if (fs.existsSync(hermesSkillsDir)) { + const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`); + } else { + failures.push('skills/gsd/gsd-*'); + } + } else { + failures.push('skills/gsd/gsd-*'); + } + + // Migrate any prior flat-layout install (skills/gsd-*/) into the nested + // skills/gsd/ category — keeps existing users from carrying duplicates + // after upgrading to the nested layout. + const flatSkillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(flatSkillsDir)) { + const stale = fs.readdirSync(flatSkillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + for (const entry of stale) { + fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); + } + } + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsDir)) { const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); @@ -7165,6 +7335,10 @@ function install(isGlobal, runtime = 'claude') { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); content = content.replace(/\.claude\//g, '.qwen/'); + } else if (isHermes) { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + content = content.replace(/\.claude\//g, '.hermes/'); } const destName = isCopilot ? entry.name.replace('.md', '.agent.md') : entry.name; fs.writeFileSync(path.join(agentsDest, destName), content); @@ -7229,6 +7403,10 @@ function install(isGlobal, runtime = 'claude') { content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); } + if (isHermes) { + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + } content = content.replace(/\{\{GSD_VERSION\}\}/g, pkg.version); fs.writeFileSync(destFile, content); // Ensure hook files are executable (fixes #1162 — missing +x permission) @@ -7951,6 +8129,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'trae') program = 'Trae'; if (runtime === 'cline') program = 'Cline'; if (runtime === 'qwen') program = 'Qwen Code'; + if (runtime === 'hermes') program = 'Hermes Agent'; let command = '/gsd-new-project'; if (runtime === 'opencode') command = '/gsd-new-project'; @@ -7965,6 +8144,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS if (runtime === 'trae') command = '/gsd-new-project'; if (runtime === 'cline') command = '/gsd-new-project'; if (runtime === 'qwen') command = '/gsd-new-project'; + if (runtime === 'hermes') command = '/gsd-new-project'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -8026,6 +8206,86 @@ function handleStatusline(settings, isInteractive, callback) { /** * Prompt for runtime selection */ +/** + * Runtime selection options for the interactive installer prompt. + * Module-level so tests can import and assert structurally without grepping source. + */ +const runtimeMap = { + '1': 'claude', + '2': 'antigravity', + '3': 'augment', + '4': 'cline', + '5': 'codebuddy', + '6': 'codex', + '7': 'copilot', + '8': 'cursor', + '9': 'gemini', + '10': 'hermes', + '11': 'kilo', + '12': 'opencode', + '13': 'qwen', + '14': 'trae', + '15': 'windsurf' +}; +const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; +const ALL_RUNTIMES_OPTION = '16'; + +/** + * Build the runtime-selection prompt text shown by the interactive installer. + * Pure function — no I/O. Exported for tests so they can assert against the + * rendered prompt instead of grepping bin/install.js source text. + */ +function buildRuntimePromptText() { + return ` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} + ${cyan}2${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} + ${cyan}3${reset}) Augment ${dim}(~/.augment)${reset} + ${cyan}4${reset}) Cline ${dim}(.clinerules)${reset} + ${cyan}5${reset}) CodeBuddy ${dim}(~/.codebuddy)${reset} + ${cyan}6${reset}) Codex ${dim}(~/.codex)${reset} + ${cyan}7${reset}) Copilot ${dim}(~/.copilot)${reset} + ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset} + ${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset} + ${cyan}10${reset}) Hermes Agent ${dim}(~/.hermes)${reset} + ${cyan}11${reset}) Kilo ${dim}(~/.config/kilo)${reset} + ${cyan}12${reset}) OpenCode ${dim}(~/.config/opencode)${reset} + ${cyan}13${reset}) Qwen Code ${dim}(~/.qwen)${reset} + ${cyan}14${reset}) Trae ${dim}(~/.trae)${reset} + ${cyan}15${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} + ${cyan}16${reset}) All + + ${dim}Select multiple: 1,2,6 or 1 2 6${reset} +`; +} + +/** + * Parse user input from the runtime-selection prompt into a runtime list. + * Pure function — exported so tests can verify split/dedupe/fallback behavior. + * - Accepts comma- and/or whitespace-separated choices + * - Deduplicates while preserving order + * - Maps option 16 ("All") to every runtime + * - Falls back to ['claude'] when nothing valid is selected + */ +function parseRuntimeInput(answer) { + const input = (answer == null ? '' : String(answer)).trim() || '1'; + + // Tokenize first so the all-runtimes shortcut also fires for inputs the + // prompt encourages — "16,", "16 1", etc. — not just the bare "16". + const choices = input.split(/[\s,]+/).filter(Boolean); + if (choices.includes(ALL_RUNTIMES_OPTION)) { + return allRuntimes.slice(); + } + + const selected = []; + for (const c of choices) { + const runtime = runtimeMap[c]; + if (runtime && !selected.includes(runtime)) { + selected.push(runtime); + } + } + + return selected.length > 0 ? selected : ['claude']; +} + function promptRuntime(callback) { const rl = readline.createInterface({ input: process.stdin, @@ -8042,65 +8302,12 @@ function promptRuntime(callback) { } }); - const runtimeMap = { - '1': 'claude', - '2': 'antigravity', - '3': 'augment', - '4': 'cline', - '5': 'codebuddy', - '6': 'codex', - '7': 'copilot', - '8': 'cursor', - '9': 'gemini', - '10': 'kilo', - '11': 'opencode', - '12': 'qwen', - '13': 'trae', - '14': 'windsurf' - }; - const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; - - console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} - ${cyan}2${reset}) Antigravity ${dim}(~/.gemini/antigravity)${reset} - ${cyan}3${reset}) Augment ${dim}(~/.augment)${reset} - ${cyan}4${reset}) Cline ${dim}(.clinerules)${reset} - ${cyan}5${reset}) CodeBuddy ${dim}(~/.codebuddy)${reset} - ${cyan}6${reset}) Codex ${dim}(~/.codex)${reset} - ${cyan}7${reset}) Copilot ${dim}(~/.copilot)${reset} - ${cyan}8${reset}) Cursor ${dim}(~/.cursor)${reset} - ${cyan}9${reset}) Gemini ${dim}(~/.gemini)${reset} - ${cyan}10${reset}) Kilo ${dim}(~/.config/kilo)${reset} - ${cyan}11${reset}) OpenCode ${dim}(~/.config/opencode)${reset} - ${cyan}12${reset}) Qwen Code ${dim}(~/.qwen)${reset} - ${cyan}13${reset}) Trae ${dim}(~/.trae)${reset} - ${cyan}14${reset}) Windsurf ${dim}(~/.codeium/windsurf)${reset} - ${cyan}15${reset}) All - - ${dim}Select multiple: 1,2,6 or 1 2 6${reset} -`); + console.log(buildRuntimePromptText()); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); - const input = answer.trim() || '1'; - - // "All" shortcut - if (input === '15') { - callback(allRuntimes); - return; - } - - // Parse comma-separated, space-separated, or single choice - const choices = input.split(/[\s,]+/).filter(Boolean); - const selected = []; - for (const c of choices) { - const runtime = runtimeMap[c]; - if (runtime && !selected.includes(runtime)) { - selected.push(runtime); - } - } - - callback(selected.length > 0 ? selected : ['claude']); + callback(parseRuntimeInput(answer)); }); } @@ -8730,6 +8937,10 @@ if (process.env.GSD_TEST_MODE) { finishInstall, homePathCoveredByRc, maybeSuggestPathExport, + runtimeMap, + allRuntimes, + parseRuntimeInput, + buildRuntimePromptText, }; } else { @@ -8743,7 +8954,12 @@ if (process.env.GSD_TEST_MODE) { process.exit(1); } const globalDir = getGlobalDir(runtimeArg, null); - console.log(path.join(globalDir, 'skills')); + // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). + // Other runtimes use a flat skills/ root. + const skillsRoot = runtimeArg === 'hermes' + ? path.join(globalDir, 'skills', 'gsd') + : path.join(globalDir, 'skills'); + console.log(skillsRoot); } else if (hasGlobal && hasLocal) { console.error(` ${yellow}Cannot specify both --global and --local${reset}`); process.exit(1); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 15c345050..c80125b28 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -1293,6 +1293,16 @@ const RUNTIME_PROFILE_MAP = { sonnet: { model: 'claude-sonnet-4-6' }, haiku: { model: 'claude-haiku-4-5' }, }, + hermes: { + // Hermes Agent is provider-agnostic; users pick any provider in ~/.hermes/config.yaml. + // Defaults use OpenRouter slugs because (a) OpenRouter is Hermes' default provider and + // (b) the same slugs resolve on OpenRouter, native Anthropic, and Copilot via Hermes' + // aggregator-aware resolver. Users on a different provider override per-tier via + // model_profile_overrides.hermes.{opus,sonnet,haiku} in .planning/config.json. + opus: { model: 'anthropic/claude-opus-4-7' }, + sonnet: { model: 'anthropic/claude-sonnet-4-6' }, + haiku: { model: 'anthropic/claude-haiku-4-5' }, + }, }; const RUNTIMES_WITH_REASONING_EFFORT = new Set(['codex']); @@ -1315,7 +1325,7 @@ const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']); const KNOWN_RUNTIMES = new Set([ 'claude', 'codex', 'opencode', 'kilo', 'gemini', 'qwen', 'copilot', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy', - 'antigravity', 'cline', + 'antigravity', 'cline', 'hermes', ]); const _warnedConfigKeys = new Set(); diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs new file mode 100644 index 000000000..13d2395a5 --- /dev/null +++ b/tests/hermes-install.test.cjs @@ -0,0 +1,395 @@ +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); +const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); +const pkg = require('../package.json'); + +const { + getDirName, + getGlobalDir, + getConfigDirFromHome, + install, + uninstall, + writeManifest, +} = require('../bin/install.js'); + +describe('Hermes Agent runtime directory mapping', () => { + test('maps Hermes to .hermes for local installs', () => { + assert.strictEqual(getDirName('hermes'), '.hermes'); + }); + + test('maps Hermes to ~/.hermes for global installs', () => { + // Isolate from any HERMES_HOME exported on the developer's machine — + // otherwise this test asserts the env-derived path, not the default. + const originalHermesHome = process.env.HERMES_HOME; + delete process.env.HERMES_HOME; + try { + assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes')); + } finally { + if (originalHermesHome === undefined) delete process.env.HERMES_HOME; + else process.env.HERMES_HOME = originalHermesHome; + } + }); + + test('returns .hermes config fragments for local and global installs', () => { + assert.strictEqual(getConfigDirFromHome('hermes', false), "'.hermes'"); + assert.strictEqual(getConfigDirFromHome('hermes', true), "'.hermes'"); + }); +}); + +describe('getGlobalDir (Hermes Agent)', () => { + let originalHermesConfigDir; + + beforeEach(() => { + originalHermesConfigDir = process.env.HERMES_HOME; + }); + + afterEach(() => { + if (originalHermesConfigDir !== undefined) { + process.env.HERMES_HOME = originalHermesConfigDir; + } else { + delete process.env.HERMES_HOME; + } + }); + + test('returns ~/.hermes with no env var or explicit dir', () => { + delete process.env.HERMES_HOME; + const result = getGlobalDir('hermes'); + assert.strictEqual(result, path.join(os.homedir(), '.hermes')); + }); + + test('returns explicit dir when provided', () => { + const result = getGlobalDir('hermes', '/custom/hermes-path'); + assert.strictEqual(result, '/custom/hermes-path'); + }); + + test('respects HERMES_HOME env var', () => { + process.env.HERMES_HOME = '~/custom-hermes'; + const result = getGlobalDir('hermes'); + assert.strictEqual(result, path.join(os.homedir(), 'custom-hermes')); + }); + + test('explicit dir takes priority over HERMES_HOME', () => { + process.env.HERMES_HOME = '~/from-env'; + const result = getGlobalDir('hermes', '/explicit/path'); + assert.strictEqual(result, '/explicit/path'); + }); + + test('does not break other runtimes', () => { + assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude')); + assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex')); + }); +}); + +describe('Hermes Agent local install/uninstall', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-hermes-install-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('installs GSD into ./.hermes and removes it cleanly', () => { + const result = install(false, 'hermes'); + const targetDir = path.join(tmpDir, '.hermes'); + + assert.strictEqual(result.runtime, 'hermes'); + assert.strictEqual(result.configDir, fs.realpathSync(targetDir)); + + // Nested layout per spec #2841: all GSD skills collapse into a single + // skills/gsd/ category so Hermes' system prompt sees one entry, not 86. + assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help', 'SKILL.md'))); + assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')), + 'DESCRIPTION.md exists at category root'); + assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION'))); + assert.ok(fs.existsSync(path.join(targetDir, 'agents'))); + + const manifest = writeManifest(targetDir, 'hermes'); + assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/gsd-help/')), manifest); + + uninstall(false, 'hermes'); + + assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help')), 'Hermes skill directory removed'); + assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')), 'Hermes gsd category dir removed'); + assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed'); + }); + + test('installed SKILL.md frontmatter conforms to Hermes spec', () => { + install(false, 'hermes'); + const targetDir = path.join(tmpDir, '.hermes'); + // Nested layout: skills live under skills/gsd/gsd-*/SKILL.md. + const categoryDir = path.join(targetDir, 'skills', 'gsd'); + const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')) + .map(e => e.name); + + assert.ok(skillDirs.length > 0, 'at least one gsd-* skill installed'); + + // Parse every SKILL.md and assert structural shape required by Hermes. + for (const dir of skillDirs) { + const content = fs.readFileSync(path.join(categoryDir, dir, 'SKILL.md'), 'utf8'); + const fm = parseFrontmatter(content); + assert.strictEqual(fm.name, dir, `${dir}/SKILL.md name matches dir`); + assert.ok(typeof fm.description === 'string' && fm.description.length > 0, + `${dir}/SKILL.md has non-empty description`); + assert.strictEqual(fm.version, pkg.version, + `${dir}/SKILL.md declares version ${pkg.version} (got ${JSON.stringify(fm.version)})`); + } + + // The category DESCRIPTION.md is part of the spec — verify it parses too. + const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8'); + const descFm = parseFrontmatter(desc); + assert.strictEqual(descFm.name, 'gsd', 'category DESCRIPTION.md name is "gsd"'); + assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0, + 'category DESCRIPTION.md has description'); + assert.strictEqual(descFm.version, pkg.version, + 'category DESCRIPTION.md declares version'); + + uninstall(false, 'hermes'); + }); + + test('replaces CLAUDE.md references with HERMES.md', () => { + install(false, 'hermes'); + const targetDir = path.join(tmpDir, '.hermes'); + const skillsDir = path.join(targetDir, 'skills'); + + // Walk all skill files and confirm no `CLAUDE.md` token leaks; any + // skill body that referenced project context should now point at + // `HERMES.md` per the issue spec. + let referencedHermesMd = false; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(full); continue; } + if (!entry.name.endsWith('.md')) continue; + const content = fs.readFileSync(full, 'utf8'); + assert.ok(!/\bCLAUDE\.md\b/.test(content), + `${path.relative(targetDir, full)} still references CLAUDE.md`); + if (/\bHERMES\.md\b/.test(content)) referencedHermesMd = true; + } + }; + walk(skillsDir); + // Sanity: at least one skill in the GSD set references the project + // context filename, so the substitution actually exercises. + assert.ok(referencedHermesMd, 'at least one skill references HERMES.md after substitution'); + + uninstall(false, 'hermes'); + }); +}); + +describe('E2E: Hermes Agent uninstall skills cleanup', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-hermes-uninstall-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + test('removes all gsd-* skill directories on --hermes --uninstall', () => { + const targetDir = path.join(tmpDir, '.hermes'); + install(false, 'hermes'); + + const skillsDir = path.join(targetDir, 'skills'); + const categoryDir = path.join(skillsDir, 'gsd'); + assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install'); + + const installedSkills = fs.readdirSync(categoryDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`); + + uninstall(false, 'hermes'); + + assert.ok(!fs.existsSync(categoryDir), 'skills/gsd/ category dir removed by uninstall'); + if (fs.existsSync(skillsDir)) { + const remainingFlat = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(remainingFlat.length, 0, + `Expected 0 stray flat gsd-* skill dirs after uninstall, found: ${remainingFlat.map(e => e.name).join(', ')}`); + } + }); + + test('preserves non-GSD skill directories during --hermes --uninstall', () => { + const targetDir = path.join(tmpDir, '.hermes'); + install(false, 'hermes'); + + const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill'); + fs.mkdirSync(customSkillDir, { recursive: true }); + fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n'); + + assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall'); + + uninstall(false, 'hermes'); + + assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), + 'Non-GSD skill directory should be preserved after Hermes uninstall'); + }); + + test('removes engine directory on --hermes --uninstall', () => { + const targetDir = path.join(tmpDir, '.hermes'); + install(false, 'hermes'); + + assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')), + 'engine exists before uninstall'); + + uninstall(false, 'hermes'); + + assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), + 'get-shit-done engine should be removed after Hermes uninstall'); + }); +}); + +// ─── Regression: no Claude references leak into Hermes install (parity with Qwen regression #2112) ────────── + +describe('Hermes install contains no leaked Claude references (parity with Qwen regression #2112)', () => { + let tmpDir; + let previousCwd; + + beforeEach(() => { + tmpDir = createTempDir('gsd-hermes-refs-'); + previousCwd = process.cwd(); + process.chdir(tmpDir); + install(false, 'hermes'); + }); + + afterEach(() => { + process.chdir(previousCwd); + cleanup(tmpDir); + }); + + /** + * Recursively walk a directory and return all file paths. + */ + function walk(dir) { + const results = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walk(full)); + } else { + results.push(full); + } + } + return results; + } + + /** + * Return files under .hermes/ that contain Claude references, + * excluding CHANGELOG.md (historical accuracy) and VERSION (no prose). + */ + function findClaudeLeaks() { + const hermesDir = path.join(tmpDir, '.hermes'); + const allFiles = walk(hermesDir); + const textFiles = allFiles.filter(f => + f.endsWith('.md') || f.endsWith('.cjs') || f.endsWith('.js') + ); + const excluded = ['CHANGELOG.md']; + const candidates = textFiles.filter(f => + !excluded.includes(path.basename(f)) + ); + const leaks = []; + for (const file of candidates) { + const content = fs.readFileSync(file, 'utf8'); + if (/\bCLAUDE\.md\b/.test(content) || + /\bClaude Code\b/.test(content) || + /\.claude\//.test(content)) { + leaks.push(path.relative(tmpDir, file)); + } + } + return leaks; + } + + test('skills contain no CLAUDE.md or Claude Code references', () => { + const hermesDir = path.join(tmpDir, '.hermes'); + const skillsDir = path.join(hermesDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills directory exists'); + + const skillFiles = walk(skillsDir).filter(f => f.endsWith('.md')); + assert.ok(skillFiles.length > 0, 'at least one skill file exists'); + + const leaks = []; + for (const file of skillFiles) { + const content = fs.readFileSync(file, 'utf8'); + if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) { + leaks.push(path.relative(tmpDir, file)); + } + } + assert.strictEqual(leaks.length, 0, + [ + 'Skills should not contain Claude references after Hermes install.', + 'Leaking files:', + ...leaks, + ].join('\n')); + }); + + test('agents contain no CLAUDE.md or Claude Code references', () => { + const agentsDir = path.join(tmpDir, '.hermes', 'agents'); + assert.ok(fs.existsSync(agentsDir), 'agents directory exists'); + + const agentFiles = walk(agentsDir).filter(f => f.endsWith('.md')); + assert.ok(agentFiles.length > 0, 'at least one agent file exists'); + + const leaks = []; + for (const file of agentFiles) { + const content = fs.readFileSync(file, 'utf8'); + if (/\bCLAUDE\.md\b/.test(content) || /\bClaude Code\b/.test(content)) { + leaks.push(path.relative(tmpDir, file)); + } + } + assert.strictEqual(leaks.length, 0, + [ + 'Agents should not contain Claude references after Hermes install.', + 'Leaking files:', + ...leaks, + ].join('\n')); + }); + + test('hooks contain no .claude/ path references', () => { + const hooksDir = path.join(tmpDir, '.hermes', 'hooks'); + if (!fs.existsSync(hooksDir)) { + return; // hooks may not be present in local installs + } + + const hookFiles = walk(hooksDir).filter(f => f.endsWith('.js')); + const leaks = []; + for (const file of hookFiles) { + const content = fs.readFileSync(file, 'utf8'); + if (/\.claude\//.test(content)) { + leaks.push(path.relative(tmpDir, file)); + } + } + assert.strictEqual(leaks.length, 0, + [ + 'Hooks should not contain .claude/ path references after Hermes install.', + 'Leaking files:', + ...leaks, + ].join('\n')); + }); + + test('full tree scan finds zero Claude references outside CHANGELOG.md', () => { + const leaks = findClaudeLeaks(); + assert.strictEqual(leaks.length, 0, + [ + 'No files under .hermes/ (except CHANGELOG.md) should contain Claude references.', + `Found ${leaks.length} leaking file(s):`, + ...leaks, + ].join('\n')); + }); +}); diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs new file mode 100644 index 000000000..84c24b5da --- /dev/null +++ b/tests/hermes-skills-migration.test.cjs @@ -0,0 +1,305 @@ +/** + * GSD Tools Tests - Hermes Agent Skills Migration + * + * Tests for installing GSD for Hermes Agent using the standard + * skills/gsd-xxx/SKILL.md format (same open standard as Claude Code 2.1.88+). + * + * Uses node:test and node:assert (NOT Jest). + */ + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const os = require('os'); +const fs = require('fs'); + +const { + convertClaudeCommandToClaudeSkill, + copyCommandsAsClaudeSkills, +} = require('../bin/install.js'); +const { parseFrontmatter } = require('./helpers.cjs'); +const pkg = require('../package.json'); + +// ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ── + +describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => { + test('preserves allowed-tools multiline YAML list', () => { + const input = [ + '---', + 'name: gsd:next', + 'description: Advance to the next step', + 'allowed-tools:', + ' - Read', + ' - Bash', + ' - Grep', + '---', + '', + 'Body content here.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); + assert.ok(result.includes('allowed-tools:'), 'allowed-tools field is present'); + assert.ok(result.includes('Read'), 'Read tool preserved'); + assert.ok(result.includes('Bash'), 'Bash tool preserved'); + assert.ok(result.includes('Grep'), 'Grep tool preserved'); + }); + + test('preserves argument-hint', () => { + const input = [ + '---', + 'name: gsd:debug', + 'description: Debug issues', + 'argument-hint: "[issue description]"', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Debug body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-debug'); + assert.ok(result.includes('argument-hint:'), 'argument-hint field is present'); + assert.ok( + result.includes('[issue description]'), + 'argument-hint value preserved' + ); + }); + + test('emits hyphen-form name (gsd-) from hyphen-form dir (#2808)', () => { + const input = [ + '---', + 'name: gsd:next', + 'description: Advance workflow', + '---', + '', + 'Body.', + ].join('\n'); + + // Directory name is gsd-next (hyphen, Windows-safe), frontmatter name is + // gsd-next (hyphen, #2808 — canonical invocation form for Claude Code autocomplete). + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-next'); + assert.ok(result.includes('name: gsd-next'), 'frontmatter name uses hyphen form (#2808)'); + }); + + test('preserves body content unchanged', () => { + const body = '\n\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + const input = [ + '---', + 'name: gsd:test', + 'description: Test command', + '---', + body, + ].join(''); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test'); + assert.ok(result.includes(''), 'objective tag preserved'); + assert.ok(result.includes('Do the thing.'), 'body text preserved'); + assert.ok(result.includes(''), 'process tag preserved'); + }); + + test('produces valid SKILL.md frontmatter starting with ---', () => { + const input = [ + '---', + 'name: gsd:plan', + 'description: Plan a phase', + '---', + '', + 'Plan body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan'); + assert.ok(result.startsWith('---\n'), 'frontmatter starts with ---'); + assert.ok(result.includes('\n---\n'), 'frontmatter closes with ---'); + }); +}); + +// ─── copyCommandsAsClaudeSkills (used for Hermes skills install) ───────────── + +describe('Hermes Agent: copyCommandsAsClaudeSkills', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-hermes-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true }); + } + }); + + test('creates skills/gsd-xxx/SKILL.md directory structure', () => { + // Create source command files + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'quick.md'), [ + '---', + 'name: gsd:quick', + 'description: Execute a quick task', + 'allowed-tools:', + ' - Read', + ' - Bash', + '---', + '', + 'Quick task body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'hermes', false); + + // Verify SKILL.md was created + const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md'); + assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists'); + + // Verify content (structural — parse frontmatter, don't substring-grep) + const content = fs.readFileSync(skillPath, 'utf8'); + const fm = parseFrontmatter(content); + assert.strictEqual(fm.name, 'gsd-quick', 'frontmatter name uses hyphen form (#2808)'); + assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty'); + assert.strictEqual(fm.version, pkg.version, + `Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`); + assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(content), + 'allowed-tools rendered as YAML block list'); + assert.ok(content.includes(''), 'body content preserved'); + }); + + test('replaces ~/.claude/ paths with pathPrefix', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'next.md'), [ + '---', + 'name: gsd:next', + 'description: Next step', + '---', + '', + 'Reference: @~/.claude/get-shit-done/workflows/next.md', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/'); + assert.ok(!content.includes('~/.claude/'), 'old claude path removed'); + }); + + test('replaces $HOME/.claude/ paths with pathPrefix', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'plan.md'), [ + '---', + 'name: gsd:plan', + 'description: Plan phase', + '---', + '', + 'Reference: $HOME/.claude/get-shit-done/workflows/plan.md', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.hermes/', 'hermes', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8'); + assert.ok(content.includes('$HOME/.hermes/'), 'path replaced to .hermes/'); + assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed'); + }); + + test('removes stale gsd- skills before installing new ones', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'quick.md'), [ + '---', + 'name: gsd:quick', + 'description: Quick task', + '---', + '', + 'Body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + // Pre-create a stale skill + fs.mkdirSync(path.join(skillsDir, 'gsd-old-skill'), { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'gsd-old-skill', 'SKILL.md'), 'old'); + + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false); + + assert.ok(!fs.existsSync(path.join(skillsDir, 'gsd-old-skill')), 'stale skill removed'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-quick', 'SKILL.md')), 'new skill installed'); + }); + + test('preserves agent field in frontmatter', () => { + const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd'); + fs.mkdirSync(srcDir, { recursive: true }); + fs.writeFileSync(path.join(srcDir, 'execute.md'), [ + '---', + 'name: gsd:execute', + 'description: Execute phase', + 'agent: gsd-executor', + 'allowed-tools:', + ' - Read', + ' - Bash', + ' - Task', + '---', + '', + 'Execute body', + ].join('\n')); + + const skillsDir = path.join(tmpDir, 'dest', 'skills'); + copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false); + + const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8'); + const fm = parseFrontmatter(content); + assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved'); + }); +}); + +// ─── Integration: SKILL.md format validation ──────────────────────────────── + +describe('Hermes Agent: SKILL.md format validation', () => { + test('SKILL.md frontmatter parses with required Hermes fields', () => { + const input = [ + '---', + 'name: gsd:review', + 'description: Code review with quality checks', + 'argument-hint: "[PR number or branch]"', + 'agent: gsd-code-reviewer', + 'allowed-tools:', + ' - Read', + ' - Grep', + ' - Bash', + '---', + '', + 'Review code', + ].join('\n'); + + // Pass runtime='hermes' so the version field is injected per Hermes spec. + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review', 'hermes'); + const fm = parseFrontmatter(result); + + assert.strictEqual(fm.name, 'gsd-review', 'name uses hyphen form'); + assert.ok(fm.description && fm.description.length > 0, 'description non-empty'); + assert.strictEqual(fm.version, pkg.version, 'version matches package.json'); + assert.strictEqual(fm.agent, 'gsd-code-reviewer', 'agent preserved'); + assert.strictEqual(fm['argument-hint'], '[PR number or branch]', 'argument-hint preserved and unquoted'); + assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(result), + 'allowed-tools rendered as YAML block list'); + }); + + test('omits version field when runtime is not hermes (parity with non-Hermes skill consumers)', () => { + const input = [ + '---', + 'name: gsd:plan', + 'description: Plan a phase', + '---', + '', + 'Body.', + ].join('\n'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-plan'); + const fm = parseFrontmatter(result); + assert.strictEqual(fm.version, undefined, 'no version key for non-hermes skills'); + assert.strictEqual(fm.name, 'gsd-plan'); + }); +}); diff --git a/tests/kilo-install.test.cjs b/tests/kilo-install.test.cjs index 51712a1ca..77553c93d 100644 --- a/tests/kilo-install.test.cjs +++ b/tests/kilo-install.test.cjs @@ -224,13 +224,30 @@ describe('Source code integration (Kilo)', () => { assert.ok(src.includes("'kilo'"), '--all includes kilo runtime'); }); - test('promptRuntime runtimeMap has Kilo as option 10', () => { - assert.ok(src.includes("'10': 'kilo'"), 'runtimeMap has 10 -> kilo'); + test('promptRuntime runtimeMap has Kilo as option 11', () => { + // Structural assertion against exported runtimeMap rather than source-grep. + process.env.GSD_TEST_MODE = '1'; + delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))]; + const { runtimeMap } = require(path.join(__dirname, '..', 'bin', 'install.js')); + assert.strictEqual(runtimeMap['11'], 'kilo', 'runtimeMap has 11 -> kilo'); }); test('prompt text shows Kilo above OpenCode without marketing copy', () => { - assert.ok(src.includes('10${reset}) Kilo'), 'prompt lists Kilo as option 10'); - assert.ok(!src.includes('the #1 AI coding platform on OpenRouter'), 'prompt does not include marketing tagline'); + // Call the exported prompt builder; assert against rendered text, not raw source. + process.env.GSD_TEST_MODE = '1'; + delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))]; + const { buildRuntimePromptText } = require(path.join(__dirname, '..', 'bin', 'install.js')); + const promptText = buildRuntimePromptText(); + // Strip ANSI color codes so assertions don't depend on terminal escapes. + // eslint-disable-next-line no-control-regex + const plain = promptText.replace(/\x1b\[[0-9;]*m/g, ''); + assert.ok(/\b11\)\s*Kilo\b/.test(plain), 'prompt lists Kilo as option 11'); + const kiloIdx = plain.indexOf('11) Kilo'); + const opencodeIdx = plain.indexOf('OpenCode'); + assert.ok(kiloIdx > -1 && opencodeIdx > -1 && kiloIdx < opencodeIdx, + 'Kilo appears above OpenCode in prompt'); + assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'), + 'prompt does not include marketing tagline'); }); test('hooks are skipped for Kilo', () => { diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index c0b1b0004..1e15d046b 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -3,59 +3,29 @@ * Verifies that promptRuntime accepts comma-separated, space-separated, * and single-choice inputs, deduplicates, and falls back to claude. * See issue #1281. + * + * Per CONTRIBUTING.md "no-source-grep" testing standard, prompt + parser + * behavior is asserted via the install module's exported pure functions + * (`runtimeMap`, `allRuntimes`, `parseRuntimeInput`, `buildRuntimePromptText`) + * instead of regexing bin/install.js source text. */ +process.env.GSD_TEST_MODE = '1'; + const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); -// Read install.js source to extract the runtimeMap and parsing logic -const installSrc = fs.readFileSync( - path.join(__dirname, '..', 'bin', 'install.js'), - 'utf8' -); +const { + runtimeMap, + allRuntimes, + parseRuntimeInput, + buildRuntimePromptText, +} = require('../bin/install.js'); -// Extract runtimeMap from source for validation -const runtimeMap = { - '1': 'claude', - '2': 'antigravity', - '3': 'augment', - '4': 'cline', - '5': 'codebuddy', - '6': 'codex', - '7': 'copilot', - '8': 'cursor', - '9': 'gemini', - '10': 'kilo', - '11': 'opencode', - '12': 'qwen', - '13': 'trae', - '14': 'windsurf' -}; -const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; - -/** - * Simulate the parsing logic from promptRuntime without requiring readline. - * This mirrors the exact logic in the rl.question callback. - */ -function parseRuntimeInput(input) { - input = input.trim() || '1'; - - if (input === '15') { - return allRuntimes; - } - - const choices = input.split(/[\s,]+/).filter(Boolean); - const selected = []; - for (const c of choices) { - const runtime = runtimeMap[c]; - if (runtime && !selected.includes(runtime)) { - selected.push(runtime); - } - } - - return selected.length > 0 ? selected : ['claude']; +// Strip ANSI color codes for human-readable assertions on prompt text. +function stripAnsi(s) { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*m/g, ''); } describe('multi-runtime selection parsing', () => { @@ -78,7 +48,7 @@ describe('multi-runtime selection parsing', () => { test('space-separated choices return multiple runtimes', () => { assert.deepStrictEqual(parseRuntimeInput('1 7 9'), ['claude', 'copilot', 'gemini']); - assert.deepStrictEqual(parseRuntimeInput('8 10'), ['cursor', 'kilo']); + assert.deepStrictEqual(parseRuntimeInput('8 11'), ['cursor', 'kilo']); }); test('mixed comma and space separators work', () => { @@ -86,24 +56,43 @@ describe('multi-runtime selection parsing', () => { assert.deepStrictEqual(parseRuntimeInput('2 , 8'), ['antigravity', 'cursor']); }); + test('single choice for hermes', () => { + assert.deepStrictEqual(parseRuntimeInput('10'), ['hermes']); + }); + + test('single choice for kilo', () => { + assert.deepStrictEqual(parseRuntimeInput('11'), ['kilo']); + }); + test('single choice for opencode', () => { - assert.deepStrictEqual(parseRuntimeInput('11'), ['opencode']); + assert.deepStrictEqual(parseRuntimeInput('12'), ['opencode']); }); test('single choice for qwen', () => { - assert.deepStrictEqual(parseRuntimeInput('12'), ['qwen']); + assert.deepStrictEqual(parseRuntimeInput('13'), ['qwen']); }); test('single choice for trae', () => { - assert.deepStrictEqual(parseRuntimeInput('13'), ['trae']); + assert.deepStrictEqual(parseRuntimeInput('14'), ['trae']); }); test('single choice for windsurf', () => { - assert.deepStrictEqual(parseRuntimeInput('14'), ['windsurf']); + assert.deepStrictEqual(parseRuntimeInput('15'), ['windsurf']); }); - test('choice 15 returns all runtimes', () => { - assert.deepStrictEqual(parseRuntimeInput('15'), allRuntimes); + test('choice 16 returns all runtimes', () => { + assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes); + }); + + test('choice 16 returns all runtimes when mixed with separators or other tokens', () => { + // CR feedback: tokenized inputs that include 16 (e.g. trailing comma, or + // alongside other choices) must still expand to all-runtimes — previously + // only the bare "16" matched, so "16," or "16 1" silently installed a + // subset. + assert.deepStrictEqual(parseRuntimeInput('16,'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput('16 1'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput('1,16'), allRuntimes); + assert.deepStrictEqual(parseRuntimeInput(' 16 '), allRuntimes); }); test('empty input defaults to claude', () => { @@ -112,13 +101,13 @@ describe('multi-runtime selection parsing', () => { }); test('invalid choices are ignored, falls back to claude if all invalid', () => { - assert.deepStrictEqual(parseRuntimeInput('16'), ['claude']); + assert.deepStrictEqual(parseRuntimeInput('17'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']); assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']); }); test('invalid choices mixed with valid are filtered out', () => { - assert.deepStrictEqual(parseRuntimeInput('1,16,7'), ['claude', 'copilot']); + assert.deepStrictEqual(parseRuntimeInput('1,17,7'), ['claude', 'copilot']); assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']); }); @@ -129,68 +118,79 @@ describe('multi-runtime selection parsing', () => { test('preserves selection order', () => { assert.deepStrictEqual(parseRuntimeInput('9,1,7'), ['gemini', 'claude', 'copilot']); - assert.deepStrictEqual(parseRuntimeInput('10,2,8'), ['kilo', 'antigravity', 'cursor']); + assert.deepStrictEqual(parseRuntimeInput('11,2,8'), ['kilo', 'antigravity', 'cursor']); }); }); -describe('install.js source contains multi-select support', () => { - test('runtimeMap is defined with all 14 runtimes', () => { - for (const [key, name] of Object.entries(runtimeMap)) { - assert.ok( - installSrc.includes(`'${key}': '${name}'`), - `runtimeMap has ${key} -> ${name}` - ); +describe('install.js exports multi-select runtime metadata', () => { + const expectedRuntimeMap = { + '1': 'claude', + '2': 'antigravity', + '3': 'augment', + '4': 'cline', + '5': 'codebuddy', + '6': 'codex', + '7': 'copilot', + '8': 'cursor', + '9': 'gemini', + '10': 'hermes', + '11': 'kilo', + '12': 'opencode', + '13': 'qwen', + '14': 'trae', + '15': 'windsurf', + }; + const expectedRuntimes = [ + 'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', + 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', + 'trae', 'windsurf', + ]; + + test('runtimeMap exports every option key bound to the right runtime', () => { + assert.deepStrictEqual(runtimeMap, expectedRuntimeMap, + 'exported runtimeMap matches the canonical option list'); + }); + + test('allRuntimes contains every runtime exactly once', () => { + assert.strictEqual(allRuntimes.length, expectedRuntimes.length); + for (const rt of expectedRuntimes) { + assert.ok(allRuntimes.includes(rt), `allRuntimes contains ${rt}`); } + assert.strictEqual(new Set(allRuntimes).size, allRuntimes.length, + 'allRuntimes has no duplicates'); }); - test('allRuntimes array contains all runtimes', () => { - const match = installSrc.match(/const allRuntimes = \[([^\]]+)\]/); - assert.ok(match, 'allRuntimes array found'); - for (const rt of allRuntimes) { - assert.ok(match[1].includes(`'${rt}'`), `allRuntimes includes ${rt}`); - } + test('"All" shortcut (option 16) selects every runtime', () => { + assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes); }); - test('all shortcut uses option 15', () => { - assert.ok( - installSrc.includes("if (input === '15')"), - 'all shortcut uses option 15' - ); - }); - - test('prompt lists Qwen Code as option 12, Trae as option 13 and All as option 15', () => { - assert.ok( - installSrc.includes('12${reset}) Qwen Code'), - 'prompt lists Qwen Code as option 12' - ); - assert.ok( - installSrc.includes('13${reset}) Trae'), - 'prompt lists Trae as option 13' - ); - assert.ok( - installSrc.includes('15${reset}) All'), - 'prompt lists All as option 15' - ); + test('prompt lists Hermes Agent (10), Qwen Code (13), Trae (14), and All (16)', () => { + const prompt = stripAnsi(buildRuntimePromptText()); + assert.ok(/\b10\)\s*Hermes Agent\b/.test(prompt), + 'prompt lists Hermes Agent as option 10'); + assert.ok(/\b13\)\s*Qwen Code\b/.test(prompt), + 'prompt lists Qwen Code as option 13'); + assert.ok(/\b14\)\s*Trae\b/.test(prompt), + 'prompt lists Trae as option 14'); + assert.ok(/\b16\)\s*All\b/.test(prompt), + 'prompt lists All as option 16'); }); test('prompt text shows multi-select hint', () => { - assert.ok( - installSrc.includes('Select multiple'), - 'prompt includes multi-select instructions' - ); + const prompt = stripAnsi(buildRuntimePromptText()); + assert.ok(/Select multiple/i.test(prompt), + 'prompt includes multi-select instructions'); }); - test('parsing uses split with comma and space regex', () => { - assert.ok( - installSrc.includes("split(/[\\s,]+/)"), - 'input is split on commas and whitespace' - ); - }); - - test('deduplication check exists', () => { - assert.ok( - installSrc.includes('!selected.includes(runtime)'), - 'deduplication guard exists' + test('parser splits on commas and whitespace and deduplicates', () => { + // Behavioral assertion: same set of choices in different separators + // produces the same selection, and duplicates collapse. + assert.deepStrictEqual( + parseRuntimeInput('1,7,9'), + parseRuntimeInput('1 7 9'), + 'comma- and space-separated input yield identical selections' ); + assert.deepStrictEqual(parseRuntimeInput('1,1,7,7'), ['claude', 'copilot'], + 'duplicates collapsed in order'); }); });