From b126c0579a8c67159681db961bc519a1df342ec9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Wed, 29 Apr 2026 04:27:46 -0700 Subject: [PATCH 1/8] feat(install): add Hermes Agent runtime support (#2841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Hermes Agent as a supported installation target. Users can run \`npx get-shit-done-cc --hermes\` to install all 86 GSD commands as skills under \`~/.hermes/skills/gsd-*/SKILL.md\`, following the same open skill standard as Claude Code 2.1.88+, Qwen Code, Antigravity, Trae, Augment, and Codebuddy. Hermes Agent is an open-source AI agent framework by Nous Research (NousResearch/hermes-agent, MIT). Its skill loader accepts the Claude skill format as-is: frontmatter parsed with PyYAML SafeLoader (unknown keys like \`allowed-tools\` / \`argument-hint\` ignored), body XML tags (\`\`, \`\`, \`\`) passed directly to the model. Compatibility proven end-to-end with all 86 GSD skills loading cleanly, \`skill_view()\` returning full bodies, and \`build_skills_system_prompt()\` emitting them into the agent system prompt — zero Hermes code changes required. Changes: - \`bin/install.js\`: --hermes flag, getDirName/getGlobalDir/getConfigDirFromHome support, HERMES_HOME env var (native to Hermes — used for profile mode / Docker deploys), install/uninstall pipelines, interactive picker option 10 (alphabetical: between Gemini and Kilo), .hermes path replacements in copyCommandsAsClaudeSkills and copyWithPathReplacement, legacy commands/gsd cleanup, CLAUDE.md -> HERMES.md and "Claude Code" -> "Hermes Agent" content rewrites in skills/agents/hooks, runtime-appropriate finish message. - \`get-shit-done/bin/lib/core.cjs\`: add hermes to KNOWN_RUNTIMES; add RUNTIME_PROFILE_MAP.hermes with OpenRouter-slug defaults (Hermes is provider-agnostic; these defaults resolve across OpenRouter, native Anthropic, and Copilot via Hermes' aggregator- aware resolver, and are overridable per-tier via model_profile_overrides.hermes.{opus,sonnet,haiku}). - \`README.md\`: Hermes Agent in tagline, runtime list, verification command, install/uninstall examples, \`--hermes\` flag reference. - \`tests/hermes-install.test.cjs\`: new, 14 tests covering directory mapping, HERMES_HOME env var precedence, install/uninstall lifecycle, user-skill preservation, engine cleanup. - \`tests/hermes-skills-migration.test.cjs\`: new, 11 tests covering frontmatter conversion, path replacement (~/.claude/ -> \$HERMES_HOME/skills/), CLAUDE.md -> HERMES.md, "Claude Code" -> "Hermes Agent", stale skill cleanup, SKILL.md format validation. - \`tests/multi-runtime-select.test.cjs\`: updated for new option numbering (hermes=10, kilo=11, opencode=12, qwen=13, trae=14, windsurf=15, all=16). - \`tests/kilo-install.test.cjs\`: updated assertions for Kilo having moved from option 10 to option 11. Closes #2841 Implementation notes: - Zero custom code paths: Hermes reuses copyCommandsAsClaudeSkills() identical to Qwen Code / Antigravity pattern. - Path replacement: ~/.claude/, \$HOME/.claude/, ./.claude/ -> .hermes equivalents in skill/agent/hook content. - Config precedence: --config-dir > HERMES_HOME > ~/.hermes (matches how Hermes itself resolves its home directory). - Legacy cleanup: removes commands/gsd/ if present from a prior install, preserving dev-preferences.md (same as Qwen). - No external dependencies added. Testing: 5841 / 5841 tests pass (0 failures, 0 regressions) - 14 new tests in hermes-install.test.cjs - 11 new tests in hermes-skills-migration.test.cjs - multi-runtime-select.test.cjs renumbered + 1 new test (single choice for hermes) --- README.md | 11 +- bin/install.js | 142 +++++++++-- get-shit-done/bin/lib/core.cjs | 12 +- tests/hermes-install.test.cjs | 316 +++++++++++++++++++++++++ tests/hermes-skills-migration.test.cjs | 287 ++++++++++++++++++++++ tests/kilo-install.test.cjs | 6 +- tests/multi-runtime-select.test.cjs | 69 +++--- 7 files changed, 792 insertions(+), 51 deletions(-) create mode 100644 tests/hermes-install.test.cjs create mode 100644 tests/hermes-skills-migration.test.cjs diff --git a/README.md b/README.md index f6a4b5427..4826da8d8 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,12 @@ 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` +- Hermes Agent: ask the agent to use `gsd-help` (skills are natural-language-triggered) - OpenCode / Kilo / Augment / Trae / CodeBuddy: `/gsd-help` - Codex: `$gsd-help` - Cline: GSD installs via `.clinerules` — verify by checking `.clinerules` exists @@ -179,6 +180,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 +197,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. diff --git a/bin/install.js b/bin/install.js index 1ad6f43b6..46f55b542 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); } @@ -5338,12 +5355,21 @@ 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); @@ -5484,6 +5510,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 +5542,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 +5587,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 +5643,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 +5828,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 +5853,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 +6001,32 @@ 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: remove skills/gsd-*/ directories (same layout as Qwen) + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + let skillCount = 0; + 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']); @@ -6783,6 +6853,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 +6904,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 +7030,32 @@ 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: reuses Claude-skill-format pipeline (skills/gsd-*/SKILL.md). + // Hermes' skill loader accepts unknown frontmatter keys (allowed-tools, + // argument-hint) and renders the gsd-* skills under ~/.hermes/skills/. + const skillsDir = path.join(targetDir, 'skills'); + const gsdSrc = stageSkillsForMode(path.join(src, 'commands', 'gsd'), installMode); + copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } + const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(legacyCommandsDir)) { const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); @@ -7165,6 +7263,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 +7331,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 +8057,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 +8072,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 (mention the skill name, e.g. "use gsd-new-project")'; console.log(` ${green}Done!${reset} Open a blank directory in ${program} and run ${cyan}${command}${reset}. @@ -8052,13 +8160,14 @@ function promptRuntime(callback) { '7': 'copilot', '8': 'cursor', '9': 'gemini', - '10': 'kilo', - '11': 'opencode', - '12': 'qwen', - '13': 'trae', - '14': 'windsurf' + '10': 'hermes', + '11': 'kilo', + '12': 'opencode', + '13': 'qwen', + '14': 'trae', + '15': 'windsurf' }; - const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; + const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', '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} @@ -8069,12 +8178,13 @@ function promptRuntime(callback) { ${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 + ${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} `); @@ -8085,7 +8195,7 @@ function promptRuntime(callback) { const input = answer.trim() || '1'; // "All" shortcut - if (input === '15') { + if (input === '16') { callback(allRuntimes); return; } 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..240efb69f --- /dev/null +++ b/tests/hermes-install.test.cjs @@ -0,0 +1,316 @@ +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 } = require('./helpers.cjs'); + +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', () => { + assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes')); + }); + + 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)); + + assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md'))); + 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-help/')), manifest); + + uninstall(false, 'hermes'); + + assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'Hermes skill directory removed'); + assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed'); + }); +}); + +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'); + assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install'); + + const installedSkills = fs.readdirSync(skillsDir, { 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'); + + if (fs.existsSync(skillsDir)) { + const remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); + assert.strictEqual(remainingGsd.length, 0, + `Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.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..5a26c90dc --- /dev/null +++ b/tests/hermes-skills-migration.test.cjs @@ -0,0 +1,287 @@ +/** + * 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'); + +// ─── 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 + const content = fs.readFileSync(skillPath, 'utf8'); + assert.ok(content.includes('name: gsd-quick'), 'frontmatter name uses hyphen form (#2808)'); + assert.ok(content.includes('description:'), 'description present'); + assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved'); + 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'); + assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved'); + }); +}); + +// ─── Integration: SKILL.md format validation ──────────────────────────────── + +describe('Hermes Agent: SKILL.md format validation', () => { + test('SKILL.md frontmatter is valid YAML structure', () => { + 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'); + + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review'); + + // Parse the frontmatter + const fmMatch = result.match(/^---\n([\s\S]*?)\n---/); + assert.ok(fmMatch, 'has frontmatter block'); + + const fmLines = fmMatch[1].split('\n'); + const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); + const hasDesc = fmLines.some(l => l.startsWith('description:')); + const hasAgent = fmLines.some(l => l.startsWith('agent:')); + const hasTools = fmLines.some(l => l.startsWith('allowed-tools:')); + + assert.ok(hasName, 'name field correct'); + assert.ok(hasDesc, 'description field present'); + assert.ok(hasAgent, 'agent field present'); + assert.ok(hasTools, 'allowed-tools field present'); + }); +}); diff --git a/tests/kilo-install.test.cjs b/tests/kilo-install.test.cjs index 51712a1ca..b0188d1bd 100644 --- a/tests/kilo-install.test.cjs +++ b/tests/kilo-install.test.cjs @@ -224,12 +224,12 @@ 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', () => { + assert.ok(src.includes("'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('11${reset}) Kilo'), 'prompt lists Kilo as option 11'); assert.ok(!src.includes('the #1 AI coding platform on OpenRouter'), 'prompt does not include marketing tagline'); }); diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index c0b1b0004..25536110a 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -27,13 +27,14 @@ const runtimeMap = { '7': 'copilot', '8': 'cursor', '9': 'gemini', - '10': 'kilo', - '11': 'opencode', - '12': 'qwen', - '13': 'trae', - '14': 'windsurf' + '10': 'hermes', + '11': 'kilo', + '12': 'opencode', + '13': 'qwen', + '14': 'trae', + '15': 'windsurf' }; -const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; +const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf']; /** * Simulate the parsing logic from promptRuntime without requiring readline. @@ -42,7 +43,7 @@ const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', ' function parseRuntimeInput(input) { input = input.trim() || '1'; - if (input === '15') { + if (input === '16') { return allRuntimes; } @@ -78,7 +79,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 +87,32 @@ 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('empty input defaults to claude', () => { @@ -112,13 +121,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,12 +138,12 @@ 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', () => { + test('runtimeMap is defined with all 15 runtimes', () => { for (const [key, name] of Object.entries(runtimeMap)) { assert.ok( installSrc.includes(`'${key}': '${name}'`), @@ -151,25 +160,29 @@ describe('install.js source contains multi-select support', () => { } }); - test('all shortcut uses option 15', () => { + test('all shortcut uses option 16', () => { assert.ok( - installSrc.includes("if (input === '15')"), - 'all shortcut uses option 15' + installSrc.includes("if (input === '16')"), + 'all shortcut uses option 16' ); }); - test('prompt lists Qwen Code as option 12, Trae as option 13 and All as option 15', () => { + test('prompt lists Hermes Agent as option 10, Qwen Code as option 13, and All as option 16', () => { assert.ok( - installSrc.includes('12${reset}) Qwen Code'), - 'prompt lists Qwen Code as option 12' + installSrc.includes('10${reset}) Hermes Agent'), + 'prompt lists Hermes Agent as option 10' ); assert.ok( - installSrc.includes('13${reset}) Trae'), - 'prompt lists Trae as option 13' + installSrc.includes('13${reset}) Qwen Code'), + 'prompt lists Qwen Code as option 13' ); assert.ok( - installSrc.includes('15${reset}) All'), - 'prompt lists All as option 15' + installSrc.includes('14${reset}) Trae'), + 'prompt lists Trae as option 14' + ); + assert.ok( + installSrc.includes('16${reset}) All'), + 'prompt lists All as option 16' ); }); From 4ce72cdee786e576c724e50d81a016e477105c3d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:22:36 -0400 Subject: [PATCH 2/8] fix(hermes): align with Hermes Agent conventions per docs review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from review of hermes-agent.nousresearch.com docs: 1. SKILL.md frontmatter now declares `version` (required field per Hermes spec). Plumbed through `convertClaudeCommandToClaudeSkill` gated on runtime='hermes' so other runtimes' frontmatter is unchanged. 2. Project-context filename rewrite changed from `HERMES.md` (not discovered by Hermes) to `.hermes.md` (top of Hermes' discovery list: .hermes.md → AGENTS.md → CLAUDE.md → .cursorrules). 3. README + finishInstall now show `/gsd-help` and `/gsd-new-project` for Hermes; per docs, Hermes auto-exposes skills as slash commands. 4. Hermes tests now parse SKILL.md frontmatter structurally via the shared parseFrontmatter helper instead of substring-matching source text, and assert the version/name/description shape required by Hermes' skill_view(). Full suite: 6128/6128 pass (3 new structural assertions). --- README.md | 3 +- bin/install.js | 20 ++++++---- tests/hermes-install.test.cjs | 55 +++++++++++++++++++++++++- tests/hermes-skills-migration.test.cjs | 53 ++++++++++++++++--------- 4 files changed, 102 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 4826da8d8..2fed3fcdc 100644 --- a/README.md +++ b/README.md @@ -108,8 +108,7 @@ The installer prompts you to choose: 2. **Location** — Global (all projects) or local (current project only) Verify with: -- Claude Code / Gemini / Copilot / Antigravity / Qwen Code: `/gsd-help` -- Hermes Agent: ask the agent to use `gsd-help` (skills are natural-language-triggered) +- 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 diff --git a/bin/install.js b/bin/install.js index 46f55b542..fbd157757 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1193,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; @@ -1213,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; @@ -5366,12 +5370,12 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti } // Hermes Agent reuses Claude skill format; rewrite branding + paths. if (runtime === 'hermes') { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + 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); } @@ -5588,7 +5592,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(/\.claude\//g, '.qwen/'); fs.writeFileSync(destPath, content); } else if (isHermes) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + 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); @@ -5647,7 +5651,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand 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(/CLAUDE\.md/g, '.hermes.md'); jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent'); fs.writeFileSync(destPath, jsContent); } else { @@ -7264,7 +7268,7 @@ function install(isGlobal, runtime = 'claude') { 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(/CLAUDE\.md/g, '.hermes.md'); content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); content = content.replace(/\.claude\//g, '.hermes/'); } @@ -7332,7 +7336,7 @@ function install(isGlobal, runtime = 'claude') { content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); } if (isHermes) { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + 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); @@ -8072,7 +8076,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 (mention the skill name, e.g. "use 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}. diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs index 240efb69f..cdf80c741 100644 --- a/tests/hermes-install.test.cjs +++ b/tests/hermes-install.test.cjs @@ -5,7 +5,8 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { createTempDir, cleanup } = require('./helpers.cjs'); +const { createTempDir, cleanup, parseFrontmatter } = require('./helpers.cjs'); +const pkg = require('../package.json'); const { getDirName, @@ -109,6 +110,58 @@ describe('Hermes Agent local install/uninstall', () => { assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'Hermes skill directory 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'); + const skillsDir = path.join(targetDir, 'skills'); + const skillDirs = fs.readdirSync(skillsDir, { 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(skillsDir, 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)})`); + } + + uninstall(false, 'hermes'); + }); + + test('replaces CLAUDE.md references with .hermes.md (Hermes-discovered project context name)', () => { + 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; if any + // skill body referenced project context, it should now point at + // `.hermes.md` (per https://hermes-agent.nousresearch.com/docs). + 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 (/\.hermes\.md/.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', () => { diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index 5a26c90dc..5857358e7 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -19,6 +19,8 @@ const { convertClaudeCommandToClaudeSkill, copyCommandsAsClaudeSkills, } = require('../bin/install.js'); +const { parseFrontmatter } = require('./helpers.cjs'); +const pkg = require('../package.json'); // ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ── @@ -152,11 +154,15 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => { const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md'); assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists'); - // Verify content + // Verify content (structural — parse frontmatter, don't substring-grep) const content = fs.readFileSync(skillPath, 'utf8'); - assert.ok(content.includes('name: gsd-quick'), 'frontmatter name uses hyphen form (#2808)'); - assert.ok(content.includes('description:'), 'description present'); - assert.ok(content.includes('allowed-tools:'), 'allowed-tools preserved'); + 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'); }); @@ -251,7 +257,7 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => { // ─── Integration: SKILL.md format validation ──────────────────────────────── describe('Hermes Agent: SKILL.md format validation', () => { - test('SKILL.md frontmatter is valid YAML structure', () => { + test('SKILL.md frontmatter parses with required Hermes fields', () => { const input = [ '---', 'name: gsd:review', @@ -267,21 +273,32 @@ describe('Hermes Agent: SKILL.md format validation', () => { 'Review code', ].join('\n'); - const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review'); + // Pass runtime='hermes' so the version field is injected per Hermes spec. + const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review', 'hermes'); + const fm = parseFrontmatter(result); - // Parse the frontmatter - const fmMatch = result.match(/^---\n([\s\S]*?)\n---/); - assert.ok(fmMatch, 'has frontmatter block'); + 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'); + }); - const fmLines = fmMatch[1].split('\n'); - const hasName = fmLines.some(l => l.startsWith('name: gsd-review')); - const hasDesc = fmLines.some(l => l.startsWith('description:')); - const hasAgent = fmLines.some(l => l.startsWith('agent:')); - const hasTools = fmLines.some(l => l.startsWith('allowed-tools:')); + 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'); - assert.ok(hasName, 'name field correct'); - assert.ok(hasDesc, 'description field present'); - assert.ok(hasAgent, 'agent field present'); - assert.ok(hasTools, 'allowed-tools field present'); + 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'); }); }); From bd0511988bbbdca9285fc1d7c2178cfb49c3ac9a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:30:48 -0400 Subject: [PATCH 3/8] fix(hermes): nest GSD skills under skills/gsd/ category (#2841) Per spec in #2841, all 86 GSD skills must collapse into a single "gsd" category in Hermes' system prompt. Previous code passed skills/ as the install root, producing a flat skills/gsd-*/ layout that inflated Hermes' loader output to 86 top-level entries. Changes: - Install path now writes to skills/gsd/{DESCRIPTION.md, gsd-*/SKILL.md} - Uninstall removes the entire skills/gsd/ category dir plus any leftover flat-layout gsd-*/ from older installs (graceful migration) - writeManifest emits skills/gsd// paths for Hermes - --skills-root hermes returns the nested category path so /gsd-sync-skills syncs into the right directory - DESCRIPTION.md at category root carries name/version/description so Hermes' skill loader surfaces the GSD category in the system prompt Also extracts promptRuntime's runtimeMap, allRuntimes, parseRuntimeInput, and buildRuntimePromptText to module scope and exports them so tests can assert structurally instead of grepping bin/install.js source. Existing hermes-install tests updated to expect the nested layout and to verify the category DESCRIPTION.md frontmatter (name, version, description) using the shared parseFrontmatter helper. Co-Authored-By: Claude Opus 4.7 --- bin/install.js | 248 ++++++++++++++++++++++++---------- tests/hermes-install.test.cjs | 39 ++++-- 2 files changed, 202 insertions(+), 85 deletions(-) diff --git a/bin/install.js b/bin/install.js index fbd157757..ad88b9542 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5384,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. @@ -6014,10 +6041,22 @@ function uninstall(isGlobal, runtime = 'claude') { restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); } } else if (isHermes) { - // Hermes Agent: remove skills/gsd-*/ directories (same layout as Qwen) + // 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)) { - let skillCount = 0; const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name.startsWith('gsd-')) { @@ -6025,10 +6064,10 @@ function uninstall(isGlobal, runtime = 'claude') { skillCount++; } } - if (skillCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Hermes Agent skills`); - } + } + if (skillCount > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${skillCount} Hermes Agent skills`); } const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); @@ -6665,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, @@ -6705,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); } } } @@ -7042,22 +7094,38 @@ function install(isGlobal, runtime = 'claude') { restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); } } else if (isHermes) { - // Hermes Agent: reuses Claude-skill-format pipeline (skills/gsd-*/SKILL.md). - // Hermes' skill loader accepts unknown frontmatter keys (allowed-tools, - // argument-hint) and renders the gsd-* skills under ~/.hermes/skills/. - const skillsDir = path.join(targetDir, 'skills'); + // 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, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + 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/`); + console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`); } else { - failures.push('skills/gsd-*'); + failures.push('skills/gsd/gsd-*'); } } else { - failures.push('skills/gsd-*'); + 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'); @@ -8138,42 +8206,37 @@ function handleStatusline(settings, isInteractive, callback) { /** * Prompt for runtime selection */ -function promptRuntime(callback) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); +/** + * 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'; - let answered = false; - - rl.on('close', () => { - if (!answered) { - answered = true; - console.log(`\n ${yellow}Installation cancelled${reset}\n`); - process.exit(0); - } - }); - - 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']; - - console.log(` ${yellow}Which runtime(s) would you like to install for?${reset}\n\n ${cyan}1${reset}) Claude Code ${dim}(~/.claude)${reset} +/** + * 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} @@ -8191,30 +8254,58 @@ function promptRuntime(callback) { ${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'; + + if (input === ALL_RUNTIMES_OPTION) { + return allRuntimes.slice(); + } + + 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']; +} + +function promptRuntime(callback) { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + let answered = false; + + rl.on('close', () => { + if (!answered) { + answered = true; + console.log(`\n ${yellow}Installation cancelled${reset}\n`); + process.exit(0); + } + }); + + console.log(buildRuntimePromptText()); rl.question(` Choice ${dim}[1]${reset}: `, (answer) => { answered = true; rl.close(); - const input = answer.trim() || '1'; - - // "All" shortcut - if (input === '16') { - 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)); }); } @@ -8844,6 +8935,10 @@ if (process.env.GSD_TEST_MODE) { finishInstall, homePathCoveredByRc, maybeSuggestPathExport, + runtimeMap, + allRuntimes, + parseRuntimeInput, + buildRuntimePromptText, }; } else { @@ -8857,7 +8952,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/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs index cdf80c741..3d02ee2ea 100644 --- a/tests/hermes-install.test.cjs +++ b/tests/hermes-install.test.cjs @@ -98,24 +98,30 @@ describe('Hermes Agent local install/uninstall', () => { assert.strictEqual(result.runtime, 'hermes'); assert.strictEqual(result.configDir, fs.realpathSync(targetDir)); - assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md'))); + // 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-help/')), manifest); + 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-help')), 'Hermes skill directory removed'); + 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'); - const skillsDir = path.join(targetDir, 'skills'); - const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true }) + // 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); @@ -123,7 +129,7 @@ describe('Hermes Agent local install/uninstall', () => { // Parse every SKILL.md and assert structural shape required by Hermes. for (const dir of skillDirs) { - const content = fs.readFileSync(path.join(skillsDir, dir, 'SKILL.md'), 'utf8'); + 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, @@ -132,6 +138,15 @@ describe('Hermes Agent local install/uninstall', () => { `${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'); }); @@ -184,19 +199,21 @@ describe('E2E: Hermes Agent uninstall skills cleanup', () => { install(false, 'hermes'); const skillsDir = path.join(targetDir, 'skills'); - assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install'); + const categoryDir = path.join(skillsDir, 'gsd'); + assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install'); - const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true }) + 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 remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true }) + const remainingFlat = fs.readdirSync(skillsDir, { withFileTypes: true }) .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - assert.strictEqual(remainingGsd.length, 0, - `Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.map(e => e.name).join(', ')}`); + assert.strictEqual(remainingFlat.length, 0, + `Expected 0 stray flat gsd-* skill dirs after uninstall, found: ${remainingFlat.map(e => e.name).join(', ')}`); } }); From 861a7d972b31551949c3be632b1a3e95924bf7b6 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:30:48 -0400 Subject: [PATCH 4/8] test(install): replace source-grep prompt assertions with structured checks Two test files were asserting installer prompt behavior by regex/.includes() against bin/install.js source. Per CONTRIBUTING.md "no-source-grep" testing standard, replace with structured assertions: - tests/kilo-install.test.cjs: import runtimeMap and buildRuntimePromptText from the install module; assert runtimeMap['11'] === 'kilo' and that the rendered prompt lists Kilo above OpenCode without marketing copy. - tests/multi-runtime-select.test.cjs: import runtimeMap, allRuntimes, parseRuntimeInput, buildRuntimePromptText. Assert exported runtimeMap matches the canonical option list, allRuntimes contains every runtime exactly once, prompt text lists Hermes (10), Qwen Code (13), Trae (14), All (16), and parser splits/dedupes by exercising parseRuntimeInput rather than regexing source code. Co-Authored-By: Claude Opus 4.7 --- tests/kilo-install.test.cjs | 23 +++- tests/multi-runtime-select.test.cjs | 176 ++++++++++++---------------- 2 files changed, 96 insertions(+), 103 deletions(-) diff --git a/tests/kilo-install.test.cjs b/tests/kilo-install.test.cjs index b0188d1bd..77553c93d 100644 --- a/tests/kilo-install.test.cjs +++ b/tests/kilo-install.test.cjs @@ -225,12 +225,29 @@ describe('Source code integration (Kilo)', () => { }); test('promptRuntime runtimeMap has Kilo as option 11', () => { - assert.ok(src.includes("'11': 'kilo'"), 'runtimeMap has 11 -> kilo'); + // 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('11${reset}) Kilo'), 'prompt lists Kilo as option 11'); - 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 25536110a..e7f760dc1 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -3,60 +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': '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']; - -/** - * 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 === '16') { - 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', () => { @@ -142,68 +111,75 @@ describe('multi-runtime selection parsing', () => { }); }); -describe('install.js source contains multi-select support', () => { - test('runtimeMap is defined with all 15 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 16', () => { - assert.ok( - installSrc.includes("if (input === '16')"), - 'all shortcut uses option 16' - ); - }); - - test('prompt lists Hermes Agent as option 10, Qwen Code as option 13, and All as option 16', () => { - assert.ok( - installSrc.includes('10${reset}) Hermes Agent'), - 'prompt lists Hermes Agent as option 10' - ); - assert.ok( - installSrc.includes('13${reset}) Qwen Code'), - 'prompt lists Qwen Code as option 13' - ); - assert.ok( - installSrc.includes('14${reset}) Trae'), - 'prompt lists Trae as option 14' - ); - assert.ok( - installSrc.includes('16${reset}) All'), - 'prompt lists All as option 16' - ); + 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'); }); }); From b5777572f71d557b971f7e273a644f1fc68f9268 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:44:12 -0400 Subject: [PATCH 5/8] docs(readme): add Hermes uninstall examples (CR #2920) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2fed3fcdc..2d4d15f29 100644 --- a/README.md +++ b/README.md @@ -923,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 @@ -939,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 ``` From e5cd523e7b981fa8a99eae4dcc74cd57c574e24e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:44:12 -0400 Subject: [PATCH 6/8] test(hermes): use parseFrontmatter for agent assertion (CR #2920) --- tests/hermes-skills-migration.test.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index 5857358e7..84c24b5da 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -250,7 +250,8 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => { copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false); const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8'); - assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved'); + const fm = parseFrontmatter(content); + assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved'); }); }); From c9d6306981dec80d59413afce27845876ad17a71 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 22:30:16 -0400 Subject: [PATCH 7/8] =?UTF-8?q?fix(hermes):=20rewrite=20CLAUDE.md=20?= =?UTF-8?q?=E2=86=92=20HERMES.md=20(revert=20from=20.hermes.md=20per=20spe?= =?UTF-8?q?c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the issue spec for #2841 and CodeRabbit feedback on PR #2920, the project-context filename rewrite should produce HERMES.md, not .hermes.md. Reverts the earlier .hermes.md change at all 5 substitution sites in bin/install.js and updates the corresponding regression test in tests/hermes-install.test.cjs to assert HERMES.md. Full suite: 6127/6127 pass. --- bin/install.js | 10 +++++----- tests/hermes-install.test.cjs | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/bin/install.js b/bin/install.js index ad88b9542..e1020afbc 100755 --- a/bin/install.js +++ b/bin/install.js @@ -5370,7 +5370,7 @@ function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runti } // Hermes Agent reuses Claude skill format; rewrite branding + paths. if (runtime === 'hermes') { - content = content.replace(/CLAUDE\.md/g, '.hermes.md'); + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); content = content.replace(/\.claude\//g, '.hermes/'); } @@ -5619,7 +5619,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand content = content.replace(/\.claude\//g, '.qwen/'); fs.writeFileSync(destPath, content); } else if (isHermes) { - content = content.replace(/CLAUDE\.md/g, '.hermes.md'); + 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); @@ -5678,7 +5678,7 @@ function copyWithPathReplacement(srcDir, destDir, pathPrefix, runtime, isCommand 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(/CLAUDE\.md/g, 'HERMES.md'); jsContent = jsContent.replace(/\bClaude Code\b/g, 'Hermes Agent'); fs.writeFileSync(destPath, jsContent); } else { @@ -7336,7 +7336,7 @@ function install(isGlobal, runtime = 'claude') { 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(/CLAUDE\.md/g, 'HERMES.md'); content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); content = content.replace(/\.claude\//g, '.hermes/'); } @@ -7404,7 +7404,7 @@ function install(isGlobal, runtime = 'claude') { content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); } if (isHermes) { - content = content.replace(/CLAUDE\.md/g, '.hermes.md'); + 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); diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs index 3d02ee2ea..3724e4d0c 100644 --- a/tests/hermes-install.test.cjs +++ b/tests/hermes-install.test.cjs @@ -150,14 +150,14 @@ describe('Hermes Agent local install/uninstall', () => { uninstall(false, 'hermes'); }); - test('replaces CLAUDE.md references with .hermes.md (Hermes-discovered project context name)', () => { + 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; if any - // skill body referenced project context, it should now point at - // `.hermes.md` (per https://hermes-agent.nousresearch.com/docs). + // 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 })) { @@ -167,13 +167,13 @@ describe('Hermes Agent local install/uninstall', () => { const content = fs.readFileSync(full, 'utf8'); assert.ok(!/\bCLAUDE\.md\b/.test(content), `${path.relative(targetDir, full)} still references CLAUDE.md`); - if (/\.hermes\.md/.test(content)) referencedHermesMd = true; + 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'); + assert.ok(referencedHermesMd, 'at least one skill references HERMES.md after substitution'); uninstall(false, 'hermes'); }); From 372d3453f5879d2ab10ba81f684a82997131c9f5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 22:48:08 -0400 Subject: [PATCH 8/8] fix(install): tokenize before ALL_RUNTIMES_OPTION check + isolate HERMES_HOME in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CodeRabbit findings on PR #2920: 1. parseRuntimeInput previously only matched the bare "16" exactly for the all-runtimes shortcut. Inputs the prompt explicitly encourages — "16,", "16 1", "1,16" — fell through to per-token parsing and silently installed only Claude or a partial subset. Move the ALL_RUNTIMES_OPTION check after tokenization so any token equal to "16" expands. Added regression coverage in tests/multi-runtime-select.test.cjs for the four mixed-input forms. 2. The "maps Hermes to ~/.hermes for global installs" test invoked getGlobalDir('hermes') without isolating HERMES_HOME. On a developer machine that exports HERMES_HOME the assertion would fail even though getGlobalDir was behaving correctly. Save/clear/restore the env var around the assertion, mirroring the pattern the later describe block already uses. Full suite: 6128/6128 pass. --- bin/install.js | 6 ++++-- tests/hermes-install.test.cjs | 11 ++++++++++- tests/multi-runtime-select.test.cjs | 11 +++++++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/bin/install.js b/bin/install.js index e1020afbc..779a1ae55 100755 --- a/bin/install.js +++ b/bin/install.js @@ -8268,11 +8268,13 @@ function buildRuntimePromptText() { function parseRuntimeInput(answer) { const input = (answer == null ? '' : String(answer)).trim() || '1'; - if (input === ALL_RUNTIMES_OPTION) { + // 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 choices = input.split(/[\s,]+/).filter(Boolean); const selected = []; for (const c of choices) { const runtime = runtimeMap[c]; diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs index 3724e4d0c..13d2395a5 100644 --- a/tests/hermes-install.test.cjs +++ b/tests/hermes-install.test.cjs @@ -23,7 +23,16 @@ describe('Hermes Agent runtime directory mapping', () => { }); test('maps Hermes to ~/.hermes for global installs', () => { - assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes')); + // 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', () => { diff --git a/tests/multi-runtime-select.test.cjs b/tests/multi-runtime-select.test.cjs index e7f760dc1..1e15d046b 100644 --- a/tests/multi-runtime-select.test.cjs +++ b/tests/multi-runtime-select.test.cjs @@ -84,6 +84,17 @@ describe('multi-runtime selection parsing', () => { 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', () => { assert.deepStrictEqual(parseRuntimeInput(''), ['claude']); assert.deepStrictEqual(parseRuntimeInput(' '), ['claude']);