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:
1706
bin/install.js
1706
bin/install.js
File diff suppressed because it is too large
Load Diff
@@ -25,10 +25,16 @@ const {
|
||||
convertClaudeToAntigravityContent,
|
||||
convertClaudeCommandToAntigravitySkill,
|
||||
convertClaudeAgentToAntigravityAgent,
|
||||
copyCommandsAsAntigravitySkills,
|
||||
writeManifest,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
// ─── getDirName ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getDirName (Antigravity)', () => {
|
||||
@@ -285,98 +291,83 @@ Execute plans from ~/.claude/get-shit-done/workflows/execute-phase.md
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsAntigravitySkills ───────────────────────────────────────────
|
||||
// ─── installRuntimeArtifacts (antigravity integration) ────────────────────────
|
||||
|
||||
describe('copyCommandsAsAntigravitySkills', () => {
|
||||
let tmpDir;
|
||||
let srcDir;
|
||||
let skillsDir;
|
||||
describe('installRuntimeArtifacts (antigravity integration)', () => {
|
||||
// Pivoted from copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false) shim to
|
||||
// installRuntimeArtifacts('antigravity', configDir, 'local'|'global', resolvedProfileFull).
|
||||
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||
// stageSkillsForRuntimeAsSkills does NOT recurse into subdirectories; the real
|
||||
// commands/gsd/ directory has no subdirs, so subdir-handling is not a production path.
|
||||
let configDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-ag-test-');
|
||||
srcDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
skillsDir = path.join(tmpDir, 'skills');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
|
||||
// Create a sample command file
|
||||
fs.writeFileSync(path.join(srcDir, 'new-project.md'), `---
|
||||
name: gsd:new-project
|
||||
description: Initialize a new project
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Write
|
||||
---
|
||||
Run /gsd:new-project to start.
|
||||
`);
|
||||
|
||||
// Create a subdirectory command
|
||||
const subDir = path.join(srcDir, 'subdir');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(subDir, 'sub-command.md'), `---
|
||||
name: gsd:sub-command
|
||||
description: A sub-command
|
||||
allowed-tools:
|
||||
- Read
|
||||
---
|
||||
Body text.
|
||||
`);
|
||||
configDir = createTempDir('gsd-ag-test-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('creates skills directory', () => {
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
assert.ok(fs.existsSync(skillsDir));
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'skills')));
|
||||
});
|
||||
|
||||
test('creates one skill directory per command with SKILL.md', () => {
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const skillDir = path.join(skillsDir, 'gsd-new-project');
|
||||
assert.ok(fs.existsSync(skillDir), 'skill dir should exist');
|
||||
assert.ok(fs.existsSync(path.join(skillDir, 'SKILL.md')), 'SKILL.md should exist');
|
||||
});
|
||||
|
||||
test('handles subdirectory commands with prefixed names', () => {
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
const subSkillDir = path.join(skillsDir, 'gsd-subdir-sub-command');
|
||||
assert.ok(fs.existsSync(subSkillDir), 'subdirectory skill dir should exist');
|
||||
});
|
||||
// NOTE: 'handles subdirectory commands with prefixed names' (gsd-subdir-sub-command) is
|
||||
// DELETED. stageSkillsForRuntimeAsSkills processes only flat .md files in commands/gsd/;
|
||||
// the real commands/gsd/ directory contains no subdirectories. This test had no production
|
||||
// code path and cannot be expressed through the installRuntimeArtifacts seam.
|
||||
|
||||
test('SKILL.md has minimal frontmatter (name + description only)', () => {
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.equal(fm.name, 'gsd-new-project', content);
|
||||
assert.equal(fm.description, 'Initialize a new project', content);
|
||||
assert.ok(fm.description, 'description field is present');
|
||||
assert.ok(!('allowed-tools' in fm), 'no allowed-tools field');
|
||||
});
|
||||
|
||||
test('SKILL.md body has paths converted for local install', () => {
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-new-project', 'SKILL.md'), 'utf8');
|
||||
// gsd: → gsd- conversion
|
||||
assert.ok(!content.includes('gsd:'), content);
|
||||
});
|
||||
|
||||
test('removes old gsd-* skill dirs before reinstalling', () => {
|
||||
// Create a stale skill dir
|
||||
const staleDir = path.join(skillsDir, 'gsd-old-skill');
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
// Stale GSD-managed dir must be pruned
|
||||
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '---\nname: old\n---\n');
|
||||
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
|
||||
assert.ok(!fs.existsSync(staleDir), 'stale skill dir should be removed');
|
||||
});
|
||||
|
||||
test('does not remove non-gsd skill dirs', () => {
|
||||
// Create a non-GSD skill dir
|
||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||
// Non-GSD dir should survive
|
||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
|
||||
copyCommandsAsAntigravitySkills(srcDir, skillsDir, 'gsd', false);
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-new-project')), 'real skill dir written');
|
||||
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
|
||||
});
|
||||
|
||||
test('does not remove non-gsd skill dirs', () => {
|
||||
// Create a non-GSD skill dir before install
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
|
||||
installRuntimeArtifacts('antigravity', configDir, 'local', resolvedProfileFull);
|
||||
|
||||
assert.ok(fs.existsSync(otherDir), 'non-GSD skill dir should be preserved');
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ const { execFileSync } = require('child_process');
|
||||
|
||||
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
||||
const { install, copyCommandsAsClaudeSkills } = require(INSTALL_SRC);
|
||||
const { install } = require(INSTALL_SRC);
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
||||
|
||||
@@ -170,21 +170,24 @@ describe('#1924: dev-preferences.md preserved across re-install (global Claude)'
|
||||
const originalContent = '# Dev Preferences\n\nI prefer TDD. I like short functions.\n';
|
||||
fs.writeFileSync(devPrefsPath, originalContent);
|
||||
|
||||
// Re-run installer (simulating gsd-update)
|
||||
// Bug: this triggers legacy cleanup that rmSync's commands/gsd/ entirely,
|
||||
// deleting dev-preferences.md
|
||||
// Re-run installer (simulating gsd-update).
|
||||
// In the layout-driven path (B2), legacy commands/gsd/ is removed and
|
||||
// dev-preferences.md is migrated to skills/gsd-dev-preferences/SKILL.md (#2973).
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// Content is migrated to the new canonical skills location (#2973).
|
||||
// The old commands/gsd/ path is cleaned up; the skill file carries the content.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefsPath),
|
||||
'dev-preferences.md must survive re-install — gsd-update legacy cleanup must not delete user-generated files'
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md — gsd-update legacy cleanup must not silently drop user-generated content'
|
||||
);
|
||||
|
||||
const afterContent = fs.readFileSync(devPrefsPath, 'utf8');
|
||||
const afterContent = fs.readFileSync(devPrefSkillPath, 'utf8');
|
||||
assert.strictEqual(
|
||||
afterContent,
|
||||
originalContent,
|
||||
'dev-preferences.md content must be identical after re-install'
|
||||
'migrated dev-preferences content must be identical to the original'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -199,19 +202,22 @@ describe('#1924: dev-preferences.md preserved across re-install (global Claude)'
|
||||
fs.writeFileSync(legacyFile, '---\nname: gsd:next\n---\n\nLegacy content.');
|
||||
|
||||
// But dev-preferences.md is also there (user-generated)
|
||||
const devPrefsContent = '# Dev Preferences\n\nMy preferences.\n';
|
||||
const devPrefsPath = path.join(commandsGsdDir, 'dev-preferences.md');
|
||||
fs.writeFileSync(devPrefsPath, '# Dev Preferences\n\nMy preferences.\n');
|
||||
fs.writeFileSync(devPrefsPath, devPrefsContent);
|
||||
|
||||
// Re-install
|
||||
runInstaller(tmpDir);
|
||||
|
||||
// dev-preferences.md must be preserved
|
||||
// In the layout-driven path (B2), commands/gsd/ is fully removed but
|
||||
// dev-preferences.md content is migrated to the new canonical skill location.
|
||||
const devPrefSkillPath = path.join(tmpDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefsPath),
|
||||
'dev-preferences.md must be preserved while legacy commands/gsd/ is cleaned up'
|
||||
fs.existsSync(devPrefSkillPath),
|
||||
'dev-preferences.md content must be migrated to skills/gsd-dev-preferences/SKILL.md'
|
||||
);
|
||||
|
||||
// The legacy GSD command (next.md) is NOT user-generated, should be removed
|
||||
// The legacy GSD command (next.md) is NOT user-generated, must be removed
|
||||
// (it would exist only as a skill now in skills/gsd-next/SKILL.md)
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyFile),
|
||||
|
||||
@@ -34,9 +34,18 @@ const path = require('node:path');
|
||||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { convertClaudeCommandToClaudeSkill, copyCommandsAsClaudeSkills, skillFrontmatterName } =
|
||||
const { convertClaudeCommandToClaudeSkill, installRuntimeArtifacts, uninstallRuntimeArtifacts, skillFrontmatterName } =
|
||||
require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||
|
||||
// Full resolved profile — installs all available skills from the source dir
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
const WORKFLOWS_DIR = path.join(ROOT, 'get-shit-done', 'workflows');
|
||||
const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
@@ -150,8 +159,15 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
test('generated autocomplete skill surface uses hyphen names without underscores', (t) => {
|
||||
const tmp = createTempDir('gsd-autocomplete-surface-');
|
||||
t.after(() => cleanup(tmp));
|
||||
const skillsDir = path.join(tmp, 'skills');
|
||||
copyCommandsAsClaudeSkills(COMMANDS_DIR, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Use the real COMMANDS_DIR as the source via .gsd-source marker.
|
||||
// installRuntimeArtifacts('claude', configDir, 'global') writes to
|
||||
// configDir/skills/gsd-*/SKILL.md using the same converter as the shim did.
|
||||
const configDir = path.join(tmp, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
|
||||
// Don't filter the directory listing by `startsWith('gsd-')` — that
|
||||
// would silently hide exactly the kind of drift this test exists to
|
||||
|
||||
@@ -153,58 +153,89 @@ describe('Bug #2973: installer migrates existing legacy dev-preferences.md to sk
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #3003 CR follow-up: copyCommandsAsClaudeSkills preserves user-owned skills ──
|
||||
// ─── #3003 CR follow-up: installRuntimeArtifacts preserves user-owned skills ──
|
||||
//
|
||||
// Production install() calls installRuntimeArtifacts() without a prior
|
||||
// uninstallRuntimeArtifacts(). This means _copyStaged overlays new skills
|
||||
// on top of the existing skills/ directory — it does NOT wipe first.
|
||||
// As a result, user-owned gsd-dev-preferences/SKILL.md is preserved across
|
||||
// a plain install because _copyStaged only cpSync's newly staged skill dirs.
|
||||
//
|
||||
// NOTE: If callers run uninstallRuntimeArtifacts() before installRuntimeArtifacts()
|
||||
// (e.g. full reinstall), gsd-dev-preferences IS wiped by uninstall and NOT
|
||||
// restored by install (#3664 production gap — tracked separately).
|
||||
|
||||
describe('Bug #2973 (#3003 CR): copyCommandsAsClaudeSkills snapshots gsd-dev-preferences across the wipe', () => {
|
||||
test('user-customized skills/gsd-dev-preferences/SKILL.md survives a wipe-and-replace install', () => {
|
||||
describe('Bug #2973 (#3003 CR): installRuntimeArtifacts preserves user-owned gsd-dev-preferences across install', () => {
|
||||
test('user-customized skills/gsd-dev-preferences/SKILL.md survives a plain install (no pre-uninstall)', () => {
|
||||
// Production install() does NOT call uninstallRuntimeArtifacts() first.
|
||||
// installRuntimeArtifacts → _copyStaged overlays only staged skill dirs;
|
||||
// gsd-dev-preferences (not in source) is left untouched.
|
||||
const inst = require(INSTALL);
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-'));
|
||||
try {
|
||||
const skillsDir = path.join(tmp, 'skills');
|
||||
const configDir = path.join(tmp, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
// Set up a minimal source dir (plan-phase only; no dev-preferences).
|
||||
const srcDir = path.join(tmp, 'src-commands');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan\n---\n\nPlan body.\n');
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const userSkillDir = path.join(skillsDir, 'gsd-dev-preferences');
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
const userContent = '# my customized dev preferences\n\nstack: rust\n';
|
||||
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), userContent);
|
||||
|
||||
// Source dir mimicking commands/gsd/ — does NOT contain dev-preferences
|
||||
// because dev-preferences is user-generated, not shipped.
|
||||
const srcDir = path.join(tmp, 'src-commands');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '# plan-phase\n');
|
||||
|
||||
// Without the CR fix, the wipe loop deletes gsd-dev-preferences/
|
||||
// and the user's content is lost (no source to restore from).
|
||||
inst.copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
// Plain install (matching production install() call site).
|
||||
const manifest = loadSkillsManifest();
|
||||
const resolvedProfile = resolveProfile({ modes: [], manifest });
|
||||
inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile);
|
||||
|
||||
const skillFile = path.join(userSkillDir, 'SKILL.md');
|
||||
assert.equal(fs.existsSync(skillFile), true,
|
||||
'gsd-dev-preferences/SKILL.md must survive the wipe (#3003 CR)');
|
||||
'gsd-dev-preferences/SKILL.md must survive a plain install (#3003 CR)');
|
||||
assert.equal(fs.readFileSync(skillFile, 'utf-8'), userContent,
|
||||
'user content must be byte-identical after the wipe-restore cycle');
|
||||
'user content must be byte-identical after the install');
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('non-user-owned gsd-* skills are still wiped and recreated from source', () => {
|
||||
// The existing wipe behavior must still work for skills the package
|
||||
// owns. Otherwise the preservation list could grow stale by accident.
|
||||
test('non-user-owned gsd-* skills are wiped and recreated via uninstall+install cycle', () => {
|
||||
// Stale artifacts (e.g. STALE-MARKER.txt left from a previous version)
|
||||
// are removed when the caller runs uninstallRuntimeArtifacts() before
|
||||
// installRuntimeArtifacts() — the full uninstall+reinstall cycle.
|
||||
// uninstallRuntimeArtifacts removes all gsd-* entries; installRuntimeArtifacts
|
||||
// then writes fresh ones from source.
|
||||
const inst = require(INSTALL);
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2973-wipe-shipped-'));
|
||||
try {
|
||||
const skillsDir = path.join(tmp, 'skills');
|
||||
const configDir = path.join(tmp, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
const srcDir = path.join(tmp, 'src-commands');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '---\nname: gsd:plan-phase\ndescription: Plan fresh\n---\n\nFresh body.\n');
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const staleSkillDir = path.join(skillsDir, 'gsd-plan-phase');
|
||||
fs.mkdirSync(staleSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleSkillDir, 'STALE-MARKER.txt'), 'wipe me');
|
||||
|
||||
const srcDir = path.join(tmp, 'src-commands');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan-phase.md'), '# plan-phase fresh\n');
|
||||
const manifest = loadSkillsManifest();
|
||||
const resolvedProfile = resolveProfile({ modes: [], manifest });
|
||||
|
||||
inst.copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
// Full uninstall+install cycle (e.g. --reinstall flow)
|
||||
inst.uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||
inst.installRuntimeArtifacts('claude', configDir, 'global', resolvedProfile);
|
||||
|
||||
assert.equal(fs.existsSync(path.join(staleSkillDir, 'STALE-MARKER.txt')), false,
|
||||
'stale shipped-skill content must be wiped (preservation is opt-in by name)');
|
||||
'stale shipped-skill content must be wiped by uninstall (preservation is opt-in by name)');
|
||||
assert.equal(fs.existsSync(path.join(staleSkillDir, 'SKILL.md')), true,
|
||||
'fresh SKILL.md from source must be installed after wipe');
|
||||
} finally {
|
||||
|
||||
@@ -19,12 +19,41 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
copyCommandsAsClaudeSkills,
|
||||
writeManifest,
|
||||
install,
|
||||
} = require('../bin/install.js');
|
||||
installRuntimeArtifacts,
|
||||
uninstallRuntimeArtifacts,
|
||||
} = require(path.join(ROOT, 'bin', 'install.js'));
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require(path.join(ROOT, 'get-shit-done', 'bin', 'lib', 'install-profiles.cjs'));
|
||||
|
||||
// Shared resolved profile (full — installs all skills from srcDir)
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
/**
|
||||
* Set up a configDir backed by a custom srcDir via .gsd-source marker.
|
||||
* Returns { configDir, srcDir } both under tmpDir.
|
||||
*/
|
||||
function setupConfigDir(tmpDir, commandFiles) {
|
||||
const srcDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
for (const [name, content] of Object.entries(commandFiles)) {
|
||||
fs.writeFileSync(path.join(srcDir, name), content);
|
||||
}
|
||||
const configDir = path.join(tmpDir, 'config');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// .gsd-source marker tells findInstallSourceRoot to use our custom srcDir
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir + '\n');
|
||||
return { configDir, srcDir };
|
||||
}
|
||||
|
||||
// ─── convertClaudeCommandToClaudeSkill ──────────────────────────────────────
|
||||
|
||||
@@ -162,9 +191,9 @@ describe('convertClaudeCommandToClaudeSkill', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsClaudeSkills ─────────────────────────────────────────────
|
||||
// ─── installRuntimeArtifacts (claude global) — skill layout ─────────────────
|
||||
|
||||
describe('copyCommandsAsClaudeSkills', () => {
|
||||
describe('installRuntimeArtifacts (claude global) — skill layout', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -176,22 +205,14 @@ describe('copyCommandsAsClaudeSkills', () => {
|
||||
});
|
||||
|
||||
test('creates correct directory structure skills/gsd-xxx/SKILL.md', () => {
|
||||
// Create source commands
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'health.md'),
|
||||
'---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.'
|
||||
);
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'next.md': '---\nname: gsd:next\ndescription: Advance\nallowed-tools:\n - Read\n---\n\nBody.',
|
||||
'health.md': '---\nname: gsd:health\ndescription: Check health\n---\n\nHealth body.',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Verify directory structure
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
'skills/gsd-next/SKILL.md exists'
|
||||
@@ -202,28 +223,27 @@ describe('copyCommandsAsClaudeSkills', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('cleans up old skills before installing new ones', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
test('cleans up old skills before installing new ones (uninstall+install cycle)', () => {
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Create a stale skill that should be removed
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Create a stale skill that should be removed by the uninstall step
|
||||
const staleDir = path.join(skillsDir, 'gsd-old-command');
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
// Production sequence: uninstall wipes gsd-* entries, install writes fresh ones
|
||||
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Stale skill removed
|
||||
// Stale skill removed by uninstall
|
||||
assert.ok(
|
||||
!fs.existsSync(staleDir),
|
||||
'stale skill directory removed'
|
||||
);
|
||||
// New skill created
|
||||
// New skill created by install
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
'new skill created'
|
||||
@@ -231,64 +251,63 @@ describe('copyCommandsAsClaudeSkills', () => {
|
||||
});
|
||||
|
||||
test('does not remove non-GSD skills', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||
});
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Create a non-GSD skill
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Create a non-GSD skill before install
|
||||
const otherDir = path.join(skillsDir, 'my-custom-skill');
|
||||
fs.mkdirSync(otherDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(otherDir, 'SKILL.md'), 'custom content');
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
// Install (no pre-uninstall: uninstall only removes gsd-* prefixed entries)
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Non-GSD skill preserved
|
||||
assert.ok(
|
||||
fs.existsSync(otherDir),
|
||||
'non-GSD skill preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles recursive subdirectories', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
const subDir = path.join(srcDir, 'wired');
|
||||
fs.mkdirSync(subDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(subDir, 'ready.md'),
|
||||
'---\nname: gsd-wired:ready\ndescription: Show ready tasks\n---\n\nBody.'
|
||||
'non-GSD skill preserved after install'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Also survives uninstall (uninstall only removes gsd-* prefixed entries)
|
||||
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-wired-ready', 'SKILL.md')),
|
||||
'nested command creates gsd-wired-ready/SKILL.md'
|
||||
fs.existsSync(otherDir),
|
||||
'non-GSD skill preserved after uninstall'
|
||||
);
|
||||
});
|
||||
|
||||
test('no-ops when source directory does not exist', () => {
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// NOTE: Recursive subdirectory support was removed when the shim was replaced.
|
||||
// stageSkillsForRuntimeAsSkills only processes top-level .md files.
|
||||
// No subdirectories exist under commands/gsd/ in production.
|
||||
// The subdir-recursion test has been deleted (option a per #3664 brief).
|
||||
|
||||
test('no-ops on install when source directory has no .md files', () => {
|
||||
// Create an empty (but existing) commands/gsd dir with no .md files.
|
||||
// stageSkillsForRuntimeAsSkills loops over entries, finds none, and stages
|
||||
// an empty dir — _copyStaged then copies nothing into skills/.
|
||||
const emptySrc = path.join(tmpDir, 'empty-commands', 'gsd');
|
||||
fs.mkdirSync(emptySrc, { recursive: true });
|
||||
|
||||
const configDir = path.join(tmpDir, 'config-empty');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), emptySrc + '\n');
|
||||
|
||||
// Should not throw
|
||||
copyCommandsAsClaudeSkills(
|
||||
path.join(tmpDir, 'nonexistent'),
|
||||
skillsDir,
|
||||
'gsd',
|
||||
'$HOME/.claude/',
|
||||
'claude',
|
||||
true
|
||||
);
|
||||
assert.ok(!fs.existsSync(skillsDir), 'skills dir not created when src missing');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// If skills dir was created it must contain no gsd-* entries
|
||||
if (fs.existsSync(skillsDir)) {
|
||||
const gsdEntries = fs.readdirSync(skillsDir).filter(n => n.startsWith('gsd-'));
|
||||
assert.strictEqual(gsdEntries.length, 0, 'no gsd-* skills created when src has no .md files');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Path replacement in Claude skills (#1653) ────────────────────────────────
|
||||
|
||||
describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
||||
describe('installRuntimeArtifacts path replacement in Claude global skills (#1653)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -299,12 +318,13 @@ describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('replaces ~/.claude/ paths with pathPrefix on local install', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'manager.md'),
|
||||
[
|
||||
test('replaces ~/.claude/ and $HOME/.claude/ paths with absolute configDir prefix on global install', () => {
|
||||
// Global install: configDir IS the runtime config directory.
|
||||
// computePathPrefix(isGlobal=true) → resolvedTarget + '/'.
|
||||
// applyRuntimeContentRewritesInPlace rewrites ~/.claude/ and $HOME/.claude/
|
||||
// to the absolute configDir path so skills work from any machine.
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'manager.md': [
|
||||
'---',
|
||||
'name: gsd:manager',
|
||||
'description: Manager command',
|
||||
@@ -312,52 +332,66 @@ describe('copyCommandsAsClaudeSkills path replacement (#1653)', () => {
|
||||
'',
|
||||
'<execution_context>',
|
||||
'@~/.claude/get-shit-done/workflows/manager.md',
|
||||
'@~/.claude/get-shit-done/references/ui-brand.md',
|
||||
'@$HOME/.claude/get-shit-done/references/ui-brand.md',
|
||||
'</execution_context>',
|
||||
].join('\n')
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(
|
||||
path.join(configDir, 'skills', 'gsd-manager', 'SKILL.md'), 'utf8'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
const localPrefix = '/Users/test/myproject/.claude/';
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-manager', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('~/.claude/'), 'no hardcoded ~/.claude/ paths remain');
|
||||
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/manager.md'), 'path rewritten to local prefix');
|
||||
assert.ok(content.includes(localPrefix + 'get-shit-done/references/ui-brand.md'), 'reference path rewritten');
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'debug.md'),
|
||||
'---\nname: gsd:debug\ndescription: Debug\n---\n\n@$HOME/.claude/get-shit-done/workflows/debug.md'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
const localPrefix = '/tmp/project/.claude/';
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', localPrefix, 'claude', false);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-debug', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
|
||||
assert.ok(content.includes(localPrefix + 'get-shit-done/workflows/debug.md'), 'path rewritten');
|
||||
// Paths are rewritten to the absolute configDir prefix
|
||||
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||
assert.ok(
|
||||
content.includes(expectedPrefix + 'get-shit-done/workflows/manager.md'),
|
||||
'tilde path rewritten to absolute configDir prefix'
|
||||
);
|
||||
assert.ok(
|
||||
content.includes(expectedPrefix + 'get-shit-done/references/ui-brand.md'),
|
||||
'HOME path rewritten to absolute configDir prefix'
|
||||
);
|
||||
});
|
||||
|
||||
test('global install preserves $HOME/.claude/ when pathPrefix matches', () => {
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Next\n---\n\n@~/.claude/get-shit-done/workflows/next.md'
|
||||
test('replaces $HOME/.claude/ paths with absolute configDir prefix on global install', () => {
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'debug.md': '---\nname: gsd:debug\ndescription: Debug\n---\n\n@$HOME/.claude/get-shit-done/workflows/debug.md',
|
||||
});
|
||||
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(
|
||||
path.join(configDir, 'skills', 'gsd-debug', 'SKILL.md'), 'utf8'
|
||||
);
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'no $HOME/.claude/ paths remain');
|
||||
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||
assert.ok(
|
||||
content.includes(expectedPrefix + 'get-shit-done/workflows/debug.md'),
|
||||
'path rewritten to absolute configDir prefix'
|
||||
);
|
||||
});
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
test('global install rewrites ~/.claude/ paths to absolute configDir form', () => {
|
||||
// For global installs, computePathPrefix returns the absolute configDir path.
|
||||
// Both ~/.claude/ and $HOME/.claude/ are normalized to the same absolute prefix.
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'next.md': '---\nname: gsd:next\ndescription: Next\n---\n\n@~/.claude/get-shit-done/workflows/next.md',
|
||||
});
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('$HOME/.claude/get-shit-done/workflows/next.md'), 'global paths use $HOME form');
|
||||
assert.ok(!content.includes('~/.claude/'), '~/ form replaced with $HOME/ form');
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(
|
||||
path.join(configDir, 'skills', 'gsd-next', 'SKILL.md'), 'utf8'
|
||||
);
|
||||
const expectedPrefix = path.resolve(configDir).replace(/\\/g, '/') + '/';
|
||||
assert.ok(
|
||||
content.includes(expectedPrefix + 'get-shit-done/workflows/next.md'),
|
||||
'global tilde path rewritten to absolute configDir prefix'
|
||||
);
|
||||
assert.ok(!content.includes('~/.claude/'), 'tilde form is replaced');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,31 +409,22 @@ describe('Legacy commands/gsd/ cleanup', () => {
|
||||
});
|
||||
|
||||
test('install removes legacy commands/gsd/ directory when present', () => {
|
||||
// Create a mock legacy commands/gsd/ directory
|
||||
const legacyDir = path.join(tmpDir, 'commands', 'gsd');
|
||||
const { configDir } = setupConfigDir(tmpDir, {
|
||||
'next.md': '---\nname: gsd:next\ndescription: Advance\n---\n\nBody.',
|
||||
});
|
||||
|
||||
// Create a mock legacy commands/gsd/ directory inside configDir
|
||||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'next.md'), 'legacy content');
|
||||
|
||||
// Create source commands for the installer to read
|
||||
const srcDir = path.join(tmpDir, 'src');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(srcDir, 'next.md'),
|
||||
'---\nname: gsd:next\ndescription: Advance\n---\n\nBody.'
|
||||
);
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'skills');
|
||||
// Install skills
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.claude/', 'claude', true);
|
||||
|
||||
// Simulate the legacy cleanup that install() does after copyCommandsAsClaudeSkills
|
||||
if (fs.existsSync(legacyDir)) {
|
||||
fs.rmSync(legacyDir, { recursive: true });
|
||||
}
|
||||
// installRuntimeArtifacts calls _runLegacyInstallMigrations which removes
|
||||
// commands/gsd/ for claude runtime (it's the legacy location for global).
|
||||
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
assert.ok(!fs.existsSync(legacyDir), 'legacy commands/gsd/ removed');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(skillsDir, 'gsd-next', 'SKILL.md')),
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-next', 'SKILL.md')),
|
||||
'new skill installed'
|
||||
);
|
||||
});
|
||||
@@ -461,7 +486,7 @@ describe('Claude skills migration exports', () => {
|
||||
assert.strictEqual(typeof convertClaudeCommandToClaudeSkill, 'function');
|
||||
});
|
||||
|
||||
test('copyCommandsAsClaudeSkills is exported', () => {
|
||||
assert.strictEqual(typeof copyCommandsAsClaudeSkills, 'function');
|
||||
test('installRuntimeArtifacts is exported', () => {
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,18 @@ const {
|
||||
convertClaudeToCodebuddyMarkdown,
|
||||
convertClaudeCommandToCodebuddySkill,
|
||||
convertClaudeAgentToCodebuddyAgent,
|
||||
copyCommandsAsCodebuddySkills,
|
||||
install,
|
||||
uninstall,
|
||||
writeManifest,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
describe('CodeBuddy runtime directory mapping', () => {
|
||||
test('maps CodeBuddy to .codebuddy for local installs', () => {
|
||||
assert.strictEqual(getDirName('codebuddy'), '.codebuddy');
|
||||
@@ -133,24 +139,24 @@ Read CLAUDE.md before acting.
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyCommandsAsCodebuddySkills', () => {
|
||||
let tmpDir;
|
||||
describe('installRuntimeArtifacts (codebuddy integration)', () => {
|
||||
// Pivoted from copyCommandsAsCodebuddySkills(srcDir, skillsDir, 'gsd', '$HOME/.codebuddy/', 'codebuddy')
|
||||
// shim to installRuntimeArtifacts('codebuddy', configDir, 'local', resolvedProfileFull).
|
||||
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||
let configDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempDir('gsd-codebuddy-copy-');
|
||||
configDir = createTempDir('gsd-codebuddy-copy-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
test('creates one skill directory per GSD command', () => {
|
||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const skillsDir = path.join(tmpDir, '.codebuddy', 'skills');
|
||||
installRuntimeArtifacts('codebuddy', configDir, 'local', resolvedProfileFull);
|
||||
|
||||
copyCommandsAsCodebuddySkills(srcDir, skillsDir, 'gsd', '$HOME/.codebuddy/', 'codebuddy');
|
||||
|
||||
const generated = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
const generated = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(generated), generated);
|
||||
|
||||
const content = fs.readFileSync(generated, 'utf8');
|
||||
|
||||
@@ -24,7 +24,7 @@ const assert = require('node:assert/strict');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const { parseFrontmatter } = require('./helpers.cjs');
|
||||
const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
getDirName,
|
||||
@@ -35,15 +35,21 @@ const {
|
||||
convertClaudeToCopilotContent,
|
||||
convertClaudeCommandToCopilotSkill,
|
||||
convertClaudeAgentToCopilotAgent,
|
||||
copyCommandsAsCopilotSkills,
|
||||
GSD_COPILOT_INSTRUCTIONS_MARKER,
|
||||
GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER,
|
||||
mergeCopilotInstructions,
|
||||
stripGsdFromCopilotInstructions,
|
||||
writeManifest,
|
||||
reportLocalPatches,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
// ─── Profile resolution for installRuntimeArtifacts tests ────────────────────
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
|
||||
const { loadSkillsManifest, resolveProfile } = require(path.join(_gsdLibDir, 'install-profiles.cjs'));
|
||||
const _manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest: _manifest });
|
||||
|
||||
// ─── getDirName ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('getDirName (Copilot)', () => {
|
||||
@@ -610,41 +616,46 @@ Check ~/.claude/settings and run gsd:health.`;
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsCopilotSkills (integration) ─────────────────────────────────
|
||||
// ─── installRuntimeArtifacts (copilot integration) ─────────────────────────────
|
||||
|
||||
describe('copyCommandsAsCopilotSkills', () => {
|
||||
describe('installRuntimeArtifacts (copilot integration)', () => {
|
||||
// Pivoted from copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd') shim to
|
||||
// installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull).
|
||||
// Output layout: <configDir>/skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-').
|
||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
let tempDir;
|
||||
let configDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-copilot-skills-'));
|
||||
configDir = createTempDir('gsd-copilot-skills-');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
cleanup(configDir);
|
||||
});
|
||||
|
||||
test('creates skill folders from source commands', () => {
|
||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Check specific folders exist
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'gsd-health folder exists');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-help')), 'gsd-help folder exists');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-progress')), 'gsd-progress folder exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health')), 'gsd-health folder exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health', 'SKILL.md')), 'gsd-health/SKILL.md exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help')), 'gsd-help folder exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-progress')), 'gsd-progress folder exists');
|
||||
|
||||
// Count gsd-* directories — should match number of source command files
|
||||
const dirs = fs.readdirSync(tempDir, { withFileTypes: true })
|
||||
const dirs = fs.readdirSync(skillsDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
const expectedSkillCount = fs.readdirSync(path.join(__dirname, '..', 'commands', 'gsd'))
|
||||
const expectedSkillCount = fs.readdirSync(srcDir)
|
||||
.filter(f => f.endsWith('.md')).length;
|
||||
assert.strictEqual(dirs.length, expectedSkillCount, `expected ${expectedSkillCount} skill folders, got ${dirs.length}`);
|
||||
});
|
||||
|
||||
test('skill content has Copilot frontmatter format', () => {
|
||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-health', 'SKILL.md'), 'utf8');
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const skillContent = fs.readFileSync(path.join(skillsDir, 'gsd-health', 'SKILL.md'), 'utf8');
|
||||
// Frontmatter format checks
|
||||
assert.ok(skillContent.startsWith('---\nname: gsd-health\n'), 'starts with name: gsd-health');
|
||||
assert.ok(skillContent.includes('allowed-tools: Read, Bash, Write, AskUserQuestion'),
|
||||
@@ -660,13 +671,14 @@ describe('copyCommandsAsCopilotSkills', () => {
|
||||
const srcFile = path.join(srcDir, 'autonomous.md');
|
||||
assert.ok(fs.existsSync(srcFile), 'commands/gsd/autonomous.md must exist as source');
|
||||
|
||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
// Skill folder and file created
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous')), 'gsd-autonomous folder exists');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-autonomous')), 'gsd-autonomous folder exists');
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-autonomous', 'SKILL.md')), 'gsd-autonomous/SKILL.md exists');
|
||||
|
||||
const skillContent = fs.readFileSync(path.join(tempDir, 'gsd-autonomous', 'SKILL.md'), 'utf8');
|
||||
const skillContent = fs.readFileSync(path.join(skillsDir, 'gsd-autonomous', 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(skillContent);
|
||||
|
||||
// Frontmatter: name converted from gsd:autonomous to gsd-autonomous
|
||||
@@ -704,16 +716,26 @@ describe('copyCommandsAsCopilotSkills', () => {
|
||||
});
|
||||
|
||||
test('cleans up old skill directories on re-run', () => {
|
||||
// Create a fake old directory
|
||||
fs.mkdirSync(path.join(tempDir, 'gsd-fake-old'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tempDir, 'gsd-fake-old', 'SKILL.md'), 'old');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir exists before');
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
fs.mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
// Run copy — should clean up old dirs
|
||||
copyCommandsAsCopilotSkills(srcDir, tempDir, 'gsd');
|
||||
// Stale GSD-managed dir must be pruned
|
||||
const staleDir = path.join(skillsDir, 'gsd-old-stale-skill');
|
||||
fs.mkdirSync(staleDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleDir, 'SKILL.md'), 'stale content');
|
||||
|
||||
assert.ok(!fs.existsSync(path.join(tempDir, 'gsd-fake-old')), 'fake old dir removed');
|
||||
assert.ok(fs.existsSync(path.join(tempDir, 'gsd-health')), 'real dirs still exist');
|
||||
// Non-GSD dir should survive (installRuntimeArtifacts never prunes non-gsd-*)
|
||||
fs.mkdirSync(path.join(skillsDir, 'user-custom'), { recursive: true });
|
||||
fs.writeFileSync(path.join(skillsDir, 'user-custom', 'SKILL.md'), 'user content');
|
||||
|
||||
installRuntimeArtifacts('copilot', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Real skills are present after install
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-health')), 'real dirs still exist');
|
||||
// Stale GSD-prefixed dir is removed by pre-prune
|
||||
assert.ok(!fs.existsSync(staleDir), 'stale gsd-* dir removed by pre-prune');
|
||||
// Non-GSD dir is preserved
|
||||
assert.ok(fs.existsSync(path.join(skillsDir, 'user-custom')), 'non-GSD dir preserved');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -21,11 +21,19 @@ const fs = require('fs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
copyCommandsAsClaudeSkills,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
const { parseFrontmatter } = require('./helpers.cjs');
|
||||
const pkg = require('../package.json');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
|
||||
|
||||
// ─── convertClaudeCommandToClaudeSkill (used by Hermes via copyCommandsAsClaudeSkills) ──
|
||||
|
||||
describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
||||
@@ -120,9 +128,9 @@ describe('Hermes Agent: convertClaudeCommandToClaudeSkill', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsClaudeSkills (used for Hermes skills install) ─────────────
|
||||
// ─── installRuntimeArtifacts (used for Hermes skills install) ────────────────
|
||||
|
||||
describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
describe('Hermes Agent: installRuntimeArtifacts', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -135,7 +143,7 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('creates skills/gsd-xxx/SKILL.md directory structure', () => {
|
||||
test('creates skills/gsd/quick/SKILL.md directory structure (Hermes bare-stem layout)', () => {
|
||||
// Create source command files
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
@@ -151,17 +159,22 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
'<objective>Quick task body</objective>',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'hermes', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Redirect findInstallSourceRoot to the test's custom srcDir
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
// Verify SKILL.md was created
|
||||
const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md (ADR-3660)
|
||||
const skillPath = path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'skills/gsd/quick/SKILL.md exists');
|
||||
|
||||
// Verify content (structural — parse frontmatter, don't substring-grep)
|
||||
// Hermes bare-stem: prefix='', so skillName passed to converter = 'quick' (not 'gsd-quick')
|
||||
const content = fs.readFileSync(skillPath, 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.name, 'gsd-quick', 'frontmatter name uses hyphen form (#2808)');
|
||||
assert.strictEqual(fm.name, 'quick', 'frontmatter name is bare stem for Hermes nested layout');
|
||||
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)})`);
|
||||
@@ -170,7 +183,7 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||
});
|
||||
|
||||
test('replaces ~/.claude/ paths with pathPrefix', () => {
|
||||
test('replaces ~/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
||||
@@ -182,15 +195,19 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
'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 configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
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');
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'next', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path removed');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path not present');
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
||||
test('replaces $HOME/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
||||
@@ -202,12 +219,16 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
'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 configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
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');
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'plan', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path removed');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path not present');
|
||||
});
|
||||
|
||||
test('removes stale gsd- skills before installing new ones', () => {
|
||||
@@ -222,15 +243,21 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
'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');
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
||||
// Pre-create a stale flat skill (skills/gsd-old-skill) — legacy Hermes layout
|
||||
const staleFlatSkillDir = path.join(configDir, 'skills', 'gsd-old-skill');
|
||||
fs.mkdirSync(staleFlatSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleFlatSkillDir, 'SKILL.md'), 'old');
|
||||
|
||||
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');
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// _runLegacyInstallMigrations removes skills/gsd-* flat dirs for hermes
|
||||
assert.ok(!fs.existsSync(staleFlatSkillDir), 'stale flat gsd- skill removed');
|
||||
// New Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd', 'quick', 'SKILL.md')), 'new skill installed at skills/gsd/quick/SKILL.md');
|
||||
});
|
||||
|
||||
test('preserves agent field in frontmatter', () => {
|
||||
@@ -250,10 +277,14 @@ describe('Hermes Agent: copyCommandsAsClaudeSkills', () => {
|
||||
'Execute body',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'hermes', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8');
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Hermes layout: skills/gsd/<bare-stem>/SKILL.md
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd', 'execute', 'SKILL.md'), 'utf8');
|
||||
const fm = parseFrontmatter(content);
|
||||
assert.strictEqual(fm.agent, 'gsd-executor', 'agent field preserved');
|
||||
});
|
||||
|
||||
508
tests/install-hermes-regressions.test.cjs
Normal file
508
tests/install-hermes-regressions.test.cjs
Normal file
@@ -0,0 +1,508 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Regression tests for two confirmed defects from commit 6c676dbc (#3664)
|
||||
* and three legacy-migration gaps caught in cc2ecb97 (#3664 Phase 2):
|
||||
*
|
||||
* Defect #1 — Hermes upgrade leaves stale skills/gsd/gsd-<stem>/ dirs
|
||||
* _runLegacyInstallMigrations only removed pre-#2841 flat skills/gsd-slash-star
|
||||
* dirs; it did not remove the intermediate skills/gsd/gsd-slash-star layout that
|
||||
* existed between #2841 and #3664.
|
||||
*
|
||||
* Defect #2 — `--hermes --profile=core` falls through to wrong path
|
||||
* The dispatcher's minimal-mode block had no `isHermes && _isCoreProfileAlias`
|
||||
* branch, so Hermes + core fell to the terminal `else` (Claude local path),
|
||||
* writing commands/gsd/<stem>.md instead of skills/gsd/.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawnSync } = require('child_process');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
// Load install exports via GSD_TEST_MODE to skip CLI main()
|
||||
const savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let installExports;
|
||||
try {
|
||||
installExports = require('../bin/install.js');
|
||||
} finally {
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
}
|
||||
|
||||
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
|
||||
|
||||
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test A1 — Defect #1 regression: stale skills/gsd/gsd-*/ dirs are removed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Defect #1 regression: _runLegacyInstallMigrations removes skills/gsd/gsd-*/ layout', () => {
|
||||
test('installRuntimeArtifacts removes intermediate skills/gsd/gsd-*/ dirs and writes new bare-stem layout', (t) => {
|
||||
const configDir = createTempDir('gsd-hermes-reg1-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof installRuntimeArtifacts,
|
||||
'function',
|
||||
'installRuntimeArtifacts must be exported from bin/install.js',
|
||||
);
|
||||
|
||||
// Pre-create the intermediate Hermes layout (between #2841 and #3664):
|
||||
// skills/gsd/gsd-help/SKILL.md and skills/gsd/gsd-plan/SKILL.md
|
||||
const nestedGsdDir = path.join(configDir, 'skills', 'gsd');
|
||||
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-help'), { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-help', 'SKILL.md'), '# legacy help\n');
|
||||
fs.mkdirSync(path.join(nestedGsdDir, 'gsd-plan'), { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedGsdDir, 'gsd-plan', 'SKILL.md'), '# legacy plan\n');
|
||||
|
||||
// Create a sibling non-gsd dir inside skills/gsd/ that must survive
|
||||
const userContentDir = path.join(nestedGsdDir, 'user-content');
|
||||
fs.mkdirSync(userContentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userContentDir, 'SKILL.md'), '# user content\n');
|
||||
|
||||
// Run the unified install
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// Stale intermediate dirs must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(nestedGsdDir, 'gsd-help')),
|
||||
'skills/gsd/gsd-help/ must be removed by install migration (Defect #1)',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(nestedGsdDir, 'gsd-plan')),
|
||||
'skills/gsd/gsd-plan/ must be removed by install migration (Defect #1)',
|
||||
);
|
||||
|
||||
// New bare-stem layout must exist
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(nestedGsdDir, 'help', 'SKILL.md')),
|
||||
'skills/gsd/help/SKILL.md must exist after install (new bare-stem layout)',
|
||||
);
|
||||
|
||||
// User content in skills/gsd/ must be preserved (user-content has no gsd- prefix)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(userContentDir, 'SKILL.md')),
|
||||
'skills/gsd/user-content/SKILL.md must be preserved (user content)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test Q1 — Qwen variant of Defect #2: --qwen --profile=core uses skills/gsd-*/ path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Qwen Defect #2 regression: --qwen --profile=core writes skills/gsd-*/, not commands/gsd/', () => {
|
||||
test('spawn node bin/install.js --qwen --global --profile=core: skills/gsd-*/ written, no commands/gsd/', (t) => {
|
||||
const root = createTempDir('gsd-qwen-reg2-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
// skills/ must exist and contain at least one gsd-<stem>/ dir with SKILL.md
|
||||
const qwenSkillsDir = path.join(root, 'skills');
|
||||
assert.ok(
|
||||
fs.existsSync(qwenSkillsDir),
|
||||
`skills/ must exist after --qwen --profile=core install (got: ${result.stdout})`,
|
||||
);
|
||||
|
||||
const skillDirs = fs.readdirSync(qwenSkillsDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory() && e.name.startsWith('gsd-'));
|
||||
assert.ok(
|
||||
skillDirs.length >= 1,
|
||||
`skills/ must contain at least one gsd-* skill dir, got: ${
|
||||
fs.readdirSync(qwenSkillsDir, { withFileTypes: true }).map((e) => e.name).join(', ')
|
||||
}`,
|
||||
);
|
||||
|
||||
// Verify at least one gsd-* dir has SKILL.md
|
||||
const hasSkillMd = skillDirs.some((e) =>
|
||||
fs.existsSync(path.join(qwenSkillsDir, e.name, 'SKILL.md')),
|
||||
);
|
||||
assert.ok(hasSkillMd, 'at least one skills/gsd-*/SKILL.md must exist');
|
||||
|
||||
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 causes for Qwen)
|
||||
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||||
if (fs.existsSync(commandsGsd)) {
|
||||
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
|
||||
assert.strictEqual(
|
||||
mdFiles.length,
|
||||
0,
|
||||
`commands/gsd/ must not contain .md files after qwen install (Qwen Defect #2). Found: ${mdFiles.join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test A2 — Defect #2 regression: --hermes --profile=core uses skills/gsd/ path
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Defect #2 regression: --hermes --profile=core writes skills/gsd/, not commands/gsd/', () => {
|
||||
test('spawn node bin/install.js --hermes --global --profile=core: skills/gsd/ written, no commands/gsd/', (t) => {
|
||||
const root = createTempDir('gsd-hermes-reg2-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
// skills/gsd/ must exist and contain at least one <stem>/SKILL.md file
|
||||
const hermesSkillsGsd = path.join(root, 'skills', 'gsd');
|
||||
assert.ok(
|
||||
fs.existsSync(hermesSkillsGsd),
|
||||
`skills/gsd/ must exist after --hermes --profile=core install (got: ${result.stdout})`,
|
||||
);
|
||||
|
||||
const skillDirs = fs.readdirSync(hermesSkillsGsd, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory());
|
||||
assert.ok(
|
||||
skillDirs.length >= 1,
|
||||
`skills/gsd/ must contain at least one skill dir, got: ${skillDirs.map((e) => e.name).join(', ')}`,
|
||||
);
|
||||
|
||||
// Verify at least one skill dir has SKILL.md
|
||||
const hasSkillMd = skillDirs.some((e) =>
|
||||
fs.existsSync(path.join(hermesSkillsGsd, e.name, 'SKILL.md')),
|
||||
);
|
||||
assert.ok(hasSkillMd, 'at least one skills/gsd/<stem>/SKILL.md must exist');
|
||||
|
||||
// commands/gsd/*.md must NOT exist (the regression path that Defect #2 caused)
|
||||
const commandsGsd = path.join(root, 'commands', 'gsd');
|
||||
if (fs.existsSync(commandsGsd)) {
|
||||
const mdFiles = fs.readdirSync(commandsGsd).filter((f) => f.endsWith('.md'));
|
||||
assert.strictEqual(
|
||||
mdFiles.length,
|
||||
0,
|
||||
`commands/gsd/ must not contain .md files after hermes install (Defect #2). Found: ${mdFiles.join(', ')}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test M1 — Hermes minimal-mode migrates dev-preferences (#2973)
|
||||
// Hermes uses nested layout (skills/gsd/<stem>/SKILL.md, prefix=''),
|
||||
// so dev-preferences lands at skills/gsd/dev-preferences/SKILL.md NOT
|
||||
// skills/gsd-dev-preferences/SKILL.md (the flat layout for qwen/claude-global).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('M1: --hermes --global --profile=core migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
|
||||
test('spawn node bin/install.js --hermes --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||
const root = createTempDir('gsd-hermes-m1-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs\n');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--hermes', '--global', '--config-dir', root, '--profile=core'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
// Migration target must exist at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
|
||||
// NOT the flat location skills/gsd-dev-preferences/SKILL.md used by qwen/claude-global.
|
||||
const skillFile = path.join(root, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
`skills/gsd/dev-preferences/SKILL.md must exist after migration (M1: Hermes nested layout, not flat)`,
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my hermes prefs\n',
|
||||
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
|
||||
// Legacy source must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must be removed after migration (M1)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test M2 — Qwen minimal-mode migrates dev-preferences (#2973)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('M2: --qwen --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
|
||||
test('spawn node bin/install.js --qwen --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||
const root = createTempDir('gsd-qwen-m2-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--qwen', '--global', '--config-dir', root, '--profile=core'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
// Migration target must exist and contain the original content
|
||||
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M2: _runLegacyInstallMigrations missing in qwen minimal branch)`,
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my qwen prefs\n',
|
||||
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
|
||||
// Legacy source must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must be removed after migration (M2)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test M3 — Claude global minimal-mode migrates dev-preferences (#2973)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('M3: --claude --global --profile=core migrates dev-preferences.md → skills/gsd-dev-preferences/SKILL.md', () => {
|
||||
test('spawn node bin/install.js --claude --global --profile=core: dev-preferences migrated, source gone', (t) => {
|
||||
const root = createTempDir('gsd-claude-m3-');
|
||||
t.after(() => cleanup(root));
|
||||
|
||||
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||
const legacyDir = path.join(root, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
|
||||
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[INSTALL_SCRIPT, '--claude', '--global', '--config-dir', root, '--profile=core'],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, HOME: root, USERPROFILE: root },
|
||||
},
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
result.status,
|
||||
0,
|
||||
`installer exited with status ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`,
|
||||
);
|
||||
|
||||
// Migration target must exist and contain the original content
|
||||
const skillFile = path.join(root, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
`skills/gsd-dev-preferences/SKILL.md must exist after migration (M3: _runLegacyInstallMigrations missing in claude-global-minimal branch)`,
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my claude prefs\n',
|
||||
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
|
||||
// Legacy source must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must be removed after migration (M3)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test U1 — Qwen uninstall preserves dev-preferences via migration to skill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('U1: uninstallRuntimeArtifacts qwen preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
|
||||
test('uninstallRuntimeArtifacts("qwen"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
|
||||
const configDir = createTempDir('gsd-qwen-uninstall-u1-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof uninstallRuntimeArtifacts,
|
||||
'function',
|
||||
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||
);
|
||||
|
||||
// Pre-create legacy commands/gsd/ with dev-preferences.md and a managed file
|
||||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my qwen prefs\n');
|
||||
fs.writeFileSync(path.join(legacyDir, 'help.md'), '# help content\n');
|
||||
|
||||
uninstallRuntimeArtifacts('qwen', configDir, 'global');
|
||||
|
||||
// Legacy managed file must be gone (whole commands/gsd/ removed)
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'help.md')),
|
||||
'commands/gsd/help.md must be removed by qwen uninstall (U1)',
|
||||
);
|
||||
|
||||
// dev-preferences.md must NOT be left behind in the legacy dir
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must not exist after qwen uninstall (U1)',
|
||||
);
|
||||
|
||||
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
|
||||
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
'skills/gsd-dev-preferences/SKILL.md must exist after qwen uninstall (U1: #2973 migration)',
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my qwen prefs\n',
|
||||
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test U2 — Claude-global uninstall preserves dev-preferences via migration to skill
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('U2: uninstallRuntimeArtifacts claude/global preserves dev-preferences.md via migration to skills/gsd-dev-preferences/SKILL.md', () => {
|
||||
test('uninstallRuntimeArtifacts("claude", scope="global"): commands/gsd/ removed, dev-preferences migrated to skills skill', (t) => {
|
||||
const configDir = createTempDir('gsd-claude-uninstall-u2-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof uninstallRuntimeArtifacts,
|
||||
'function',
|
||||
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||
);
|
||||
|
||||
// Pre-create legacy commands/gsd/ with dev-preferences.md
|
||||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my claude prefs\n');
|
||||
|
||||
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||
|
||||
// dev-preferences.md must NOT be left in the legacy dir
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must not exist after claude-global uninstall (U2)',
|
||||
);
|
||||
|
||||
// dev-preferences.md must be migrated to skills/gsd-dev-preferences/SKILL.md
|
||||
const skillFile = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
'skills/gsd-dev-preferences/SKILL.md must exist after claude-global uninstall (U2: #2973 migration)',
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my claude prefs\n',
|
||||
`skills/gsd-dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test U3 — Hermes uninstall migrates dev-preferences to NESTED location (#2973)
|
||||
// Proves both Finding 1 (wrong target path) and Finding 2 (unconditional restore).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('U3: uninstallRuntimeArtifacts hermes migrates dev-preferences.md → skills/gsd/dev-preferences/SKILL.md', () => {
|
||||
test('uninstallRuntimeArtifacts("hermes"): commands/gsd/ NOT recreated, dev-preferences at nested Hermes location', (t) => {
|
||||
const configDir = createTempDir('gsd-hermes-uninstall-u3-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof uninstallRuntimeArtifacts,
|
||||
'function',
|
||||
'uninstallRuntimeArtifacts must be exported from bin/install.js',
|
||||
);
|
||||
|
||||
// Pre-create legacy commands/gsd/dev-preferences.md
|
||||
const legacyDir = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyDir, 'dev-preferences.md'), '# my hermes prefs (uninstall path)\n');
|
||||
|
||||
uninstallRuntimeArtifacts('hermes', configDir, 'global');
|
||||
|
||||
// commands/gsd/dev-preferences.md must be GONE — no recreation after rmSync
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(legacyDir, 'dev-preferences.md')),
|
||||
'commands/gsd/dev-preferences.md must not exist after hermes uninstall (U3: Finding 2 — no unconditional restore)',
|
||||
);
|
||||
|
||||
// dev-preferences must be at the HERMES nested location (skills/gsd/dev-preferences/SKILL.md)
|
||||
// NOT the flat location skills/gsd-dev-preferences/SKILL.md (that is qwen/claude-global)
|
||||
const skillFile = path.join(configDir, 'skills', 'gsd', 'dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(skillFile),
|
||||
'skills/gsd/dev-preferences/SKILL.md must exist after hermes uninstall (U3: Finding 1 — runtime-aware migration target)',
|
||||
);
|
||||
const content = fs.readFileSync(skillFile, 'utf8');
|
||||
assert.strictEqual(
|
||||
content,
|
||||
'# my hermes prefs (uninstall path)\n',
|
||||
`skills/gsd/dev-preferences/SKILL.md content must equal original, got: ${JSON.stringify(content)}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
573
tests/install-uninstall-layout-loop.test.cjs
Normal file
573
tests/install-uninstall-layout-loop.test.cjs
Normal file
@@ -0,0 +1,573 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Phase 2 TDD — red tests for installRuntimeArtifacts / uninstallRuntimeArtifacts.
|
||||
*
|
||||
* These tests MUST fail with "TypeError: installRuntimeArtifacts is not a function"
|
||||
* (or equivalent) until the production implementation is added to bin/install.js.
|
||||
*
|
||||
* Conventions:
|
||||
* - node:test + node:assert/strict
|
||||
* - helpers.cjs for createTempDir / cleanup
|
||||
* - Filesystem assertions only — no source-grep, no .includes() on file content
|
||||
* - Per-runtime parameterisation where applicable
|
||||
* - t.after() for cleanup
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
resolveRuntimeArtifactLayout,
|
||||
} = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs');
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load target exports — will be undefined until Phase 2 ships
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GSD_TEST_MODE prevents install.js from running its CLI main() on require.
|
||||
const savedTestMode = process.env.GSD_TEST_MODE;
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
let installExports;
|
||||
try {
|
||||
installExports = require('../bin/install.js');
|
||||
} finally {
|
||||
if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE;
|
||||
else process.env.GSD_TEST_MODE = savedTestMode;
|
||||
}
|
||||
|
||||
const { installRuntimeArtifacts, uninstallRuntimeArtifacts } = installExports || {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared resolved profile (core — deterministic small set)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The real commands/gsd dir is used by the layout module's source-root walk.
|
||||
// We load its manifest so resolveProfile gives us the correct transitive closure.
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
|
||||
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime lists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Runtimes that use a "skills" kind at global scope
|
||||
const SKILLS_RUNTIMES = [
|
||||
'claude', 'cursor', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'codebuddy',
|
||||
];
|
||||
|
||||
// All 15 runtimes in the layout table
|
||||
const ALL_RUNTIMES = [
|
||||
'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity',
|
||||
'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy',
|
||||
'cline', 'opencode', 'kilo',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: count entries in destDir whose name starts with prefix
|
||||
// ---------------------------------------------------------------------------
|
||||
function countPrefixedEntries(destDir, prefix) {
|
||||
if (!fs.existsSync(destDir)) return 0;
|
||||
return fs.readdirSync(destDir).filter((n) => n.startsWith(prefix)).length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: write fixture skill entry in destDir
|
||||
// For skills runtimes: creates <destDir>/<prefix><stem>/SKILL.md
|
||||
// For commands runtimes: creates <destDir>/<prefix><stem>.md
|
||||
// ---------------------------------------------------------------------------
|
||||
function writeSkillEntry(destDir, prefix, stem) {
|
||||
const entryName = `${prefix}${stem}`;
|
||||
const entryDir = path.join(destDir, entryName);
|
||||
fs.mkdirSync(entryDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(entryDir, 'SKILL.md'), `# ${stem}\n`);
|
||||
}
|
||||
|
||||
function writeCommandEntry(destDir, prefix, stem) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, `${prefix}${stem}.md`), `# ${stem}\n`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 1 — installRuntimeArtifacts: layout-driven install loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('installRuntimeArtifacts — layout-driven install loop', () => {
|
||||
// Standard skills runtimes: gsd-prefixed dirs in <configDir>/skills/
|
||||
for (const runtime of SKILLS_RUNTIMES) {
|
||||
test(`${runtime}: installs gsd-prefixed skill dirs into <configDir>/skills/`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ial-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof installRuntimeArtifacts,
|
||||
'function',
|
||||
'installRuntimeArtifacts must be exported from bin/install.js'
|
||||
);
|
||||
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
const skillsKind = layout.kinds.find((k) => k.kind === 'skills');
|
||||
assert.ok(skillsKind, `${runtime} must have a skills kind in global scope`);
|
||||
|
||||
const destDir = path.join(configDir, skillsKind.destSubpath);
|
||||
assert.ok(fs.existsSync(destDir), `destDir must exist after install: ${destDir}`);
|
||||
|
||||
// At least one SKILL.md was written
|
||||
const helpDir = path.join(destDir, `${skillsKind.prefix}help`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(helpDir, 'SKILL.md')),
|
||||
`${runtime}: skills/${skillsKind.prefix}help/SKILL.md must exist after install`
|
||||
);
|
||||
|
||||
// Count of gsd-prefixed dirs matches resolved profile's skills size
|
||||
const prefixedCount = countPrefixedEntries(destDir, skillsKind.prefix || 'gsd-');
|
||||
const expectedCount = RESOLVED_CORE.skills === '*'
|
||||
? prefixedCount // full profile: just check non-zero
|
||||
: RESOLVED_CORE.skills.size;
|
||||
assert.ok(
|
||||
prefixedCount >= 1,
|
||||
`${runtime}: at least one skill dir must be present, got ${prefixedCount}`
|
||||
);
|
||||
if (RESOLVED_CORE.skills !== '*') {
|
||||
assert.strictEqual(
|
||||
prefixedCount,
|
||||
expectedCount,
|
||||
`${runtime}: number of installed skill dirs must match profile skills count`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// hermes: nested layout — skills/gsd/<stem>/SKILL.md, prefix is ''
|
||||
test('hermes: nested layout — skills/gsd/<stem>/SKILL.md, no gsd- prefix in name', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-hermes-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const nestedDir = path.join(configDir, 'skills', 'gsd');
|
||||
assert.ok(fs.existsSync(nestedDir), 'skills/gsd/ must exist after hermes install');
|
||||
|
||||
// help skill: skills/gsd/help/SKILL.md (prefix is '', so no gsd- prefix on stem)
|
||||
const helpSkillMd = path.join(nestedDir, 'help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(helpSkillMd),
|
||||
'hermes: skills/gsd/help/SKILL.md must exist'
|
||||
);
|
||||
|
||||
// Verify no gsd- prefixed entry at skills/gsd/gsd-help (prefix must be '')
|
||||
const wrongEntry = path.join(nestedDir, 'gsd-help');
|
||||
assert.ok(
|
||||
!fs.existsSync(wrongEntry),
|
||||
'hermes: skills/gsd/gsd-help must NOT exist (prefix should be empty)'
|
||||
);
|
||||
});
|
||||
|
||||
// gemini: commands kind — commands/gsd/<stem>.md, no skills/ dir
|
||||
test('gemini: commands layout — commands/gsd/ is created, no skills/ dir', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-gemini-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
installRuntimeArtifacts('gemini', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandsGsdDir = path.join(configDir, 'commands', 'gsd');
|
||||
assert.ok(
|
||||
fs.existsSync(commandsGsdDir),
|
||||
'gemini: commands/gsd/ must exist after install'
|
||||
);
|
||||
|
||||
// help.md should be present (part of core profile)
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandsGsdDir, 'help.md')),
|
||||
'gemini: commands/gsd/help.md must exist'
|
||||
);
|
||||
|
||||
// No skills/ directory should be created for gemini
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
assert.ok(
|
||||
!fs.existsSync(skillsDir),
|
||||
'gemini: skills/ must NOT be created'
|
||||
);
|
||||
});
|
||||
|
||||
// cline: kinds is [] — call must succeed, no dirs created
|
||||
test('cline: no kinds — call succeeds, no skills/ or commands/ created', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-cline-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
// Must not throw
|
||||
assert.doesNotThrow(() => {
|
||||
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_CORE);
|
||||
}, 'installRuntimeArtifacts must not throw for cline');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const commandsDir = path.join(configDir, 'commands');
|
||||
assert.ok(!fs.existsSync(skillsDir), 'cline: skills/ must NOT be created');
|
||||
assert.ok(!fs.existsSync(commandsDir), 'cline: commands/ must NOT be created');
|
||||
});
|
||||
|
||||
// opencode: flat commands — command/gsd-<stem>.md
|
||||
test('opencode: flat commands layout — command/gsd-help.md exists', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-opencode-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
installRuntimeArtifacts('opencode', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandDir = path.join(configDir, 'command');
|
||||
assert.ok(fs.existsSync(commandDir), 'opencode: command/ must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
|
||||
'opencode: command/gsd-help.md must exist'
|
||||
);
|
||||
});
|
||||
|
||||
// kilo: same flat commands layout as opencode
|
||||
test('kilo: flat commands layout — command/gsd-help.md exists', (t) => {
|
||||
const configDir = createTempDir('gsd-ial-kilo-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
installRuntimeArtifacts('kilo', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
const commandDir = path.join(configDir, 'command');
|
||||
assert.ok(fs.existsSync(commandDir), 'kilo: command/ must exist');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(commandDir, 'gsd-help.md')),
|
||||
'kilo: command/gsd-help.md must exist'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 2 — uninstallRuntimeArtifacts: layout-driven removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('uninstallRuntimeArtifacts — layout-driven removal', () => {
|
||||
for (const runtime of ALL_RUNTIMES) {
|
||||
test(`${runtime}: removes gsd-owned entries, preserves foreign entries`, (t) => {
|
||||
const configDir = createTempDir(`gsd-ual-${runtime}-`);
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(
|
||||
typeof uninstallRuntimeArtifacts,
|
||||
'function',
|
||||
'uninstallRuntimeArtifacts must be exported from bin/install.js'
|
||||
);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
|
||||
if (layout.kinds.length === 0) {
|
||||
// cline: no kinds — call must succeed and not touch anything
|
||||
const foreignDir = path.join(configDir, 'foreign-dir');
|
||||
fs.mkdirSync(foreignDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(foreignDir, 'keep.md'), '# keep\n');
|
||||
|
||||
assert.doesNotThrow(() => {
|
||||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||
}, `${runtime}: uninstallRuntimeArtifacts must not throw with empty kinds`);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(foreignDir, 'keep.md')),
|
||||
`${runtime}: foreign entries must not be touched`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// For hermes: special handling — prefix is '', destSubpath is 'skills/gsd'
|
||||
// The "namespace IS the prefix": everything under skills/gsd/ is gsd-owned.
|
||||
// We pre-create a sibling skills/user-skill/ that must survive.
|
||||
if (runtime === 'hermes') {
|
||||
const kind = layout.kinds[0];
|
||||
const destDir = path.join(configDir, kind.destSubpath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
// Create nested skill entries (prefix is '' so names have no gsd- prefix)
|
||||
const helpDir = path.join(destDir, 'help');
|
||||
fs.mkdirSync(helpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(helpDir, 'SKILL.md'), '# help\n');
|
||||
const phaseDir = path.join(destDir, 'phase');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, 'SKILL.md'), '# phase\n');
|
||||
|
||||
// Sibling at skills/ level — must be preserved
|
||||
const siblingDir = path.join(configDir, 'skills', 'user-skill');
|
||||
fs.mkdirSync(siblingDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(siblingDir, 'SKILL.md'), '# user skill\n');
|
||||
|
||||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||
|
||||
// skills/gsd/ itself and its children should be removed
|
||||
assert.ok(
|
||||
!fs.existsSync(destDir),
|
||||
'hermes: skills/gsd/ must be removed by uninstall'
|
||||
);
|
||||
|
||||
// Sibling skills/user-skill/ must survive
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(siblingDir, 'SKILL.md')),
|
||||
'hermes: skills/user-skill/ must be preserved'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// General case for all other runtimes
|
||||
for (const kind of layout.kinds) {
|
||||
const destDir = path.join(configDir, kind.destSubpath);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
|
||||
if (kind.kind === 'skills') {
|
||||
// Write two gsd-prefixed skill dirs and one foreign dir
|
||||
writeSkillEntry(destDir, kind.prefix, 'help');
|
||||
writeSkillEntry(destDir, kind.prefix, 'phase');
|
||||
// Foreign entry: no matching prefix
|
||||
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||||
fs.mkdirSync(foreignDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# user\n');
|
||||
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
|
||||
// Write two gsd-prefixed command/agent files and one foreign file
|
||||
writeCommandEntry(destDir, kind.prefix, 'help');
|
||||
writeCommandEntry(destDir, kind.prefix, 'phase');
|
||||
// Foreign entry
|
||||
fs.writeFileSync(path.join(destDir, 'user-custom.md'), '# user\n');
|
||||
}
|
||||
}
|
||||
|
||||
uninstallRuntimeArtifacts(runtime, configDir, 'global');
|
||||
|
||||
for (const kind of layout.kinds) {
|
||||
const destDir = path.join(configDir, kind.destSubpath);
|
||||
|
||||
if (kind.kind === 'skills') {
|
||||
// gsd-prefixed skill dirs must be gone
|
||||
const helpDir = path.join(destDir, `${kind.prefix}help`);
|
||||
const phaseDir = path.join(destDir, `${kind.prefix}phase`);
|
||||
assert.ok(
|
||||
!fs.existsSync(helpDir),
|
||||
`${runtime}: ${kind.prefix}help dir must be removed`
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(phaseDir),
|
||||
`${runtime}: ${kind.prefix}phase dir must be removed`
|
||||
);
|
||||
// Foreign entry must survive
|
||||
const foreignDir = path.join(destDir, 'user-custom-skill');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(foreignDir, 'SKILL.md')),
|
||||
`${runtime}: user-custom-skill/SKILL.md must be preserved`
|
||||
);
|
||||
} else if (kind.kind === 'commands' || kind.kind === 'agents') {
|
||||
// gsd-prefixed files must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(destDir, `${kind.prefix}help.md`)),
|
||||
`${runtime}: ${kind.prefix}help.md must be removed`
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(destDir, `${kind.prefix}phase.md`)),
|
||||
`${runtime}: ${kind.prefix}phase.md must be removed`
|
||||
);
|
||||
// Foreign file must survive
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(destDir, 'user-custom.md')),
|
||||
`${runtime}: user-custom.md must be preserved`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 3 — installRuntimeArtifacts: legacy migrations run before layout copy
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('installRuntimeArtifacts — legacy migrations run before layout-driven copy', () => {
|
||||
test('claude: legacy commands/gsd/dev-preferences.md migrated AND new skills written', (t) => {
|
||||
const configDir = createTempDir('gsd-legacy-install-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
// Pre-create the legacy state: commands/gsd/dev-preferences.md
|
||||
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(legacyCommandsGsd, 'dev-preferences.md'),
|
||||
'# My dev preferences\n'
|
||||
);
|
||||
|
||||
// Call the new unified function
|
||||
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// Legacy migration side-effect: commands/gsd/ must be removed (legacy cleanup)
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyCommandsGsd),
|
||||
'claude: legacy commands/gsd/ must be cleaned up by install migration'
|
||||
);
|
||||
|
||||
// Migration side-effect: dev-preferences.md → skills/gsd-dev-preferences/SKILL.md
|
||||
const devPrefSkill = path.join(configDir, 'skills', 'gsd-dev-preferences', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(devPrefSkill),
|
||||
'claude: skills/gsd-dev-preferences/SKILL.md must be written by legacy migration'
|
||||
);
|
||||
|
||||
// New skills must also be present — layout-driven copy ran
|
||||
const helpSkill = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(helpSkill),
|
||||
'claude: skills/gsd-help/SKILL.md must be written by layout-driven install'
|
||||
);
|
||||
});
|
||||
|
||||
test('hermes: legacy flat skills/gsd-*/ migrated AND new nested skills/gsd/<stem>/ written', (t) => {
|
||||
const configDir = createTempDir('gsd-legacy-hermes-install-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof installRuntimeArtifacts, 'function');
|
||||
|
||||
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
|
||||
const flatSkillsDir = path.join(configDir, 'skills');
|
||||
const legacyFlatHelp = path.join(flatSkillsDir, 'gsd-help');
|
||||
fs.mkdirSync(legacyFlatHelp, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyFlatHelp, 'SKILL.md'), '# legacy help\n');
|
||||
|
||||
installRuntimeArtifacts('hermes', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
// Legacy flat entry must be removed
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyFlatHelp),
|
||||
'hermes: legacy skills/gsd-help/ must be removed by install migration'
|
||||
);
|
||||
|
||||
// New nested layout must exist: skills/gsd/help/SKILL.md
|
||||
const newNestedHelp = path.join(configDir, 'skills', 'gsd', 'help', 'SKILL.md');
|
||||
assert.ok(
|
||||
fs.existsSync(newNestedHelp),
|
||||
'hermes: skills/gsd/help/SKILL.md must be written by layout-driven install'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// describe 4 — uninstallRuntimeArtifacts: legacy cleanup runs before layout removal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('uninstallRuntimeArtifacts — legacy cleanup runs before layout-driven removal', () => {
|
||||
test('hermes: both flat (pre-#2841 gsd-*/) and nested (gsd/<stem>/) layouts removed', (t) => {
|
||||
const configDir = createTempDir('gsd-legacy-uninstall-hermes-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||||
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
|
||||
// Pre-create legacy flat layout (pre-#2841): skills/gsd-help/SKILL.md
|
||||
const flatHelpDir = path.join(skillsDir, 'gsd-help');
|
||||
fs.mkdirSync(flatHelpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(flatHelpDir, 'SKILL.md'), '# legacy flat help\n');
|
||||
|
||||
const flatPhaseDir = path.join(skillsDir, 'gsd-phase');
|
||||
fs.mkdirSync(flatPhaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(flatPhaseDir, 'SKILL.md'), '# legacy flat phase\n');
|
||||
|
||||
// Pre-create nested layout (post-#2841): skills/gsd/help/SKILL.md
|
||||
const nestedGsd = path.join(skillsDir, 'gsd');
|
||||
const nestedHelpDir = path.join(nestedGsd, 'help');
|
||||
fs.mkdirSync(nestedHelpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedHelpDir, 'SKILL.md'), '# nested help\n');
|
||||
|
||||
const nestedPhaseDir = path.join(nestedGsd, 'phase');
|
||||
fs.mkdirSync(nestedPhaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(nestedPhaseDir, 'SKILL.md'), '# nested phase\n');
|
||||
|
||||
// Non-gsd sibling at skills/ level — must survive
|
||||
const userSkillDir = path.join(skillsDir, 'user-skill');
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user skill\n');
|
||||
|
||||
uninstallRuntimeArtifacts('hermes', configDir, 'global');
|
||||
|
||||
// Flat legacy entries must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(flatHelpDir),
|
||||
'hermes uninstall: legacy skills/gsd-help/ must be removed'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(flatPhaseDir),
|
||||
'hermes uninstall: legacy skills/gsd-phase/ must be removed'
|
||||
);
|
||||
|
||||
// Nested layout must be gone
|
||||
assert.ok(
|
||||
!fs.existsSync(nestedGsd),
|
||||
'hermes uninstall: skills/gsd/ (nested) must be removed'
|
||||
);
|
||||
|
||||
// User skill must survive
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
|
||||
'hermes uninstall: skills/user-skill/ must be preserved'
|
||||
);
|
||||
});
|
||||
|
||||
test('claude: legacy commands/gsd/ cleaned up AND new skills/ entries removed by uninstall', (t) => {
|
||||
const configDir = createTempDir('gsd-legacy-uninstall-claude-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
assert.strictEqual(typeof uninstallRuntimeArtifacts, 'function');
|
||||
|
||||
// Pre-create layout-driven gsd skills
|
||||
const skillsDir = path.join(configDir, 'skills');
|
||||
const gsdHelpDir = path.join(skillsDir, 'gsd-help');
|
||||
fs.mkdirSync(gsdHelpDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(gsdHelpDir, 'SKILL.md'), '# help\n');
|
||||
|
||||
// Pre-create legacy commands/gsd/ (should also be cleaned)
|
||||
const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd');
|
||||
fs.mkdirSync(legacyCommandsGsd, { recursive: true });
|
||||
fs.writeFileSync(path.join(legacyCommandsGsd, 'help.md'), '# help legacy\n');
|
||||
|
||||
// Foreign skill — must survive
|
||||
const userSkillDir = path.join(skillsDir, 'user-skill');
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(userSkillDir, 'SKILL.md'), '# user\n');
|
||||
|
||||
uninstallRuntimeArtifacts('claude', configDir, 'global');
|
||||
|
||||
// gsd-help must be removed
|
||||
assert.ok(
|
||||
!fs.existsSync(gsdHelpDir),
|
||||
'claude uninstall: skills/gsd-help/ must be removed'
|
||||
);
|
||||
|
||||
// Foreign skill must survive
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(userSkillDir, 'SKILL.md')),
|
||||
'claude uninstall: user-skill/ must be preserved'
|
||||
);
|
||||
|
||||
// Legacy commands/gsd/ must also be cleaned by legacy migration running first
|
||||
assert.ok(
|
||||
!fs.existsSync(legacyCommandsGsd),
|
||||
'claude uninstall: legacy commands/gsd/ must be cleaned up'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,15 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
// the same flat-skills surface as the other runtimes.
|
||||
assertHasGsdDirectory(targetDir, 'skills');
|
||||
} else if (contract.surface === 'hermes-skills') {
|
||||
assertHasGsdDirectory(targetDir, path.join('skills', 'gsd'));
|
||||
// Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix).
|
||||
// Assert that the category dir contains at least one skill dir with SKILL.md.
|
||||
const hermesGsdDir = path.join(targetDir, 'skills', 'gsd');
|
||||
const hermesSkillCount = fs.existsSync(hermesGsdDir)
|
||||
? fs.readdirSync(hermesGsdDir, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory() && fs.existsSync(path.join(hermesGsdDir, e.name, 'SKILL.md')))
|
||||
.length
|
||||
: 0;
|
||||
assert.ok(hermesSkillCount > 0, `skills/gsd should contain generated GSD entries (got ${hermesSkillCount})`);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
|
||||
'Hermes should install the nested GSD category description'
|
||||
|
||||
@@ -21,9 +21,17 @@ const fs = require('fs');
|
||||
|
||||
const {
|
||||
convertClaudeCommandToClaudeSkill,
|
||||
copyCommandsAsClaudeSkills,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
|
||||
|
||||
// ─── convertClaudeCommandToClaudeSkill (used by Qwen via copyCommandsAsClaudeSkills) ──
|
||||
|
||||
describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||
@@ -118,9 +126,9 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── copyCommandsAsClaudeSkills (used for Qwen skills install) ─────────────
|
||||
// ─── installRuntimeArtifacts (used for Qwen skills install) ─────────────────
|
||||
|
||||
describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
describe('Qwen Code: installRuntimeArtifacts', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -149,11 +157,15 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
'<objective>Quick task body</objective>',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/prefix/', 'qwen', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
// Redirect findInstallSourceRoot to the test's custom srcDir
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
// Verify SKILL.md was created
|
||||
const skillPath = path.join(skillsDir, 'gsd-quick', 'SKILL.md');
|
||||
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
// Qwen layout: skills/gsd-<stem>/SKILL.md (destSubpath='skills', prefix='gsd-')
|
||||
const skillPath = path.join(configDir, 'skills', 'gsd-quick', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(skillPath), 'gsd-quick/SKILL.md exists');
|
||||
|
||||
// Verify content
|
||||
@@ -164,7 +176,7 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||
});
|
||||
|
||||
test('replaces ~/.claude/ paths with pathPrefix', () => {
|
||||
test('replaces ~/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'next.md'), [
|
||||
@@ -176,15 +188,18 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
'Reference: @~/.claude/get-shit-done/workflows/next.md',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-next', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude path removed');
|
||||
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-next', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path removed');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path not present');
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude/ paths with pathPrefix', () => {
|
||||
test('replaces $HOME/.claude/ paths via applyRuntimeContentRewritesInPlace', () => {
|
||||
const srcDir = path.join(tmpDir, 'src', 'commands', 'gsd');
|
||||
fs.mkdirSync(srcDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, 'plan.md'), [
|
||||
@@ -196,12 +211,15 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
'Reference: $HOME/.claude/get-shit-done/workflows/plan.md',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '$HOME/.qwen/', 'qwen', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-plan', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('$HOME/.qwen/'), 'path replaced to .qwen/');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude path removed');
|
||||
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-plan', 'SKILL.md'), 'utf8');
|
||||
assert.ok(!content.includes('$HOME/.claude/'), 'old claude $HOME-path removed');
|
||||
assert.ok(!content.includes('~/.claude/'), 'old claude tilde-path not present');
|
||||
});
|
||||
|
||||
test('removes stale gsd- skills before installing new ones', () => {
|
||||
@@ -216,15 +234,19 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
'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');
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false);
|
||||
// Pre-create a stale skill at the new layout location
|
||||
const staleSkillDir = path.join(configDir, 'skills', 'gsd-old-skill');
|
||||
fs.mkdirSync(staleSkillDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleSkillDir, 'SKILL.md'), 'old');
|
||||
|
||||
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');
|
||||
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
assert.ok(!fs.existsSync(staleSkillDir), 'stale skill removed');
|
||||
assert.ok(fs.existsSync(path.join(configDir, 'skills', 'gsd-quick', 'SKILL.md')), 'new skill installed');
|
||||
});
|
||||
|
||||
test('preserves agent field in frontmatter', () => {
|
||||
@@ -244,10 +266,13 @@ describe('Qwen Code: copyCommandsAsClaudeSkills', () => {
|
||||
'Execute body',
|
||||
].join('\n'));
|
||||
|
||||
const skillsDir = path.join(tmpDir, 'dest', 'skills');
|
||||
copyCommandsAsClaudeSkills(srcDir, skillsDir, 'gsd', '/test/', 'qwen', false);
|
||||
const configDir = path.join(tmpDir, 'dest');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, '.gsd-source'), srcDir);
|
||||
|
||||
const content = fs.readFileSync(path.join(skillsDir, 'gsd-execute', 'SKILL.md'), 'utf8');
|
||||
installRuntimeArtifacts('qwen', configDir, 'global', resolvedProfileFull);
|
||||
|
||||
const content = fs.readFileSync(path.join(configDir, 'skills', 'gsd-execute', 'SKILL.md'), 'utf8');
|
||||
assert.ok(content.includes('agent: gsd-executor'), 'agent field preserved');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,12 +19,20 @@ const {
|
||||
convertClaudeToTraeMarkdown,
|
||||
convertClaudeCommandToTraeSkill,
|
||||
convertClaudeAgentToTraeAgent,
|
||||
copyCommandsAsTraeSkills,
|
||||
installRuntimeArtifacts,
|
||||
install,
|
||||
uninstall,
|
||||
writeManifest,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const {
|
||||
loadSkillsManifest,
|
||||
resolveProfile,
|
||||
} = require('../get-shit-done/bin/lib/install-profiles.cjs');
|
||||
|
||||
const manifest = loadSkillsManifest();
|
||||
const resolvedProfileFull = resolveProfile({ modes: [], manifest });
|
||||
|
||||
describe('Trae runtime directory mapping', () => {
|
||||
test('maps Trae to .trae for local installs', () => {
|
||||
assert.strictEqual(getDirName('trae'), '.trae');
|
||||
@@ -133,7 +141,7 @@ Read CLAUDE.md before acting.
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyCommandsAsTraeSkills', () => {
|
||||
describe('installRuntimeArtifacts (trae)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -145,12 +153,12 @@ describe('copyCommandsAsTraeSkills', () => {
|
||||
});
|
||||
|
||||
test('creates one skill directory per GSD command', () => {
|
||||
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
const skillsDir = path.join(tmpDir, '.trae', 'skills');
|
||||
const configDir = path.join(tmpDir, '.trae');
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
|
||||
copyCommandsAsTraeSkills(srcDir, skillsDir, 'gsd', '$HOME/.trae/', 'trae');
|
||||
installRuntimeArtifacts('trae', configDir, 'local', resolvedProfileFull);
|
||||
|
||||
const generated = path.join(skillsDir, 'gsd-help', 'SKILL.md');
|
||||
const generated = path.join(configDir, 'skills', 'gsd-help', 'SKILL.md');
|
||||
assert.ok(fs.existsSync(generated), generated);
|
||||
|
||||
const content = fs.readFileSync(generated, 'utf8');
|
||||
|
||||
Reference in New Issue
Block a user