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:
@@ -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
|
||||
|
||||
@@ -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}.
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user