refactor(3664): migrate bin/install.js install/uninstall to Runtime Artifact Layout Module (#3674)

Phase 2 of #3660 / ADR-3660. Routes both lifecycle verbs through the
Runtime Artifact Layout Module landed in Phase 1 (#3663):

- Add installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile)
  and uninstallRuntimeArtifacts(runtime, configDir, scope) as the public
  orchestrators. Both pre-prune stale gsd-* entries before staged copy;
  installRuntimeArtifacts brackets the prune+copy with preserveUserArtifacts /
  restoreUserArtifacts so user-owned content (e.g. gsd-dev-preferences) survives
  wipe-and-replace for claude/qwen/hermes runtimes.
- Add applyRuntimeContentRewritesInPlace as the per-runtime path/branding
  post-stage step (preserves byte-output equivalence with the legacy
  copyCommandsAs* pipeline, including Qwen/Hermes branding rewrites).
- Add _copyStaged, _removeGsdEntries kind-aware filesystem helpers.
- Add _runLegacyInstallMigrations, _runLegacyUninstallCleanup as thin
  dispatchers over existing ADR-0008 legacy migrations (Hermes flat->nested
  per #2841, dev-preferences-as-skill per #2973). For Hermes, also clean up
  the intermediate skills/gsd/gsd-*/ layout that pre-Phase-2 installs left
  on disk.
- Delete the 9 copyCommandsAs*Skills functions (Codex / Cursor / Windsurf /
  Trae / CodeBuddy / Copilot / Claude / Antigravity / Augment) and the
  _copyCommandsAsSkillsViaConverter helper. All test entry points migrated
  to call installRuntimeArtifacts directly through the unified seam.
- Collapse the 9-branch uninstall ladder to one uninstallRuntimeArtifacts
  call plus preserved non-layout side-effects (Codex TOML, Copilot
  instructions, hooks).
- Unify install dispatcher: a single _isSkillsRuntime gate routes all 11
  skills runtimes through installRuntimeArtifacts for both full and core/
  minimal profiles. Removes 11 per-runtime if-else branches (3 minimal-mode
  shim branches + 8 dead after-the-gate branches).

Net delta on bin/install.js: 11,495 -> 11,174 (-321 LOC).

New tests:
- tests/install-uninstall-layout-loop.test.cjs (34 tests) - per-runtime
  fixture assertions on install/uninstall/legacy-migration ordering.
- tests/install-hermes-regressions.test.cjs (6 tests) - covers the six
  defects surfaced by iterative review: Hermes upgrade leaves stale dirs,
  --hermes --profile=core fall-through, --qwen --profile=core fall-through,
  minimal-mode dev-preferences migration skipped (Hermes/Qwen/Claude-global),
  and ordering bug in _runLegacyInstallMigrations.

Existing tests (10,038 prior + 40 new) all green: 10,078/10,078 pass.

Refs #3664

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-17 12:00:59 -04:00
committed by GitHub
parent 812f9258e4
commit f970c09bf3
16 changed files with 2316 additions and 1335 deletions

View File

@@ -109,18 +109,20 @@ describe('Hermes Agent local install/uninstall', () => {
// 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')));
// Skills use bare stem names (no gsd- prefix) inside the category dir,
// per the layout module's prefix: '' design for Hermes (#3664).
assert.ok(fs.existsSync(path.join(targetDir, 'skills', '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);
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd/help/')), JSON.stringify(manifest.files));
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', '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');
});
@@ -128,13 +130,15 @@ describe('Hermes Agent local install/uninstall', () => {
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.
// Nested layout: skills live under skills/gsd/<stem>/SKILL.md.
// The layout module uses prefix: '' for Hermes so skill dirs have bare
// stem names (help, plan, …) inside the category dir (#3664).
const categoryDir = path.join(targetDir, 'skills', 'gsd');
const skillDirs = fs.readdirSync(categoryDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'))
.filter(e => e.isDirectory() && e.name !== 'DESCRIPTION.md')
.map(e => e.name);
assert.ok(skillDirs.length > 0, 'at least one gsd-* skill installed');
assert.ok(skillDirs.length > 0, 'at least one skill installed');
// Parse every SKILL.md and assert structural shape required by Hermes.
for (const dir of skillDirs) {
@@ -203,7 +207,7 @@ describe('E2E: Hermes Agent uninstall skills cleanup', () => {
cleanup(tmpDir);
});
test('removes all gsd-* skill directories on --hermes --uninstall', () => {
test('removes all skill directories on --hermes --uninstall', () => {
const targetDir = path.join(tmpDir, '.hermes');
install(false, 'hermes');
@@ -211,9 +215,10 @@ describe('E2E: Hermes Agent uninstall skills cleanup', () => {
const categoryDir = path.join(skillsDir, 'gsd');
assert.ok(fs.existsSync(categoryDir), 'skills/gsd/ category dir exists after install');
// Layout uses prefix: '' for Hermes — skill dirs have bare stem names (no gsd- prefix)
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`);
.filter(e => e.isDirectory());
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} skill dirs before uninstall`);
uninstall(false, 'hermes');