Merge pull request #2920 from gsd-build/feat/hermes-runtime-2841
feat(install): add Hermes Agent runtime support
This commit is contained in:
14
README.md
14
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,11 @@ 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`
|
||||
- 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
|
||||
@@ -179,6 +179,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 +196,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.
|
||||
|
||||
</details>
|
||||
@@ -919,6 +923,7 @@ npx get-shit-done-cc --antigravity --global --uninstall
|
||||
npx get-shit-done-cc --augment --global --uninstall
|
||||
npx get-shit-done-cc --trae --global --uninstall
|
||||
npx get-shit-done-cc --qwen --global --uninstall
|
||||
npx get-shit-done-cc --hermes --global --uninstall
|
||||
npx get-shit-done-cc --codebuddy --global --uninstall
|
||||
npx get-shit-done-cc --cline --global --uninstall
|
||||
|
||||
@@ -935,6 +940,7 @@ npx get-shit-done-cc --antigravity --local --uninstall
|
||||
npx get-shit-done-cc --augment --local --uninstall
|
||||
npx get-shit-done-cc --trae --local --uninstall
|
||||
npx get-shit-done-cc --qwen --local --uninstall
|
||||
npx get-shit-done-cc --hermes --local --uninstall
|
||||
npx get-shit-done-cc --codebuddy --local --uninstall
|
||||
npx get-shit-done-cc --cline --local --uninstall
|
||||
```
|
||||
|
||||
342
bin/install.js
342
bin/install.js
File diff suppressed because one or more lines are too long
@@ -1293,6 +1293,16 @@ const RUNTIME_PROFILE_MAP = {
|
||||
sonnet: { model: 'claude-sonnet-4-6' },
|
||||
haiku: { model: 'claude-haiku-4-5' },
|
||||
},
|
||||
hermes: {
|
||||
// Hermes Agent is provider-agnostic; users pick any provider in ~/.hermes/config.yaml.
|
||||
// Defaults use OpenRouter slugs because (a) OpenRouter is Hermes' default provider and
|
||||
// (b) the same slugs resolve on OpenRouter, native Anthropic, and Copilot via Hermes'
|
||||
// aggregator-aware resolver. Users on a different provider override per-tier via
|
||||
// model_profile_overrides.hermes.{opus,sonnet,haiku} in .planning/config.json.
|
||||
opus: { model: 'anthropic/claude-opus-4-7' },
|
||||
sonnet: { model: 'anthropic/claude-sonnet-4-6' },
|
||||
haiku: { model: 'anthropic/claude-haiku-4-5' },
|
||||
},
|
||||
};
|
||||
|
||||
const RUNTIMES_WITH_REASONING_EFFORT = new Set(['codex']);
|
||||
@@ -1315,7 +1325,7 @@ const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
||||
const KNOWN_RUNTIMES = new Set([
|
||||
'claude', 'codex', 'opencode', 'kilo', 'gemini', 'qwen',
|
||||
'copilot', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy',
|
||||
'antigravity', 'cline',
|
||||
'antigravity', 'cline', 'hermes',
|
||||
]);
|
||||
|
||||
const _warnedConfigKeys = new Set();
|
||||
|
||||
395
tests/hermes-install.test.cjs
Normal file
395
tests/hermes-install.test.cjs
Normal file
@@ -0,0 +1,395 @@
|
||||
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, parseFrontmatter } = require('./helpers.cjs');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
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', () => {
|
||||
// Isolate from any HERMES_HOME exported on the developer's machine —
|
||||
// otherwise this test asserts the env-derived path, not the default.
|
||||
const originalHermesHome = process.env.HERMES_HOME;
|
||||
delete process.env.HERMES_HOME;
|
||||
try {
|
||||
assert.strictEqual(getGlobalDir('hermes'), path.join(os.homedir(), '.hermes'));
|
||||
} finally {
|
||||
if (originalHermesHome === undefined) delete process.env.HERMES_HOME;
|
||||
else process.env.HERMES_HOME = originalHermesHome;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns .hermes config fragments for local and global installs', () => {
|
||||
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));
|
||||
|
||||
// Nested layout per spec #2841: all GSD skills collapse into a single
|
||||
// skills/gsd/ category so Hermes' system prompt sees one entry, not 86.
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help', 'SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||
'DESCRIPTION.md exists at category root');
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'hermes');
|
||||
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/gsd-help/')), manifest);
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'gsd-help')), 'Hermes skill directory removed');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')), 'Hermes gsd category dir removed');
|
||||
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
|
||||
});
|
||||
|
||||
test('installed SKILL.md frontmatter conforms to Hermes spec', () => {
|
||||
install(false, 'hermes');
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
// Nested layout: skills live under skills/gsd/gsd-*/SKILL.md.
|
||||
const categoryDir = path.join(targetDir, 'skills', 'gsd');
|
||||
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
|
||||
.map(e => e.name);
|
||||
|
||||
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(categoryDir, dir, 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.name, dir, `${dir}/SKILL.md name matches dir`);
|
||||
assert.ok(typeof fm.description === 'string' && fm.description.length > 0,
|
||||
`${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)})`);
|
||||
}
|
||||
|
||||
// The category DESCRIPTION.md is part of the spec — verify it parses too.
|
||||
const desc = fs.readFileSync(path.join(categoryDir, 'DESCRIPTION.md'), 'utf8');
|
||||
const descFm = parseFrontmatter(desc);
|
||||
assert.strictEqual(descFm.name, 'gsd', 'category DESCRIPTION.md name is "gsd"');
|
||||
assert.ok(typeof descFm.description === 'string' && descFm.description.length > 0,
|
||||
'category DESCRIPTION.md has description');
|
||||
assert.strictEqual(descFm.version, pkg.version,
|
||||
'category DESCRIPTION.md declares version');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
});
|
||||
|
||||
test('replaces CLAUDE.md references with HERMES.md', () => {
|
||||
install(false, 'hermes');
|
||||
const targetDir = path.join(tmpDir, '.hermes');
|
||||
const skillsDir = path.join(targetDir, 'skills');
|
||||
|
||||
// Walk all skill files and confirm no `CLAUDE.md` token leaks; any
|
||||
// skill body that referenced project context should now point at
|
||||
// `HERMES.md` per the issue spec.
|
||||
let referencedHermesMd = false;
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
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 (/\bHERMES\.md\b/.test(content)) referencedHermesMd = true;
|
||||
}
|
||||
};
|
||||
walk(skillsDir);
|
||||
// Sanity: at least one skill in the GSD set references the project
|
||||
// context filename, so the substitution actually exercises.
|
||||
assert.ok(referencedHermesMd, 'at least one skill references HERMES.md after substitution');
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
const categoryDir = path.join(skillsDir, 'gsd');
|
||||
assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install');
|
||||
|
||||
const installedSkills = fs.readdirSync(categoryDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
|
||||
|
||||
uninstall(false, 'hermes');
|
||||
|
||||
assert.ok(!fs.existsSync(categoryDir), 'skills/gsd/ category dir removed by uninstall');
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const remainingFlat = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.strictEqual(remainingFlat.length, 0,
|
||||
`Expected 0 stray flat gsd-* skill dirs after uninstall, found: ${remainingFlat.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'));
|
||||
});
|
||||
});
|
||||
305
tests/hermes-skills-migration.test.cjs
Normal file
305
tests/hermes-skills-migration.test.cjs
Normal file
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* 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');
|
||||
const { parseFrontmatter } = require('./helpers.cjs');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
// ─── 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-<cmd>) 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<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test',
|
||||
'description: Test command',
|
||||
'---',
|
||||
body,
|
||||
].join('');
|
||||
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-test');
|
||||
assert.ok(result.includes('<objective>'), 'objective tag preserved');
|
||||
assert.ok(result.includes('Do the thing.'), 'body text preserved');
|
||||
assert.ok(result.includes('<process>'), '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',
|
||||
'---',
|
||||
'',
|
||||
'<objective>Quick task body</objective>',
|
||||
].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 (structural — parse frontmatter, don't substring-grep)
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Integration: SKILL.md format validation ────────────────────────────────
|
||||
|
||||
describe('Hermes Agent: SKILL.md format validation', () => {
|
||||
test('SKILL.md frontmatter parses with required Hermes fields', () => {
|
||||
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',
|
||||
'---',
|
||||
'',
|
||||
'<objective>Review code</objective>',
|
||||
].join('\n');
|
||||
|
||||
// Pass runtime='hermes' so the version field is injected per Hermes spec.
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review', 'hermes');
|
||||
const fm = parseFrontmatter(result);
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -224,13 +224,30 @@ 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', () => {
|
||||
// Structural assertion against exported runtimeMap rather than source-grep.
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
|
||||
const { runtimeMap } = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
assert.strictEqual(runtimeMap['11'], 'kilo', 'runtimeMap has 11 -> kilo');
|
||||
});
|
||||
|
||||
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
|
||||
assert.ok(src.includes('10${reset}) Kilo'), 'prompt lists Kilo as option 10');
|
||||
assert.ok(!src.includes('the #1 AI coding platform on OpenRouter'), 'prompt does not include marketing tagline');
|
||||
// Call the exported prompt builder; assert against rendered text, not raw source.
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
delete require.cache[require.resolve(path.join(__dirname, '..', 'bin', 'install.js'))];
|
||||
const { buildRuntimePromptText } = require(path.join(__dirname, '..', 'bin', 'install.js'));
|
||||
const promptText = buildRuntimePromptText();
|
||||
// Strip ANSI color codes so assertions don't depend on terminal escapes.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const plain = promptText.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
assert.ok(/\b11\)\s*Kilo\b/.test(plain), 'prompt lists Kilo as option 11');
|
||||
const kiloIdx = plain.indexOf('11) Kilo');
|
||||
const opencodeIdx = plain.indexOf('OpenCode');
|
||||
assert.ok(kiloIdx > -1 && opencodeIdx > -1 && kiloIdx < opencodeIdx,
|
||||
'Kilo appears above OpenCode in prompt');
|
||||
assert.ok(!plain.includes('the #1 AI coding platform on OpenRouter'),
|
||||
'prompt does not include marketing tagline');
|
||||
});
|
||||
|
||||
test('hooks are skipped for Kilo', () => {
|
||||
|
||||
@@ -3,59 +3,29 @@
|
||||
* Verifies that promptRuntime accepts comma-separated, space-separated,
|
||||
* and single-choice inputs, deduplicates, and falls back to claude.
|
||||
* See issue #1281.
|
||||
*
|
||||
* Per CONTRIBUTING.md "no-source-grep" testing standard, prompt + parser
|
||||
* behavior is asserted via the install module's exported pure functions
|
||||
* (`runtimeMap`, `allRuntimes`, `parseRuntimeInput`, `buildRuntimePromptText`)
|
||||
* instead of regexing bin/install.js source text.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// Read install.js source to extract the runtimeMap and parsing logic
|
||||
const installSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'bin', 'install.js'),
|
||||
'utf8'
|
||||
);
|
||||
const {
|
||||
runtimeMap,
|
||||
allRuntimes,
|
||||
parseRuntimeInput,
|
||||
buildRuntimePromptText,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// Extract runtimeMap from source for validation
|
||||
const runtimeMap = {
|
||||
'1': 'claude',
|
||||
'2': 'antigravity',
|
||||
'3': 'augment',
|
||||
'4': 'cline',
|
||||
'5': 'codebuddy',
|
||||
'6': 'codex',
|
||||
'7': 'copilot',
|
||||
'8': 'cursor',
|
||||
'9': 'gemini',
|
||||
'10': 'kilo',
|
||||
'11': 'opencode',
|
||||
'12': 'qwen',
|
||||
'13': 'trae',
|
||||
'14': 'windsurf'
|
||||
};
|
||||
const allRuntimes = ['claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'qwen', 'trae', 'windsurf'];
|
||||
|
||||
/**
|
||||
* Simulate the parsing logic from promptRuntime without requiring readline.
|
||||
* This mirrors the exact logic in the rl.question callback.
|
||||
*/
|
||||
function parseRuntimeInput(input) {
|
||||
input = input.trim() || '1';
|
||||
|
||||
if (input === '15') {
|
||||
return allRuntimes;
|
||||
}
|
||||
|
||||
const choices = input.split(/[\s,]+/).filter(Boolean);
|
||||
const selected = [];
|
||||
for (const c of choices) {
|
||||
const runtime = runtimeMap[c];
|
||||
if (runtime && !selected.includes(runtime)) {
|
||||
selected.push(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
return selected.length > 0 ? selected : ['claude'];
|
||||
// Strip ANSI color codes for human-readable assertions on prompt text.
|
||||
function stripAnsi(s) {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/\x1b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('multi-runtime selection parsing', () => {
|
||||
@@ -78,7 +48,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 +56,43 @@ 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('choice 16 returns all runtimes when mixed with separators or other tokens', () => {
|
||||
// CR feedback: tokenized inputs that include 16 (e.g. trailing comma, or
|
||||
// alongside other choices) must still expand to all-runtimes — previously
|
||||
// only the bare "16" matched, so "16," or "16 1" silently installed a
|
||||
// subset.
|
||||
assert.deepStrictEqual(parseRuntimeInput('16,'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('16 1'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,16'), allRuntimes);
|
||||
assert.deepStrictEqual(parseRuntimeInput(' 16 '), allRuntimes);
|
||||
});
|
||||
|
||||
test('empty input defaults to claude', () => {
|
||||
@@ -112,13 +101,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,68 +118,79 @@ 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', () => {
|
||||
for (const [key, name] of Object.entries(runtimeMap)) {
|
||||
assert.ok(
|
||||
installSrc.includes(`'${key}': '${name}'`),
|
||||
`runtimeMap has ${key} -> ${name}`
|
||||
);
|
||||
describe('install.js exports multi-select runtime metadata', () => {
|
||||
const expectedRuntimeMap = {
|
||||
'1': 'claude',
|
||||
'2': 'antigravity',
|
||||
'3': 'augment',
|
||||
'4': 'cline',
|
||||
'5': 'codebuddy',
|
||||
'6': 'codex',
|
||||
'7': 'copilot',
|
||||
'8': 'cursor',
|
||||
'9': 'gemini',
|
||||
'10': 'hermes',
|
||||
'11': 'kilo',
|
||||
'12': 'opencode',
|
||||
'13': 'qwen',
|
||||
'14': 'trae',
|
||||
'15': 'windsurf',
|
||||
};
|
||||
const expectedRuntimes = [
|
||||
'claude', 'antigravity', 'augment', 'cline', 'codebuddy', 'codex',
|
||||
'copilot', 'cursor', 'gemini', 'hermes', 'kilo', 'opencode', 'qwen',
|
||||
'trae', 'windsurf',
|
||||
];
|
||||
|
||||
test('runtimeMap exports every option key bound to the right runtime', () => {
|
||||
assert.deepStrictEqual(runtimeMap, expectedRuntimeMap,
|
||||
'exported runtimeMap matches the canonical option list');
|
||||
});
|
||||
|
||||
test('allRuntimes contains every runtime exactly once', () => {
|
||||
assert.strictEqual(allRuntimes.length, expectedRuntimes.length);
|
||||
for (const rt of expectedRuntimes) {
|
||||
assert.ok(allRuntimes.includes(rt), `allRuntimes contains ${rt}`);
|
||||
}
|
||||
assert.strictEqual(new Set(allRuntimes).size, allRuntimes.length,
|
||||
'allRuntimes has no duplicates');
|
||||
});
|
||||
|
||||
test('allRuntimes array contains all runtimes', () => {
|
||||
const match = installSrc.match(/const allRuntimes = \[([^\]]+)\]/);
|
||||
assert.ok(match, 'allRuntimes array found');
|
||||
for (const rt of allRuntimes) {
|
||||
assert.ok(match[1].includes(`'${rt}'`), `allRuntimes includes ${rt}`);
|
||||
}
|
||||
test('"All" shortcut (option 16) selects every runtime', () => {
|
||||
assert.deepStrictEqual(parseRuntimeInput('16'), allRuntimes);
|
||||
});
|
||||
|
||||
test('all shortcut uses option 15', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("if (input === '15')"),
|
||||
'all shortcut uses option 15'
|
||||
);
|
||||
});
|
||||
|
||||
test('prompt lists Qwen Code as option 12, Trae as option 13 and All as option 15', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('12${reset}) Qwen Code'),
|
||||
'prompt lists Qwen Code as option 12'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('13${reset}) Trae'),
|
||||
'prompt lists Trae as option 13'
|
||||
);
|
||||
assert.ok(
|
||||
installSrc.includes('15${reset}) All'),
|
||||
'prompt lists All as option 15'
|
||||
);
|
||||
test('prompt lists Hermes Agent (10), Qwen Code (13), Trae (14), and All (16)', () => {
|
||||
const prompt = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/\b10\)\s*Hermes Agent\b/.test(prompt),
|
||||
'prompt lists Hermes Agent as option 10');
|
||||
assert.ok(/\b13\)\s*Qwen Code\b/.test(prompt),
|
||||
'prompt lists Qwen Code as option 13');
|
||||
assert.ok(/\b14\)\s*Trae\b/.test(prompt),
|
||||
'prompt lists Trae as option 14');
|
||||
assert.ok(/\b16\)\s*All\b/.test(prompt),
|
||||
'prompt lists All as option 16');
|
||||
});
|
||||
|
||||
test('prompt text shows multi-select hint', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('Select multiple'),
|
||||
'prompt includes multi-select instructions'
|
||||
);
|
||||
const prompt = stripAnsi(buildRuntimePromptText());
|
||||
assert.ok(/Select multiple/i.test(prompt),
|
||||
'prompt includes multi-select instructions');
|
||||
});
|
||||
|
||||
test('parsing uses split with comma and space regex', () => {
|
||||
assert.ok(
|
||||
installSrc.includes("split(/[\\s,]+/)"),
|
||||
'input is split on commas and whitespace'
|
||||
);
|
||||
});
|
||||
|
||||
test('deduplication check exists', () => {
|
||||
assert.ok(
|
||||
installSrc.includes('!selected.includes(runtime)'),
|
||||
'deduplication guard exists'
|
||||
test('parser splits on commas and whitespace and deduplicates', () => {
|
||||
// Behavioral assertion: same set of choices in different separators
|
||||
// produces the same selection, and duplicates collapse.
|
||||
assert.deepStrictEqual(
|
||||
parseRuntimeInput('1,7,9'),
|
||||
parseRuntimeInput('1 7 9'),
|
||||
'comma- and space-separated input yield identical selections'
|
||||
);
|
||||
assert.deepStrictEqual(parseRuntimeInput('1,1,7,7'), ['claude', 'copilot'],
|
||||
'duplicates collapsed in order');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user