From 4ce72cdee786e576c724e50d81a016e477105c3d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 30 Apr 2026 21:22:36 -0400 Subject: [PATCH] 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'); }); });