fix(hermes): align with Hermes Agent conventions per docs review

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).
This commit is contained in:
Tom Boucher
2026-04-30 21:22:36 -04:00
parent b126c0579a
commit 4ce72cdee7
4 changed files with 102 additions and 29 deletions

View File

@@ -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

View File

@@ -1193,7 +1193,7 @@ function skillFrontmatterName(skillDirName) {
* Emits `name: gsd-<cmd>` (hyphen) so Skill(skill="gsd-<cmd>") 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}.

View File

@@ -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', () => {

View File

@@ -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('<objective>'), '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', () => {
'<objective>Review code</objective>',
].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');
});
});