diff --git a/bin/install.js b/bin/install.js index ea070b47b..f813f25e4 100755 --- a/bin/install.js +++ b/bin/install.js @@ -123,6 +123,7 @@ const { loadSkillsManifest, stageSkillsForProfile, stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, } = require(path.join(_gsdLibDir, 'install-profiles.cjs')); const { applyInstallerMigrationPlan, @@ -134,6 +135,9 @@ const { resolveInstallerMigrationPromptsForNonTty, summarizeInstallerMigrationResult, } = require(path.join(_gsdLibDir, 'installer-migration-report.cjs')); +const { + resolveRuntimeArtifactLayout, +} = require(path.join(_gsdLibDir, 'runtime-artifact-layout.cjs')); // Parse args const args = process.argv.slice(2); @@ -2231,58 +2235,6 @@ function convertClaudeAgentToAugmentAgent(content) { * Copy Claude commands as Augment skills — one folder per skill with SKILL.md. * Mirrors copyCommandsAsCursorSkills but uses Augment converters. */ -function copyCommandsAsAugmentSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - // Remove previous GSD Augment skills to avoid stale command skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const augmentDirRegex = /~\/\.augment\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(augmentDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToAugmentSkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} function convertSlashCommandsToTraeSkillMentions(content) { return content.replace(/\/gsd:([a-z0-9-]+)/g, (_, commandName) => { @@ -5563,462 +5515,52 @@ function listCodexSkillNames(skillsDir, prefix = 'gsd-') { .sort(); } -function copyCommandsAsCodexSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } +/** + * Generic skills install helper used by all copyCommandsAs*Skills shims. + * + * Recursively walks srcDir, applies converter to each .md file (mirroring the + * old per-function recurse() bodies), applies runtime content rewrites + * (path + branding), and writes each skill as -/SKILL.md under + * skillsDir. Replaces the ~50-line recursion bodies in the 9 old functions. + * + * @param {string} srcDir source commands directory + * @param {string} skillsDir destination skills directory + * @param {string} prefix skill name prefix without trailing dash (e.g. 'gsd') + * @param {string} pathPrefix trailing-slash path prefix for content rewrites + * @param {string} runtime canonical runtime ID for rewrite table + * @param {Function} converter wrapped converter (content, skillName) → string + */ - fs.mkdirSync(skillsDir, { recursive: true }); - // Remove previous GSD Codex skills to avoid stale command skills. - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const codexDirRegex = /~\/\.codex\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(codexDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToCodexSkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} - -function copyCommandsAsCursorSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - // Remove previous GSD Cursor skills to avoid stale command skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const cursorDirRegex = /~\/\.cursor\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(cursorDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToCursorSkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Copy Claude commands as Windsurf skills — one folder per skill with SKILL.md. * Mirrors copyCommandsAsCursorSkills but uses Windsurf converters. */ -function copyCommandsAsWindsurfSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - fs.mkdirSync(skillsDir, { recursive: true }); - - // Remove previous GSD Windsurf skills to avoid stale command skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const windsurfDirRegex = /~\/\.codeium\/windsurf\//g; - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(windsurfDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToWindsurfSkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} - -function copyCommandsAsTraeSkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const bareGlobalClaudeRegex = /~\/\.claude\b/g; - const bareGlobalClaudeHomeRegex = /\$HOME\/\.claude\b/g; - const bareLocalClaudeRegex = /\.\/\.claude\b/g; - const traeDirRegex = /~\/\.trae\//g; - const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(bareGlobalClaudeRegex, normalizedPathPrefix); - content = content.replace(bareGlobalClaudeHomeRegex, normalizedPathPrefix); - content = content.replace(bareLocalClaudeRegex, `./${getDirName(runtime)}`); - content = content.replace(traeDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToTraeSkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Copy Claude commands as CodeBuddy skills — one folder per skill with SKILL.md. * CodeBuddy uses the same tool names as Claude Code, but has its own config directory structure. */ -function copyCommandsAsCodebuddySkills(srcDir, skillsDir, prefix, pathPrefix, runtime) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - const globalClaudeRegex = /~\/\.claude\//g; - const globalClaudeHomeRegex = /\$HOME\/\.claude\//g; - const localClaudeRegex = /\.\/\.claude\//g; - const bareGlobalClaudeRegex = /~\/\.claude\b/g; - const bareGlobalClaudeHomeRegex = /\$HOME\/\.claude\b/g; - const bareLocalClaudeRegex = /\.\/\.claude\b/g; - const codebuddyDirRegex = /~\/\.codebuddy\//g; - const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); - content = content.replace(globalClaudeRegex, pathPrefix); - content = content.replace(globalClaudeHomeRegex, pathPrefix); - content = content.replace(localClaudeRegex, `./${getDirName(runtime)}/`); - content = content.replace(bareGlobalClaudeRegex, normalizedPathPrefix); - content = content.replace(bareGlobalClaudeHomeRegex, normalizedPathPrefix); - content = content.replace(bareLocalClaudeRegex, `./${getDirName(runtime)}`); - content = content.replace(codebuddyDirRegex, pathPrefix); - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToCodebuddySkill(content, skillName); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Copy Claude commands as Copilot skills — one folder per skill with SKILL.md. * Applies CONV-01 (structure), CONV-02 (allowed-tools), CONV-06 (paths), CONV-07 (command names). */ -function copyCommandsAsCopilotSkills(srcDir, skillsDir, prefix, isGlobal = false) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - // Remove previous GSD Copilot skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeCommandToCopilotSkill(content, skillName, isGlobal); - content = processAttribution(content, getCommitAttribution('copilot')); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Copy Claude commands as Claude skills — one folder per skill with SKILL.md. * Claude Code 2.1.88+ uses skills/xxx/SKILL.md instead of commands/gsd/xxx.md. - * Claude is the native format so no path replacement is needed — only - * frontmatter restructuring via convertClaudeCommandToClaudeSkill. + * Supports runtime='claude'|'qwen'|'hermes'; branding rewrites are applied via + * applyRuntimeContentRewritesInPlace inside _copyCommandsAsSkillsViaConverter. * @param {string} srcDir - Source commands directory * @param {string} skillsDir - Target skills directory * @param {string} prefix - Skill name prefix (e.g. 'gsd') * @param {string} pathPrefix - Path prefix for file references * @param {string} runtime - Target runtime - * @param {boolean} isGlobal - Whether this is a global install + * @param {boolean} isGlobal - Whether this is a global install (unused; kept for compat) */ -function copyCommandsAsClaudeSkills(srcDir, skillsDir, prefix, pathPrefix, runtime, isGlobal = false) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - // Live command names for the colon→hyphen body transform (#3583), computed - // once per install instead of inside convertClaudeCommandToClaudeSkill where - // it would re-scan commands/gsd for every skill. - const cmdNames = readGsdCommandNames(); - - // #2973 (CR follow-up on #3003): preserve user-generated skills across the - // wipe-and-replace. `gsd-dev-preferences/SKILL.md` is written by the user - // via `/gsd-profile-user --refresh`; it is NOT shipped by the npm package, - // so a wipe without snapshot deletes the user's content with nothing to - // restore from. Snapshot the SKILL.md (and any sibling files in that - // directory) before the wipe and restore them after. - const USER_OWNED_SKILLS = new Set(['gsd-dev-preferences']); - const preservedUserSkills = new Map(); // skillName -> Map(relPath -> Buffer) - for (const skillName of USER_OWNED_SKILLS) { - const skillDir = path.join(skillsDir, skillName); - if (!fs.existsSync(skillDir)) continue; - const files = new Map(); - const walkSnap = (curRel, curAbs) => { - for (const e of fs.readdirSync(curAbs, { withFileTypes: true })) { - const childRel = curRel ? path.join(curRel, e.name) : e.name; - const childAbs = path.join(curAbs, e.name); - if (e.isDirectory()) walkSnap(childRel, childAbs); - else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs)); - } - }; - walkSnap('', skillDir); - if (files.size > 0) preservedUserSkills.set(skillName, files); - } - - // Remove previous GSD Claude skills to avoid stale command skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - // Restore user-owned skills after the wipe but before recursive copy populates - // shipped skills. If the npm package later happens to ship a same-named skill - // (currently it does not for gsd-dev-preferences), the restored user content - // is the source of truth: the recurse() loop below would overwrite it on - // collision, but the USER_OWNED_SKILLS set is by definition disjoint from - // shipped-skill names. - for (const [skillName, files] of preservedUserSkills) { - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - for (const [relPath, buf] of files) { - const absPath = path.join(skillDir, relPath); - fs.mkdirSync(path.dirname(absPath), { recursive: true }); - fs.writeFileSync(absPath, buf); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - content = content.replace(/~\/\.claude\//g, pathPrefix); - content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); - content = content.replace(/\.\/\.claude\//g, `./${getDirName(runtime)}/`); - content = content.replace(/~\/\.qwen\//g, pathPrefix); - content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); - content = content.replace(/\.\/\.qwen\//g, `./${getDirName(runtime)}/`); - content = content.replace(/~\/\.hermes\//g, pathPrefix); - content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); - content = content.replace(/\.\/\.hermes\//g, `./${getDirName(runtime)}/`); - // Qwen reuses Claude skill format but needs runtime-specific content replacement - if (runtime === 'qwen') { - content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); - content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); - content = content.replace(/\.claude\//g, '.qwen/'); - } - // Hermes Agent reuses Claude skill format; rewrite branding + paths. - if (runtime === 'hermes') { - content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); - content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); - content = content.replace(/\.claude\//g, '.hermes/'); - } - content = processAttribution(content, getCommitAttribution(runtime)); - content = convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Write the Hermes "gsd" category DESCRIPTION.md. @@ -6056,50 +5598,6 @@ function writeHermesCategoryDescription(categoryDir) { * @param {string} prefix - Skill name prefix (e.g. 'gsd') * @param {boolean} isGlobal - Whether this is a global install */ -function copyCommandsAsAntigravitySkills(srcDir, skillsDir, prefix, isGlobal = false) { - if (!fs.existsSync(srcDir)) { - return; - } - - fs.mkdirSync(skillsDir, { recursive: true }); - - // Remove previous GSD Antigravity skills - const existing = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of existing) { - if (entry.isDirectory() && entry.name.startsWith(`${prefix}-`)) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - } - } - - function recurse(currentSrcDir, currentPrefix) { - const entries = fs.readdirSync(currentSrcDir, { withFileTypes: true }); - - for (const entry of entries) { - const srcPath = path.join(currentSrcDir, entry.name); - if (entry.isDirectory()) { - recurse(srcPath, `${currentPrefix}-${entry.name}`); - continue; - } - - if (!entry.name.endsWith('.md')) { - continue; - } - - const baseName = entry.name.replace('.md', ''); - const skillName = `${currentPrefix}-${baseName}`; - const skillDir = path.join(skillsDir, skillName); - fs.mkdirSync(skillDir, { recursive: true }); - - let content = fs.readFileSync(srcPath, 'utf8'); - content = convertClaudeCommandToAntigravitySkill(content, skillName, isGlobal); - content = processAttribution(content, getCommitAttribution('antigravity')); - - fs.writeFileSync(path.join(skillDir, 'SKILL.md'), content); - } - } - - recurse(srcDir, prefix); -} /** * Single source of truth for user-owned artifacts inside get-shit-done/. @@ -6157,7 +5655,12 @@ function restoreUserArtifacts(destDir, saved) { /** * Migrate a legacy dev-preferences.md (saved from commands/gsd/) into the - * skills/gsd-dev-preferences/SKILL.md location used by the writer after #2973. + * runtime-aware SKILL.md location used by the writer after #2973. + * + * For runtimes with a nested skills layout (e.g. Hermes: skills/gsd//), + * the target is /skills/gsd/dev-preferences/SKILL.md. + * For runtimes with a flat skills layout (prefix='gsd-'), the target is + * /skills/gsd-dev-preferences/SKILL.md. * * Skips silently if no legacy file was preserved, or if a SKILL.md already * exists at the new location (don't clobber user-customized skill content @@ -6166,11 +5669,23 @@ function restoreUserArtifacts(destDir, saved) { * * @param {string} targetDir - Resolved runtime config directory (e.g. ~/.claude) * @param {Map} saved - Map returned by preserveUserArtifacts + * @param {string} [runtime] - canonical runtime ID (e.g. 'hermes', 'qwen', 'claude') + * @param {'global'|'local'} [scope] - install scope * @returns {boolean} - true if a file was migrated, false otherwise */ -function migrateLegacyDevPreferencesToSkill(targetDir, saved) { +function migrateLegacyDevPreferencesToSkill(targetDir, saved, runtime, scope = 'global') { if (!saved || !saved.has('dev-preferences.md')) return false; - const skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences'); + let skillDir; + if (runtime) { + const layout = resolveRuntimeArtifactLayout(runtime, targetDir, scope); + const skillsKindEntry = layout.kinds.find((k) => k.kind === 'skills'); + if (!skillsKindEntry) return false; // runtime has no skills layout (e.g. cline) + const stemName = skillsKindEntry.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; + skillDir = path.join(targetDir, skillsKindEntry.destSubpath, stemName); + } else { + // Legacy fallback for callers that have not yet been updated to pass runtime + skillDir = path.join(targetDir, 'skills', 'gsd-dev-preferences'); + } const skillFile = path.join(skillDir, 'SKILL.md'); if (fs.existsSync(skillFile)) return false; try { @@ -6182,6 +5697,517 @@ function migrateLegacyDevPreferencesToSkill(targetDir, saved) { } } +// --------------------------------------------------------------------------- +// Phase 2 — Layout-driven install/uninstall orchestrators +// --------------------------------------------------------------------------- + +/** + * Apply per-runtime content rewrites in place across every SKILL.md inside a + * staged directory. Reproduces the rewrite scaffolding that the old + * copyCommandsAsSkills functions applied between read-content and + * converter-call. Applied AFTER stage (which already called the converter); + * rewrites target stable path patterns the converter doesn't touch. + * + * For Qwen/Hermes, branding rewrites (.claude/ → .qwen/ / .hermes/) run + * AFTER the slash-form path replacements but they only catch bare `.claude/` + * patterns (skill-body relative refs) that the slash forms didn't consume. + * This mirrors the exact ordering in the legacy copyCommandsAsClaudeSkills body. + * + * @param {string} stagedDir + * @param {string} runtime + * @param {string} pathPrefix e.g. "~/.codex/" — trailing-slash string + */ +function applyRuntimeContentRewritesInPlace(stagedDir, runtime, pathPrefix) { + if (!fs.existsSync(stagedDir)) return; + + // Walk all SKILL.md files under stagedDir + const walkAndRewrite = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkAndRewrite(fullPath); + } else if (entry.name === 'SKILL.md') { + let content = fs.readFileSync(fullPath, 'utf8'); + content = _applyRuntimeRewrites(content, runtime, pathPrefix); + fs.writeFileSync(fullPath, content); + } + } + }; + walkAndRewrite(stagedDir); +} + +/** + * Apply the per-runtime rewrite table to a single content string. + * Extracted so it can be unit-tested independently of the filesystem walk. + * + * @param {string} content + * @param {string} runtime + * @param {string} pathPrefix trailing-slash string + * @returns {string} + */ +function _applyRuntimeRewrites(content, runtime, pathPrefix) { + const dirName = getDirName(runtime); + const normalizedPathPrefix = pathPrefix.replace(/\/$/, ''); + + switch (runtime) { + case 'codex': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.codex\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'cursor': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.cursor\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'windsurf': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.codeium\/windsurf\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'augment': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.augment\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'trae': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = content.replace(/~\/\.trae\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'codebuddy': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); + content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + content = content.replace(/~\/\.codebuddy\//g, pathPrefix); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'copilot': + // Copilot converter handles path rewrites; only attribution here + content = processAttribution(content, getCommitAttribution('copilot')); + break; + + case 'antigravity': + // Antigravity converter handles path rewrites; only attribution here + content = processAttribution(content, getCommitAttribution('antigravity')); + break; + + case 'claude': + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'qwen': + // Branding rewrites run before path rewrites to avoid consuming + // patterns that the path step would also match. + content = content.replace(/CLAUDE\.md/g, 'QWEN.md'); + content = content.replace(/\bClaude Code\b/g, 'Qwen Code'); + // Base path rewrites (use ~/ and $HOME/ slash forms first — most specific) + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/~\/\.qwen\//g, pathPrefix); + content = content.replace(/\$HOME\/\.qwen\//g, pathPrefix); + // Bare relative .claude/ → .qwen/ (residual refs not matched above) + content = content.replace(/\.claude\//g, '.qwen/'); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/\.\/\.qwen\//g, `./${dirName}/`); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + case 'hermes': + // Branding rewrites run before path rewrites (same rationale as qwen) + content = content.replace(/CLAUDE\.md/g, 'HERMES.md'); + content = content.replace(/\bClaude Code\b/g, 'Hermes Agent'); + // Base path rewrites + content = content.replace(/~\/\.claude\//g, pathPrefix); + content = content.replace(/\$HOME\/\.claude\//g, pathPrefix); + content = content.replace(/~\/\.hermes\//g, pathPrefix); + content = content.replace(/\$HOME\/\.hermes\//g, pathPrefix); + // Bare relative .claude/ → .hermes/ (residual refs) + content = content.replace(/\.claude\//g, '.hermes/'); + content = content.replace(/\.\/\.claude\//g, `./${dirName}/`); + content = content.replace(/\.\/\.hermes\//g, `./${dirName}/`); + content = processAttribution(content, getCommitAttribution(runtime)); + break; + + default: + // Unknown runtime — no rewrites + break; + } + + return content; +} + +/** + * Copy a staged directory's contents into destDir. + * Additive — does not prune (surface.cjs handles pruning). + * + * For skills kind: each child of stagedDir is a `${prefix}${stem}/` dir; copy + * the whole dir into destDir. + * For commands/agents kind: iterate .md files and write them into destDir. + * - commands: write as `${prefix}${stem}.md` unless destSubpath already + * encodes the GSD namespace as its last segment (e.g. `commands/gsd`), in + * which case write as `${stem}.md` (directory IS the namespace). + * - agents: write as-is (files already carry their own `gsd-` prefix). + */ +function _copyStaged(stagedDir, destDir, kind) { + if (!fs.existsSync(stagedDir)) return; + fs.mkdirSync(destDir, { recursive: true }); + + if (kind.kind === 'skills') { + // Each child of stagedDir is a prefixed skill directory: gsd-help/, etc. + for (const entry of fs.readdirSync(stagedDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const src = path.join(stagedDir, entry.name); + const dest = path.join(destDir, entry.name); + fs.cpSync(src, dest, { recursive: true }); + } + return; + } + + // commands or agents + const entries = fs.readdirSync(stagedDir, { withFileTypes: true }); + // For commands: apply prefix unless the destSubpath's last segment already + // represents the GSD namespace (e.g. 'commands/gsd' → last segment 'gsd'). + const destLast = path.basename(kind.destSubpath); + const prefixStem = kind.prefix ? kind.prefix.replace(/-$/, '') : ''; + const namespacedByDir = kind.kind === 'commands' && destLast === prefixStem; + + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); // strip .md + + let destName; + if (kind.kind === 'agents') { + // Agent files already carry the gsd- prefix in the source dir + destName = entry.name; + } else if (namespacedByDir) { + // Directory is the namespace; don't double-prefix the filename + destName = entry.name; + } else { + // Flat commands directory (e.g. command/ for opencode/kilo) + destName = `${kind.prefix}${stem}.md`; + } + + fs.copyFileSync(path.join(stagedDir, entry.name), path.join(destDir, destName)); + } +} + +/** + * Remove GSD-prefixed entries from destDir matching kind.prefix. + * For Hermes nested case (prefix === ''): the destSubpath IS the namespace + * (skills/gsd) — remove the entire destDir. + */ +function _removeGsdEntries(destDir, kind) { + if (!fs.existsSync(destDir)) return; + if (kind.prefix === '') { + // Whole-namespace removal (Hermes nested case — destSubpath is skills/gsd) + // The directory itself is the GSD namespace, so remove it entirely. + fs.rmSync(destDir, { recursive: true, force: true }); + return; + } + for (const entry of fs.readdirSync(destDir, { withFileTypes: true })) { + if (!entry.name.startsWith(kind.prefix)) continue; + fs.rmSync(path.join(destDir, entry.name), { recursive: true, force: true }); + } +} + +/** + * Run legacy install migrations that must execute BEFORE the layout-driven + * copy so stale artifacts are cleaned up before new ones are written. + * + * - Claude/Qwen/Hermes: migrate legacy commands/gsd/dev-preferences.md → + * skills/gsd-dev-preferences/SKILL.md if the old file is present. + * Also removes the legacy commands/gsd/ directory. + * - Hermes: remove flat skills/gsd-STAR directories (pre-2841 layout) before + * writing the new nested skills/gsd/ layout. + * + * @param {string} runtime + * @param {string} configDir resolved runtime config directory + * @param {'global'|'local'} [scope] + */ +function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') { + const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); + + // Claude / Qwen / Hermes: clean up legacy commands/gsd/ and preserve dev-preferences + // for migration. The actual migration call is deferred to after all layout cleanup so + // that for Hermes the flat skills/gsd-*/ removal (below) does not delete the freshly + // created skills/gsd-dev-preferences/ skill dir. + let savedLegacyArtifacts = null; + if (runtime === 'claude' || runtime === 'qwen' || runtime === 'hermes') { + if (fs.existsSync(legacyCommandsGsd)) { + savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); + fs.rmSync(legacyCommandsGsd, { recursive: true }); + } + } + + // Hermes: remove pre-#2841 flat skills/gsd-*/ entries that lived alongside + // the new skills/gsd/ nested layout. + if (runtime === 'hermes') { + const flatSkillsDir = path.join(configDir, 'skills'); + if (fs.existsSync(flatSkillsDir)) { + for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); + } + } + } + + // Hermes: remove intermediate-layout skills/gsd/gsd-*/ entries that existed + // between #2841 and #3664. Phase 2 (#3664) uses prefix='' producing bare-stem + // names (skills/gsd//SKILL.md); the intermediate layout had the gsd- + // prefix inside the nested dir (skills/gsd/gsd-/SKILL.md). Only + // children whose name starts with gsd- are removed — the parent skills/gsd/ + // directory and any non-gsd- siblings (user content) are preserved. + const nestedGsdDir = path.join(configDir, 'skills', 'gsd'); + if (fs.existsSync(nestedGsdDir)) { + for (const entry of fs.readdirSync(nestedGsdDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(nestedGsdDir, entry.name), { recursive: true }); + } + } + } + } + + // Migrate dev-preferences.md content → runtime-aware SKILL.md location (#2973). + // Done after all layout cleanup so Hermes flat-dir removal does not delete the + // newly created skill dir. No-op if skill file already exists. + if (savedLegacyArtifacts) { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + } +} + +/** + * Run legacy uninstall cleanup that must execute BEFORE the layout-driven + * removal so old-format entries are also cleaned up. + * + * - Claude global/Qwen: remove legacy commands/gsd/ directory if present. + * For Claude LOCAL, commands/gsd/ is the current primary location (not + * legacy), so we skip removal here and let _removeGsdEntries handle it + * with gsd- prefix filtering (preserving user files like dev-preferences.md). + * - Hermes: remove pre-2841 flat skills/gsd-STAR entries. + * + * @param {string} runtime + * @param {string} configDir resolved runtime config directory + * @param {'global'|'local'} [scope] + */ +function _runLegacyUninstallCleanup(runtime, configDir, scope = 'global') { + // Claude global / Qwen: commands/gsd/ is a legacy location (global Claude + // uses skills/ now; Qwen always uses skills/). Remove whole directory. + // Claude local: commands/gsd/ is the primary current location — skip here, + // let layout's _removeGsdEntries handle gsd-prefixed file removal. + // #2973 / Codex review (bd1f06c9): preserve user-owned dev-preferences.md + // before destructive wipe. Migration to skills/gsd-dev-preferences/SKILL.md + // is deferred and returned so the caller can apply it AFTER layout-driven + // removal — this prevents the layout's gsd-* prefix removal from wiping the + // freshly created skill dir (same pattern as _runLegacyInstallMigrations). + let savedLegacyArtifacts = null; + // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude-global. + // Claude-local commands/gsd/ is the primary current location — skip here. + const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global'); + if (isLegacyCommandsGsd) { + const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); + if (fs.existsSync(legacyCommandsGsd)) { + savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsGsd, ['dev-preferences.md']); + fs.rmSync(legacyCommandsGsd, { recursive: true }); + } + } + + // Hermes: pre-#2841 flat skills/gsd-*/ entries + if (runtime === 'hermes') { + const flatSkillsDir = path.join(configDir, 'skills'); + if (fs.existsSync(flatSkillsDir)) { + for (const entry of fs.readdirSync(flatSkillsDir, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name.startsWith('gsd-')) { + fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); + } + } + } + } + + // Return saved artifacts so the caller can migrate after layout-driven removal. + return savedLegacyArtifacts; +} + +/** + * Layout-driven install orchestrator. + * Runs legacy migrations first, then uses resolveRuntimeArtifactLayout to + * determine what artifact kinds to write and where. + * + * @param {string} runtime canonical runtime ID + * @param {string} configDir resolved runtime config directory + * @param {'global'|'local'} scope + * @param {Object} resolvedProfile from resolveProfile() / resolveEffectiveProfile() + */ +/** + * Deep-snapshot a directory tree into a Map. + * Returns an empty Map if the directory doesn't exist. + * @param {string} dir + * @returns {Map} + */ +function _snapshotDir(dir) { + const files = new Map(); + if (!fs.existsSync(dir)) return files; + const walk = (relPath, absPath) => { + for (const e of fs.readdirSync(absPath, { withFileTypes: true })) { + const childRel = relPath ? path.join(relPath, e.name) : e.name; + const childAbs = path.join(absPath, e.name); + if (e.isDirectory()) walk(childRel, childAbs); + else if (e.isFile()) files.set(childRel, fs.readFileSync(childAbs)); + } + }; + walk('', dir); + return files; +} + +/** + * Restore a directory tree from a Map produced by _snapshotDir. + * @param {string} dir + * @param {Map} snapshot + */ +function _restoreDir(dir, snapshot) { + for (const [relPath, buf] of snapshot) { + const absPath = path.join(dir, relPath); + fs.mkdirSync(path.dirname(absPath), { recursive: true }); + fs.writeFileSync(absPath, buf); + } +} + +function installRuntimeArtifacts(runtime, configDir, scope, resolvedProfile) { + // Legacy cleanup before layout-driven writes + _runLegacyInstallMigrations(runtime, configDir, scope); + + const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope); + + // Compute pathPrefix once for the rewrite step (same derivation as the + // top-level install() function). + const _resolvedTarget = path.resolve(configDir).replace(/\\/g, '/'); + const _homeDir = os.homedir().replace(/\\/g, '/'); + const pathPrefix = computePathPrefix({ + isGlobal: scope === 'global', + isOpencode: runtime === 'opencode', + isWindowsHost: process.platform === 'win32', + resolvedTarget: _resolvedTarget, + homeDir: _homeDir, + }); + + for (const kind of layout.kinds) { + const staged = kind.stage(resolvedProfile); + if (kind.kind === 'skills') { + applyRuntimeContentRewritesInPlace(staged, runtime, pathPrefix); + } + const dest = path.join(layout.configDir, kind.destSubpath); + fs.mkdirSync(dest, { recursive: true }); + + if (kind.kind === 'skills' && fs.existsSync(dest)) { + // Pre-prune: snapshot user-owned content before _removeGsdEntries wipes it, + // then restore after. This preserves user dirs across a wipe-and-replace + // install (#2973 / #3664). + // + // For prefix='' (Hermes): _removeGsdEntries wipes the entire dest dir (skills/gsd/). + // Preserve every subdir that is NOT in the staged set — those are user-added dirs + // (e.g. user-content/) that GSD does not manage. + // + // For prefix='gsd-' (others): _removeGsdEntries removes only gsd-* entries. + // Non-gsd-* user dirs (e.g. my-custom-skill/) are untouched. Only preserve the + // explicit user-owned GSD-prefixed skill gsd-dev-preferences, which GSD does not + // reinstall from source but must survive the prune (#2973). + const toPreserve = new Map(); // dirName -> Map + + if (kind.prefix === '') { + // Hermes: wipes entire dest dir — preserve anything not in staged. + const stagedNames = fs.existsSync(staged) + ? new Set(fs.readdirSync(staged, { withFileTypes: true }) + .filter(e => e.isDirectory()).map(e => e.name)) + : new Set(); + for (const entry of fs.readdirSync(dest, { withFileTypes: true })) { + if (!entry.isDirectory() || stagedNames.has(entry.name)) continue; + const snap = _snapshotDir(path.join(dest, entry.name)); + if (snap.size > 0) toPreserve.set(entry.name, snap); + } + } else { + // Non-Hermes: only preserve explicitly user-owned GSD-prefixed skill dirs. + // gsd-dev-preferences is the sole user-customisable skill in this category. + const USER_OWNED_SKILL_DIRS = ['gsd-dev-preferences']; + for (const dirName of USER_OWNED_SKILL_DIRS) { + const skillDir = path.join(dest, dirName); + if (!fs.existsSync(skillDir)) continue; + const snap = _snapshotDir(skillDir); + if (snap.size > 0) toPreserve.set(dirName, snap); + } + } + + _removeGsdEntries(dest, kind); + _copyStaged(staged, dest, kind); + + // Restore user-owned dirs after the prune+copy + for (const [dirName, snap] of toPreserve) { + _restoreDir(path.join(dest, dirName), snap); + } + } else { + // For non-skills kinds (commands, agents): no user content to preserve; + // just prune stale gsd-* entries and copy new ones. + _removeGsdEntries(dest, kind); + _copyStaged(staged, dest, kind); + } + } +} + +/** + * Layout-driven uninstall orchestrator. + * Runs legacy cleanup first, then uses resolveRuntimeArtifactLayout to + * determine which GSD-owned entries to remove. + * + * @param {string} runtime canonical runtime ID + * @param {string} configDir resolved runtime config directory + * @param {'global'|'local'} scope + */ +function uninstallRuntimeArtifacts(runtime, configDir, scope) { + // Legacy cleanup before layout-driven removal (scope-aware to avoid + // removing Claude local commands/gsd/ which is the primary install dir). + // Returns saved user artifacts so we can migrate AFTER layout removal + // (the layout's gsd-* prefix pass would wipe a skill dir created here). + const savedLegacyArtifacts = _runLegacyUninstallCleanup(runtime, configDir, scope); + + const layout = resolveRuntimeArtifactLayout(runtime, configDir, scope); + for (const kind of layout.kinds) { + const dest = path.join(layout.configDir, kind.destSubpath); + _removeGsdEntries(dest, kind); + } + + // #2973 / Codex review (bd1f06c9): migrate dev-preferences.md to the + // runtime-aware SKILL.md location after all layout-driven removal is + // complete. Do NOT restore to commands/gsd/ — the user is uninstalling. + if (savedLegacyArtifacts) { + migrateLegacyDevPreferencesToSkill(configDir, savedLegacyArtifacts, runtime, scope); + } +} + /** * Recursively copy directory, replacing paths in .md files * Deletes existing destDir first to remove orphaned files from previous versions @@ -6549,98 +6575,54 @@ function uninstall(isGlobal, runtime = 'claude') { removedCount++; } catch {} - // 1. Remove GSD commands/skills - if (isOpencode || isKilo) { - // OpenCode/Kilo: remove command/gsd-*.md files - const commandDir = path.join(targetDir, 'command'); - if (fs.existsSync(commandDir)) { - const files = fs.readdirSync(commandDir); - for (const file of files) { - if (file.startsWith('gsd-') && file.endsWith('.md')) { - fs.unlinkSync(path.join(commandDir, file)); - removedCount++; + // 1. Remove GSD commands/skills (layout-driven) + const scope = isGlobal ? 'global' : 'local'; + uninstallRuntimeArtifacts(runtime, targetDir, scope); + removedCount++; + + // 1a. Non-layout Codex side-effects: agent .toml files, config.toml sections, hooks.json + if (isCodex) { + const codexAgentsDir = path.join(targetDir, 'agents'); + if (fs.existsSync(codexAgentsDir)) { + const tomlFiles = fs.readdirSync(codexAgentsDir); + let tomlCount = 0; + for (const file of tomlFiles) { + if (file.startsWith('gsd-') && file.endsWith('.toml')) { + fs.unlinkSync(path.join(codexAgentsDir, file)); + tomlCount++; } } - console.log(` ${green}✓${reset} Removed GSD commands from command/`); - } - } else if (isCodex || isCursor || isWindsurf || isTrae || isCodebuddy) { - // Codex/Cursor/Windsurf/Trae/CodeBuddy: remove skills/gsd-*/SKILL.md skill directories - const skillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(skillsDir)) { - let skillCount = 0; - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - if (skillCount > 0) { + if (tomlCount > 0) { removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} ${runtimeLabel} skills`); + console.log(` ${green}✓${reset} Removed ${tomlCount} agent .toml configs`); } } - // Codex-only: remove GSD agent .toml config files and config.toml sections - if (isCodex) { - const codexAgentsDir = path.join(targetDir, 'agents'); - if (fs.existsSync(codexAgentsDir)) { - const tomlFiles = fs.readdirSync(codexAgentsDir); - let tomlCount = 0; - for (const file of tomlFiles) { - if (file.startsWith('gsd-') && file.endsWith('.toml')) { - fs.unlinkSync(path.join(codexAgentsDir, file)); - tomlCount++; - } - } - if (tomlCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${tomlCount} agent .toml configs`); - } - } - - // Codex: clean GSD sections from config.toml - const configPath = path.join(targetDir, 'config.toml'); - if (fs.existsSync(configPath)) { - const content = fs.readFileSync(configPath, 'utf8'); - const cleaned = stripGsdFromCodexConfig(content); - if (cleaned === null) { - // File is empty after stripping — delete it - fs.unlinkSync(configPath); - removedCount++; - console.log(` ${green}✓${reset} Removed config.toml (was GSD-only)`); - } else if (cleaned !== content) { - fs.writeFileSync(configPath, cleaned); - removedCount++; - console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); - } - } - - const hooksJsonCleanup = removeCodexHooksJsonSessionStart(targetDir); - if (hooksJsonCleanup.changed) { + // Codex: clean GSD sections from config.toml + const codexConfigPath = path.join(targetDir, 'config.toml'); + if (fs.existsSync(codexConfigPath)) { + const content = fs.readFileSync(codexConfigPath, 'utf8'); + const cleaned = stripGsdFromCodexConfig(content); + if (cleaned === null) { + fs.unlinkSync(codexConfigPath); removedCount++; - console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`); - } - } - } else if (isCopilot) { - // Copilot: remove skills/gsd-*/ directories (same layout as Codex skills) - const skillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(skillsDir)) { - let skillCount = 0; - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - if (skillCount > 0) { + console.log(` ${green}✓${reset} Removed config.toml (was GSD-only)`); + } else if (cleaned !== content) { + fs.writeFileSync(codexConfigPath, cleaned); removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Copilot skills`); + console.log(` ${green}✓${reset} Cleaned GSD sections from config.toml`); } } - // Copilot: clean GSD section from copilot-instructions.md + const hooksJsonCleanup = removeCodexHooksJsonSessionStart(targetDir); + if (hooksJsonCleanup.changed) { + removedCount++; + console.log(` ${green}✓${reset} Removed managed Codex SessionStart hook from hooks.json`); + } + } + + // 1b. Non-layout Copilot side-effect: copilot-instructions.md cleanup + if (isCopilot) { const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); if (fs.existsSync(instructionsPath)) { const content = fs.readFileSync(instructionsPath, 'utf8'); @@ -6655,115 +6637,21 @@ function uninstall(isGlobal, runtime = 'claude') { console.log(` ${green}✓${reset} Cleaned GSD section from copilot-instructions.md`); } } - } else if (isAntigravity) { - // Antigravity: remove skills/gsd-*/ directories (same layout as Copilot skills) - const skillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(skillsDir)) { - let skillCount = 0; - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - if (skillCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Antigravity skills`); - } - } - } else if (isQwen) { - const skillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(skillsDir)) { - let skillCount = 0; - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - if (skillCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Qwen Code skills`); - } - } + } - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); - fs.rmSync(legacyCommandsDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); - // #2973: also migrate dev-preferences.md content into the new - // skills/gsd-dev-preferences/SKILL.md location (skills-aware runtimes). - // This prevents the legacy file from being orphaned after the writer - // starts targeting the skills path. No-op if SKILL.md already exists. - restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts)) { - console.log(` ${green}✓${reset} Migrated dev-preferences.md → skills/gsd-dev-preferences/SKILL.md (#2973)`); - } - } - } else if (isHermes) { - // Hermes Agent: skills live under skills/gsd/ as a single category (per - // spec in #2841). Remove the whole gsd/ category directory; also clean up - // any pre-nested-layout flat skills/gsd-*/ left over from older installs. - const skillsDir = path.join(targetDir, 'skills'); - let skillCount = 0; - const nestedCategoryDir = path.join(skillsDir, 'gsd'); - if (fs.existsSync(nestedCategoryDir)) { - const entries = fs.readdirSync(nestedCategoryDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - skillCount++; - } - } - fs.rmSync(nestedCategoryDir, { recursive: true }); - } - if (fs.existsSync(skillsDir)) { - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - } - if (skillCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Hermes Agent skills`); - } - - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); - fs.rmSync(legacyCommandsDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); - // #2973: also migrate dev-preferences.md content into the new - // skills/gsd-dev-preferences/SKILL.md location (skills-aware runtimes). - // This prevents the legacy file from being orphaned after the writer - // starts targeting the skills path. No-op if SKILL.md already exists. - restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts)) { - console.log(` ${green}✓${reset} Migrated dev-preferences.md → skills/gsd-dev-preferences/SKILL.md (#2973)`); - } - } - } else if (isGemini) { - // Gemini: still uses commands/gsd/ + // 1c. Claude local: remove commands/gsd/ (primary local install location). + // The layout's _removeGsdEntries uses the 'gsd-' prefix which applies to + // flat command dirs (OpenCode/Kilo). Claude local files use no prefix inside + // the namespaced directory, so layout does not remove them. Handle inline. + // Preserve dev-preferences.md across the wipe (#1423). + if (!isGlobal && runtime === 'claude') { const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { - // Preserve user-generated files before wipe (#1423) - // Note: if more user files are added, consider a naming convention (e.g., USER-*.md) - // and preserve all matching files instead of listing each one individually. const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md'); const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; - fs.rmSync(gsdCommandsDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed commands/gsd/`); - - // Restore user-generated files if (preservedDevPrefs) { try { fs.mkdirSync(gsdCommandsDir, { recursive: true }); @@ -6774,57 +6662,19 @@ function uninstall(isGlobal, runtime = 'claude') { } } } - } else if (isGlobal) { - // Claude Code global: remove skills/gsd-*/ directories (primary global install location) - const skillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(skillsDir)) { - let skillCount = 0; - const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); - for (const entry of entries) { - if (entry.isDirectory() && entry.name.startsWith('gsd-')) { - fs.rmSync(path.join(skillsDir, entry.name), { recursive: true }); - skillCount++; - } - } - if (skillCount > 0) { - removedCount++; - console.log(` ${green}✓${reset} Removed ${skillCount} Claude Code skills`); - } - } + } - // Also clean up legacy commands/gsd/ from older global installs - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - // Preserve user-generated files before legacy wipe (#1423) - const devPrefsPath = path.join(legacyCommandsDir, 'dev-preferences.md'); - const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; - - fs.rmSync(legacyCommandsDir, { recursive: true }); - removedCount++; - console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); - - if (preservedDevPrefs) { - try { - fs.mkdirSync(legacyCommandsDir, { recursive: true }); - fs.writeFileSync(devPrefsPath, preservedDevPrefs); - console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); - } catch (err) { - console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); - } - } - } - } else { - // Claude Code local: remove commands/gsd/ (primary local install location since #1736) + // 1d. Gemini: remove commands/gsd/ with dev-preferences.md preservation. + // The layout removes gsd-*.toml files but not the directory itself. + // Preserve user files before removing the directory. + if (isGemini) { const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); if (fs.existsSync(gsdCommandsDir)) { - // Preserve user-generated files before wipe (#1423) const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md'); const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; - fs.rmSync(gsdCommandsDir, { recursive: true }); removedCount++; console.log(` ${green}✓${reset} Removed commands/gsd/`); - if (preservedDevPrefs) { try { fs.mkdirSync(gsdCommandsDir, { recursive: true }); @@ -6837,6 +6687,37 @@ function uninstall(isGlobal, runtime = 'claude') { } } + // 1d. Qwen/Hermes: migrate dev-preferences.md from legacy commands/gsd/ location + // during uninstall. _runLegacyUninstallCleanup (called by uninstallRuntimeArtifacts) + // removes the directory; we must preserve/restore user artifacts before that path. + // This block runs AFTER uninstallRuntimeArtifacts, so we check if the directory + // was already removed and skip if so (idempotent). + if (isQwen || isHermes) { + // dev-preferences may have survived in skills/ as SKILL.md — nothing to do for + // that case. If a stale commands/gsd/ still exists (e.g. legacy was not removed), + // attempt migration. In practice _runLegacyUninstallCleanup removes it first, + // so this is a best-effort guard. + const legacyDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyDir)) { + const savedLegacyArtifacts = preserveUserArtifacts(legacyDir, ['dev-preferences.md']); + fs.rmSync(legacyDir, { recursive: true }); + removedCount++; + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + const _uninstallScope = isGlobal ? 'global' : 'local'; + if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts, runtime, _uninstallScope)) { + // Compute the actual path written so the log line is accurate per-runtime + const _layout = resolveRuntimeArtifactLayout(runtime, targetDir, _uninstallScope); + const _sk = _layout.kinds.find((k) => k.kind === 'skills'); + const _stem = _sk && _sk.prefix === '' ? 'dev-preferences' : 'gsd-dev-preferences'; + const _skillRelPath = _sk ? `${_sk.destSubpath}/${_stem}/SKILL.md` : 'skills/gsd-dev-preferences/SKILL.md'; + console.log(` ${green}✓${reset} Migrated dev-preferences.md → ${_skillRelPath} (#2973)`); + } else { + // Migration failed or already exists — restore to legacy location so user content is not lost + restoreUserArtifacts(legacyDir, savedLegacyArtifacts); + } + } + } + // 2. Remove get-shit-done directory const gsdDir = path.join(targetDir, 'get-shit-done'); if (fs.existsSync(gsdDir)) { @@ -7499,7 +7380,9 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { } } if ((isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || isTrae || (!isOpencode && !isGemini)) && fs.existsSync(codexSkillsDir)) { - for (const skillName of listCodexSkillNames(codexSkillsDir)) { + // Hermes uses prefix '' (bare stem names); all others use 'gsd-' + const skillListPrefix = isHermes ? '' : 'gsd-'; + for (const skillName of listCodexSkillNames(codexSkillsDir, skillListPrefix)) { const skillRoot = path.join(codexSkillsDir, skillName); const skillHashes = generateManifest(skillRoot); for (const [rel, hash] of Object.entries(skillHashes)) { @@ -7925,15 +7808,16 @@ function install(isGlobal, runtime = 'claude', options = {}) { const _isCoreProfileAlias = _activeProfileName === 'core'; const _effectiveInstallMode = _isCoreProfileAlias ? 'minimal' : 'full'; // Load the manifest and compute resolved profile for named profiles. - // --minimal keeps its own staging path via _stageSkillsFn (see below). + // For --minimal/core: use an empty manifest (core profile has no transitive + // deps) to produce a resolvedProfile with the core skill set. This allows + // installRuntimeArtifacts to use stageSkillsForProfile uniformly across all + // profile modes without a null sentinel. const _commandsDir = path.join(src, 'commands', 'gsd'); const _skillsManifest = _isCoreProfileAlias ? new Map() : loadSkillsManifest(_commandsDir); - const _resolvedProfile = _isCoreProfileAlias - ? null // --minimal uses stageSkillsForMode at dispatch sites - : resolveProfile({ - modes: [_activeProfileName], - manifest: _skillsManifest, - }); + const _resolvedProfile = resolveProfile({ + modes: [_activeProfileName], + manifest: _skillsManifest, + }); // Unified staging function: for --minimal uses stageSkillsForMode (back-compat); // for named profiles uses stageSkillsForProfile (new API with transitive closure). function _stageSkills(commandsGsdDir) { @@ -8238,8 +8122,69 @@ function install(isGlobal, runtime = 'claude', options = {}) { reportInstallerMigrationResult(installerMigrationResult); assertInstallerMigrationsUnblocked(installerMigrationResult); - // OpenCode/Kilo use command/ (flat), Codex uses skills/, Claude/Gemini use commands/gsd/ - if (isOpencode || isKilo) { + // Artifact install dispatcher — routes to layout-driven path for all + // skills-based runtimes (both full and minimal/core profiles); keeps + // back-compat paths for commands-based runtimes (OpenCode/Kilo/Gemini/ + // Claude-local). + // + // installRuntimeArtifacts handles legacy migration + skill/agent staging + // via layout kinds for all profile modes. _resolvedProfile already reflects + // the user's --profile=core / --minimal choice. + // + // Non-layout side-effects preserved inline: + // Hermes: writeHermesCategoryDescription (not a layout kind) + // Cline: no-op (cline layout has empty kinds[]) + // Gemini: conflict-detection logic (not expressible in layout) + // OpenCode/Kilo: copyFlattenedCommands (frontmatter conversion not in commandsKind) + // Claude local: copyWithPathReplacement + stale-skills cleanup + + // Layout-driven path for all skills-based runtimes (full and minimal modes). + // applyRuntimeContentRewritesInPlace (called inside installRuntimeArtifacts) + // handles per-runtime path + branding rewrites, including Qwen/Hermes. + const _isSkillsRuntime = isCodex || isCopilot || isAntigravity || isCursor || isWindsurf || + isAugment || isTrae || isCodebuddy || isQwen || isHermes || + (runtime === 'claude' && isGlobal); + + if (_isSkillsRuntime) { + // Layout-driven install for skills-based runtimes (full and minimal modes) + const scope = isGlobal ? 'global' : 'local'; + installRuntimeArtifacts(runtime, targetDir, scope, _resolvedProfile); + + // Hermes only: write DESCRIPTION.md for the gsd/ category after layout install + if (isHermes) { + writeHermesCategoryDescription(path.join(targetDir, 'skills', 'gsd')); + } + + // Verify installed artifacts and report + if (isHermes) { + const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd'); + if (fs.existsSync(hermesSkillsDir)) { + // Hermes layout uses prefix: '' — skill dirs have bare stem names (no gsd- prefix) + const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory()).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`); + } else { + failures.push('skills/gsd/*'); + } + } else { + failures.push('skills/gsd/*'); + } + } else { + const skillsDir = path.join(targetDir, 'skills'); + if (fs.existsSync(skillsDir)) { + const count = fs.readdirSync(skillsDir, { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + if (count > 0) { + console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); + } else { + failures.push('skills/gsd-*'); + } + } else { + failures.push('skills/gsd-*'); + } + } + } else if (isOpencode || isKilo) { // OpenCode/Kilo: flat structure in command/ directory const commandDir = path.join(targetDir, 'command'); fs.mkdirSync(commandDir, { recursive: true }); @@ -8253,188 +8198,6 @@ function install(isGlobal, runtime = 'claude', options = {}) { } else { failures.push('command/gsd-*'); } - } else if (isCodex) { - // Codex CLI (0.130.0 at time of #3562) does NOT auto-discover commands - // from get-shit-done/workflows/*.md or agents/*.md. It only registers - // commands from skills//SKILL.md. The earlier "Codex discovers - // official skills directly" branch left users with workflows on disk and - // no $gsd-* entrypoints. Regenerate the skill surface the same way the - // other runtimes do — copyCommandsAsCodexSkills() rewrites each - // commands/gsd/*.md as ~/.codex/skills/gsd-/SKILL.md and converts - // Claude-flavored command frontmatter into Codex skill frontmatter. - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsCodexSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else { - failures.push('skills/gsd-*'); - } - } else if (isCopilot) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsCopilotSkills(gsdSrc, skillsDir, 'gsd', isGlobal); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else { - failures.push('skills/gsd-*'); - } - } else if (isAntigravity) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsAntigravitySkills(gsdSrc, skillsDir, 'gsd', isGlobal); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else { - failures.push('skills/gsd-*'); - } - } else if (isCursor) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsCursorSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure - if (installedSkillNames.length > 0) { - console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else if (isWindsurf) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsWindsurfSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - const installedSkillNames = listCodexSkillNames(skillsDir); // reuse — same dir structure - if (installedSkillNames.length > 0) { - console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else if (isAugment) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsAugmentSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - const installedSkillNames = listCodexSkillNames(skillsDir); - if (installedSkillNames.length > 0) { - console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else if (isTrae) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsTraeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - const installedSkillNames = listCodexSkillNames(skillsDir); - if (installedSkillNames.length > 0) { - console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else if (isQwen) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else { - failures.push('skills/gsd-*'); - } - - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); - fs.rmSync(legacyCommandsDir, { recursive: true }); - console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); - // #2973: also migrate dev-preferences.md content into the new - // skills/gsd-dev-preferences/SKILL.md location (skills-aware runtimes). - // This prevents the legacy file from being orphaned after the writer - // starts targeting the skills path. No-op if SKILL.md already exists. - restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts)) { - console.log(` ${green}✓${reset} Migrated dev-preferences.md → skills/gsd-dev-preferences/SKILL.md (#2973)`); - } - } - } else if (isHermes) { - // Hermes Agent: nests all GSD skills under skills/gsd/ as a single - // category (per spec in #2841) so the 86 gsd-* skills collapse into a - // single entry in Hermes' system prompt instead of 86 top-level entries. - // The Claude skill pipeline writes each gsd-/SKILL.md inside the - // gsd/ category dir, alongside a DESCRIPTION.md that Hermes uses as the - // category summary. - const hermesSkillsDir = path.join(targetDir, 'skills', 'gsd'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsClaudeSkills(gsdSrc, hermesSkillsDir, 'gsd', pathPrefix, runtime, isGlobal); - writeHermesCategoryDescription(hermesSkillsDir); - if (fs.existsSync(hermesSkillsDir)) { - const count = fs.readdirSync(hermesSkillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/gsd/`); - } else { - failures.push('skills/gsd/gsd-*'); - } - } else { - failures.push('skills/gsd/gsd-*'); - } - - // Migrate any prior flat-layout install (skills/gsd-*/) into the nested - // skills/gsd/ category — keeps existing users from carrying duplicates - // after upgrading to the nested layout. - const flatSkillsDir = path.join(targetDir, 'skills'); - if (fs.existsSync(flatSkillsDir)) { - const stale = fs.readdirSync(flatSkillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')); - for (const entry of stale) { - fs.rmSync(path.join(flatSkillsDir, entry.name), { recursive: true }); - } - } - - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); - fs.rmSync(legacyCommandsDir, { recursive: true }); - console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); - // #2973: also migrate dev-preferences.md content into the new - // skills/gsd-dev-preferences/SKILL.md location (skills-aware runtimes). - // This prevents the legacy file from being orphaned after the writer - // starts targeting the skills path. No-op if SKILL.md already exists. - restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts)) { - console.log(` ${green}✓${reset} Migrated dev-preferences.md → skills/gsd-dev-preferences/SKILL.md (#2973)`); - } - } - } else if (isCodebuddy) { - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsCodebuddySkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime); - const installedSkillNames = listCodexSkillNames(skillsDir); - if (installedSkillNames.length > 0) { - console.log(` ${green}✓${reset} Installed ${installedSkillNames.length} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } } else if (isCline) { // Cline is rules-based — commands are embedded in .clinerules (generated below). // No skills/commands directory needed. Engine is installed via copyWithPathReplacement. @@ -8490,39 +8253,6 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('commands/gsd'); } } - } else if (isGlobal) { - // Claude Code global: skills/ format (2.1.88+ compatibility) - const skillsDir = path.join(targetDir, 'skills'); - const gsdSrc = _stageSkills(_commandsDir); - copyCommandsAsClaudeSkills(gsdSrc, skillsDir, 'gsd', pathPrefix, runtime, isGlobal); - if (fs.existsSync(skillsDir)) { - const count = fs.readdirSync(skillsDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; - if (count > 0) { - console.log(` ${green}✓${reset} Installed ${count} skills to skills/`); - } else { - failures.push('skills/gsd-*'); - } - } else { - failures.push('skills/gsd-*'); - } - - // Clean up legacy commands/gsd/ from previous global installs - // Preserve user-generated files (dev-preferences.md) before wiping the directory - const legacyCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(legacyCommandsDir)) { - const savedLegacyArtifacts = preserveUserArtifacts(legacyCommandsDir, ['dev-preferences.md']); - fs.rmSync(legacyCommandsDir, { recursive: true }); - console.log(` ${green}✓${reset} Removed legacy commands/gsd/ directory`); - // #2973: also migrate dev-preferences.md content into the new - // skills/gsd-dev-preferences/SKILL.md location (skills-aware runtimes). - // This prevents the legacy file from being orphaned after the writer - // starts targeting the skills path. No-op if SKILL.md already exists. - restoreUserArtifacts(legacyCommandsDir, savedLegacyArtifacts); - if (migrateLegacyDevPreferencesToSkill(targetDir, savedLegacyArtifacts)) { - console.log(` ${green}✓${reset} Migrated dev-preferences.md → skills/gsd-dev-preferences/SKILL.md (#2973)`); - } - } } else { // Claude Code local: commands/gsd/ format — Claude Code reads local project // commands from .claude/commands/gsd/, not .claude/skills/ @@ -11320,9 +11050,10 @@ function installAllRuntimes(runtimes, isGlobal, isInteractive) { } } -// Test-only exports — skip main logic when loaded as a module for testing -if (process.env.GSD_TEST_MODE) { - module.exports = { +// Always export so runtime-artifact-layout.cjs's lazy loader can access +// converter functions when called from within the CLI path (circular require). +// The main() block below is gated on !GSD_TEST_MODE, as before. +module.exports = { yamlIdentifier, computePathPrefix, getCodexSkillAdapterHeader, @@ -11372,7 +11103,6 @@ if (process.env.GSD_TEST_MODE) { convertClaudeToCopilotContent, convertClaudeCommandToCopilotSkill, convertClaudeAgentToCopilotAgent, - copyCommandsAsCopilotSkills, GSD_COPILOT_INSTRUCTIONS_MARKER, GSD_COPILOT_INSTRUCTIONS_CLOSE_MARKER, mergeCopilotInstructions, @@ -11380,26 +11110,20 @@ if (process.env.GSD_TEST_MODE) { convertClaudeToAntigravityContent, convertClaudeCommandToAntigravitySkill, convertClaudeAgentToAntigravityAgent, - copyCommandsAsAntigravitySkills, convertClaudeCommandToClaudeSkill, skillFrontmatterName, - copyCommandsAsClaudeSkills, convertClaudeToWindsurfMarkdown, convertClaudeCommandToWindsurfSkill, convertClaudeAgentToWindsurfAgent, - copyCommandsAsWindsurfSkills, convertClaudeToAugmentMarkdown, convertClaudeCommandToAugmentSkill, convertClaudeAgentToAugmentAgent, - copyCommandsAsAugmentSkills, convertClaudeToTraeMarkdown, convertClaudeCommandToTraeSkill, convertClaudeAgentToTraeAgent, - copyCommandsAsTraeSkills, convertClaudeToCodebuddyMarkdown, convertClaudeCommandToCodebuddySkill, convertClaudeAgentToCodebuddyAgent, - copyCommandsAsCodebuddySkills, convertClaudeToCliineMarkdown, convertClaudeAgentToClineAgent, writeManifest, @@ -11437,10 +11161,12 @@ if (process.env.GSD_TEST_MODE) { buildCodexHookBlock, rewriteLegacyCodexHookBlock, readGsdCommandNames, + installRuntimeArtifacts, + uninstallRuntimeArtifacts, }; -} else { - // Main logic +// Main logic — only run when not loaded as a module for testing +if (!process.env.GSD_TEST_MODE) { if (hasSkillsRoot) { // Print the skills root directory for a given runtime (used by /gsd-sync-skills). // Usage: node install.js --skills-root @@ -11492,4 +11218,4 @@ if (process.env.GSD_TEST_MODE) { } } -} // end of else block for GSD_TEST_MODE +} // end of !GSD_TEST_MODE main logic block diff --git a/tests/antigravity-install.test.cjs b/tests/antigravity-install.test.cjs index 0a8ed5fa9..8bbcc5039 100644 --- a/tests/antigravity-install.test.cjs +++ b/tests/antigravity-install.test.cjs @@ -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: /skills/gsd-/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'); }); diff --git a/tests/bug-1736-local-install-commands.test.cjs b/tests/bug-1736-local-install-commands.test.cjs index fd33861fe..f1f0b2b05 100644 --- a/tests/bug-1736-local-install-commands.test.cjs +++ b/tests/bug-1736-local-install-commands.test.cjs @@ -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 ──────────────────── diff --git a/tests/bug-1924-preserve-user-artifacts.test.cjs b/tests/bug-1924-preserve-user-artifacts.test.cjs index 3ffddacfd..59269064c 100644 --- a/tests/bug-1924-preserve-user-artifacts.test.cjs +++ b/tests/bug-1924-preserve-user-artifacts.test.cjs @@ -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), diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index 6b387191f..67f48d183 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -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 diff --git a/tests/bug-2973-profile-user-skills-path.test.cjs b/tests/bug-2973-profile-user-skills-path.test.cjs index 0e61903ba..61609cacf 100644 --- a/tests/bug-2973-profile-user-skills-path.test.cjs +++ b/tests/bug-2973-profile-user-skills-path.test.cjs @@ -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 { diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index aaef37dd1..55e8f9e6b 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -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)', () => { '', '', '@~/.claude/get-shit-done/workflows/manager.md', - '@~/.claude/get-shit-done/references/ui-brand.md', + '@$HOME/.claude/get-shit-done/references/ui-brand.md', '', - ].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'); }); }); diff --git a/tests/codebuddy-install.test.cjs b/tests/codebuddy-install.test.cjs index f87e44a13..84ef40128 100644 --- a/tests/codebuddy-install.test.cjs +++ b/tests/codebuddy-install.test.cjs @@ -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: /skills/gsd-/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'); diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 40abb3eb0..872e80223 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -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: /skills/gsd-/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'); }); }); diff --git a/tests/hermes-install.test.cjs b/tests/hermes-install.test.cjs index 13d2395a5..6d7fc7469 100644 --- a/tests/hermes-install.test.cjs +++ b/tests/hermes-install.test.cjs @@ -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//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'); diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index 69e2bd7b9..c35da1378 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -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', () => { 'Quick task body', ].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//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(''), '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//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//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//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//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'); }); diff --git a/tests/install-hermes-regressions.test.cjs b/tests/install-hermes-regressions.test.cjs new file mode 100644 index 000000000..bd36810e9 --- /dev/null +++ b/tests/install-hermes-regressions.test.cjs @@ -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-/ 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/.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-/ 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 /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//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//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)}`, + ); + }); +}); diff --git a/tests/install-uninstall-layout-loop.test.cjs b/tests/install-uninstall-layout-loop.test.cjs new file mode 100644 index 000000000..31e30b5ea --- /dev/null +++ b/tests/install-uninstall-layout-loop.test.cjs @@ -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 //SKILL.md +// For commands runtimes: creates /.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 /skills/ + for (const runtime of SKILLS_RUNTIMES) { + test(`${runtime}: installs gsd-prefixed skill dirs into /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//SKILL.md, prefix is '' + test('hermes: nested layout — skills/gsd//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/.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-.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// 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//) 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' + ); + }); +}); diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index 5f824ad9a..36450621d 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -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' diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs index 25a5c2f7c..31e13a485 100644 --- a/tests/qwen-skills-migration.test.cjs +++ b/tests/qwen-skills-migration.test.cjs @@ -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', () => { 'Quick task body', ].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-/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(''), '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'); }); }); diff --git a/tests/trae-install.test.cjs b/tests/trae-install.test.cjs index f329981a4..bb2cb7971 100644 --- a/tests/trae-install.test.cjs +++ b/tests/trae-install.test.cjs @@ -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');