From 5a636bc90ae4808fa885b58d5a03f8c1620d2de3 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] 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 5f48fc6e9..29da0342d 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); } @@ -5097,12 +5114,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); @@ -5240,6 +5266,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); @@ -5271,6 +5298,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)); @@ -5319,6 +5349,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); } @@ -5370,6 +5405,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); } @@ -5548,6 +5590,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); @@ -5572,6 +5615,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`); @@ -5719,6 +5763,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']); @@ -6543,6 +6613,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); @@ -6587,6 +6658,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'; @@ -6712,6 +6784,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']); @@ -6919,6 +7017,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); @@ -6983,6 +7085,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) @@ -7726,6 +7832,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'; @@ -7739,6 +7846,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}. @@ -7826,13 +7934,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} @@ -7843,12 +7952,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} `); @@ -7859,7 +7969,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 808208e09..617582a15 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -1610,6 +1610,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']); @@ -1632,7 +1642,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 69d5efaec..c3fec323f 100644 --- a/tests/kilo-install.test.cjs +++ b/tests/kilo-install.test.cjs @@ -221,12 +221,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' ); });