diff --git a/.changeset/1367-claude-local-flat-command-layout.md b/.changeset/1367-claude-local-flat-command-layout.md new file mode 100644 index 000000000..2f52fa98e --- /dev/null +++ b/.changeset/1367-claude-local-flat-command-layout.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1367 +--- +**Project-local Claude Code install now produces `/gsd-` (hyphen) slash commands** — the installer was writing command files to `.claude/commands/gsd/.md` (subdirectory with bare names), causing Claude Code to namespace them as `/gsd:` (colon form). The fix writes flat `gsd-.md` files at `.claude/commands/` level so Claude Code registers `/gsd-` (hyphen form), matching hooks, statusline, and all cross-command references. Legacy `commands/gsd/` directories from prior installs are cleaned up on reinstall and uninstall, with `dev-preferences.md` preserved. (#1367) diff --git a/bin/install.js b/bin/install.js index 8b982f426..994d723ae 100755 --- a/bin/install.js +++ b/bin/install.js @@ -7118,18 +7118,23 @@ function _runLegacyInstallMigrations(runtime, configDir, scope = 'global') { * @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. + // commands/gsd/ is a legacy location for Qwen, Hermes, and all Claude installs. + // Prior to #1367 fix, Claude-local used commands/gsd/.md (colon-namespaced). + // After #1367, Claude-local uses flat commands/gsd-.md. The inline uninstall + // block (1c) handles removal of flat files; this function handles the legacy + // commands/gsd/ directory for all Claude scopes (global was already included, + // local is now added since that layout is also legacy post-#1367). // #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. + // commands/gsd/ is a legacy location for Qwen, Hermes, and Claude global. + // Claude local is intentionally excluded: the inline uninstall block (1c) handles + // commands/gsd/ for claude local, preserving dev-preferences.md by restoring it + // to the same location (#1423). Using migrateLegacyDevPreferencesToSkill here + // (which would redirect to skills/) conflicts with the test contract for local installs. const isLegacyCommandsGsd = runtime === 'qwen' || runtime === 'hermes' || (runtime === 'claude' && scope === 'global'); if (isLegacyCommandsGsd) { const legacyCommandsGsd = path.join(configDir, 'commands', 'gsd'); @@ -8066,23 +8071,37 @@ function uninstall(isGlobal, runtime = 'claude') { } catch { /* best-effort */ } } - // 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). + // 1c. Claude local: remove flat gsd-*.md commands from commands/ (current layout, + // #1367 fix). Also remove legacy commands/gsd/ subdirectory from prior installs. if (!isGlobal && runtime === 'claude') { - const gsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); - if (fs.existsSync(gsdCommandsDir)) { - const devPrefsPath = path.join(gsdCommandsDir, 'dev-preferences.md'); - const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; - fs.rmSync(gsdCommandsDir, { recursive: true }); + const commandsDir = path.join(targetDir, 'commands'); + // Remove flat gsd-*.md files (current layout after #1367 fix) + if (fs.existsSync(commandsDir)) { + let removed = 0; + for (const f of fs.readdirSync(commandsDir)) { + if (f.startsWith('gsd-') && f.endsWith('.md')) { + fs.rmSync(path.join(commandsDir, f), { force: true }); + removed++; + } + } + if (removed > 0) { + removedCount++; + console.log(` ${green}✓${reset} Removed ${removed} flat gsd-*.md commands from commands/`); + } + } + // Remove legacy commands/gsd/ subdirectory if it still exists (pre-#1367 layout). + // Preserve user-owned dev-preferences.md if present (#1423 parity). + const legacyGsdCommandsDir = path.join(targetDir, 'commands', 'gsd'); + if (fs.existsSync(legacyGsdCommandsDir)) { + const legacyDevPrefsPath = path.join(legacyGsdCommandsDir, 'dev-preferences.md'); + const savedDevPrefs = fs.existsSync(legacyDevPrefsPath) ? fs.readFileSync(legacyDevPrefsPath, 'utf-8') : null; + fs.rmSync(legacyGsdCommandsDir, { recursive: true }); removedCount++; - console.log(` ${green}✓${reset} Removed commands/gsd/`); - if (preservedDevPrefs) { + console.log(` ${green}✓${reset} Removed legacy commands/gsd/`); + if (savedDevPrefs) { try { - fs.mkdirSync(gsdCommandsDir, { recursive: true }); - fs.writeFileSync(devPrefsPath, preservedDevPrefs); + fs.mkdirSync(legacyGsdCommandsDir, { recursive: true }); + fs.writeFileSync(legacyDevPrefsPath, savedDevPrefs); console.log(` ${green}✓${reset} Preserved commands/gsd/dev-preferences.md`); } catch (err) { console.error(` ${red}✗${reset} Failed to restore dev-preferences.md: ${err.message}`); @@ -8849,7 +8868,11 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { const isKimi = runtime === 'kimi'; const isHermes = runtime === 'hermes'; const gsdDir = path.join(configDir, 'gsd-core'); + // #1367: Claude local now writes flat gsd-*.md files at commands/ (not commands/gsd/). + // commandsDir points to the old location for Gemini (which still uses commands/gsd/). + // Claude local uses flatCommandsDir instead for manifest recording. const commandsDir = path.join(configDir, 'commands', 'gsd'); + const flatCommandsDir = path.join(configDir, 'commands'); const opencodeCommandDir = path.join(configDir, 'command'); // Hermes nests GSD skills under skills/gsd/ as a single category (#2841). // All other runtimes that use the Codex-style skills layout use a flat skills/ root. @@ -8875,17 +8898,27 @@ function writeManifest(configDir, runtime = 'claude', options = {}) { if (USER_OWNED_ARTIFACTS.includes(rel)) continue; manifest.files['gsd-core/' + rel] = hash; } - // Record commands/gsd/ for any runtime that emits it (Gemini globally, - // Claude Code locally — see #2923). Manifest must reflect everything on - // disk so saveLocalPatches() can detect user edits and so per-runtime - // assertions about minimal-mode emit can read manifest.files instead of - // re-walking the dir. - if (fs.existsSync(commandsDir)) { + // Record commands surface for runtimes that emit it: + // Gemini: commands/gsd/.toml (nested, colon-namespaced) + // Claude local (#1367 fix): flat gsd-.md at commands/ level + // Manifest must reflect everything on disk so saveLocalPatches() can detect + // user edits and per-runtime minimal-mode assertions can read manifest.files. + if (isGemini && fs.existsSync(commandsDir)) { const cmdHashes = generateManifest(commandsDir); for (const [rel, hash] of Object.entries(cmdHashes)) { manifest.files['commands/gsd/' + rel] = hash; } } + // Claude local (#1367): flat gsd-*.md files at commands/ level. + // Only claude local writes gsd-*.md here; global installs don't emit commands, + // so this branch is a no-op for global (no matching files to find). + if (runtime === 'claude' && fs.existsSync(flatCommandsDir)) { + for (const file of fs.readdirSync(flatCommandsDir)) { + if (file.startsWith('gsd-') && file.endsWith('.md')) { + manifest.files['commands/' + file] = fileHash(path.join(flatCommandsDir, file)); + } + } + } if ((isOpencode || isKilo) && fs.existsSync(opencodeCommandDir)) { for (const file of fs.readdirSync(opencodeCommandDir)) { if (file.startsWith('gsd-') && file.endsWith('.md')) { @@ -9985,18 +10018,59 @@ function install(isGlobal, runtime = 'claude', options = {}) { } } } else { - // Claude Code local: commands/gsd/ format — Claude Code reads local project - // commands from .claude/commands/gsd/, not .claude/skills/ + // Claude Code local: flat gsd-.md layout — Claude Code registers + // commands from .claude/commands/ using the filename stem as the command + // name, so gsd-.md produces the /gsd- hyphen form used everywhere + // in the framework. The old commands/gsd/.md subdirectory layout caused + // Claude Code to namespace commands as /gsd: (colon form). (#1367) const commandsDir = path.join(targetDir, 'commands'); fs.mkdirSync(commandsDir, { recursive: true }); const gsdSrc = _stageSkills(_commandsDir); - const gsdDest = path.join(commandsDir, 'gsd'); - copyWithPathReplacement(gsdSrc, gsdDest, pathPrefix, runtime, true, isGlobal); - if (verifyInstalled(gsdDest, 'commands/gsd')) { - const count = fs.readdirSync(gsdDest).filter(f => f.endsWith('.md')).length; - console.log(` ${green}✓${reset} Installed ${count} commands to commands/gsd/`); + const cmdNames = readGsdCommandNames(); + + // Remove stale gsd-*.md files before writing new ones (clean install) + if (fs.existsSync(commandsDir)) { + for (const f of fs.readdirSync(commandsDir)) { + if (f.startsWith('gsd-') && f.endsWith('.md')) { + fs.unlinkSync(path.join(commandsDir, f)); + } + } + } + + // Write each command as gsd-.md (flat, hyphen-prefixed) + let cmdCount = 0; + if (fs.existsSync(gsdSrc)) { + for (const entry of fs.readdirSync(gsdSrc, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + let content = fs.readFileSync(path.join(gsdSrc, entry.name), 'utf8'); + content = _applyRuntimeRewrites(content, runtime, pathPrefix, isGlobal); + content = normalizeAgentBodyForRuntime(content, runtime, cmdNames); + fs.writeFileSync(path.join(commandsDir, `gsd-${stem}.md`), content); + cmdCount++; + } + } + + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} commands to commands/ (gsd-.md flat form)`); } else { - failures.push('commands/gsd'); + failures.push('commands/gsd-*'); + } + + // Legacy cleanup: remove old commands/gsd/ subdirectory from prior installs + // that used the namespaced layout (wrote bare-name files under commands/gsd/). + const legacyGsdDir = path.join(commandsDir, 'gsd'); + if (fs.existsSync(legacyGsdDir)) { + // Preserve user-owned dev-preferences.md before wiping + const devPrefsPath = path.join(legacyGsdDir, 'dev-preferences.md'); + const preservedDevPrefs = fs.existsSync(devPrefsPath) ? fs.readFileSync(devPrefsPath, 'utf-8') : null; + fs.rmSync(legacyGsdDir, { recursive: true }); + console.log(` ${green}✓${reset} Removed legacy commands/gsd/ (migrated to flat gsd-.md layout)`); + if (preservedDevPrefs) { + // Migrate dev-preferences to the new flat form + fs.writeFileSync(path.join(commandsDir, 'gsd-dev-preferences.md'), preservedDevPrefs); + console.log(` ${green}✓${reset} Migrated dev-preferences.md to commands/gsd-dev-preferences.md`); + } } // Clean up any stale skills/ from a previous local install diff --git a/capabilities/claude/capability.json b/capabilities/claude/capability.json index 3151c4cde..30f47f2c3 100644 --- a/capabilities/claude/capability.json +++ b/capabilities/claude/capability.json @@ -32,7 +32,7 @@ "local": [ { "kind": "commands", - "destSubpath": "commands/gsd", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index dbc5e9157..e17c237b0 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -260,7 +260,7 @@ const capabilities = { "local": [ { "kind": "commands", - "destSubpath": "commands/gsd", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, @@ -2881,7 +2881,7 @@ const runtimes = { "local": [ { "kind": "commands", - "destSubpath": "commands/gsd", + "destSubpath": "commands", "prefix": "gsd-", "nesting": "flat", "recursive": false, diff --git a/scripts/lint-regression-test-names.allowlist.json b/scripts/lint-regression-test-names.allowlist.json index d3d369897..79fca1d2f 100644 --- a/scripts/lint-regression-test-names.allowlist.json +++ b/scripts/lint-regression-test-names.allowlist.json @@ -2,6 +2,7 @@ "bug-10-semver-policy-consolidation.test.cjs", "bug-130-finishinstall-opencode-testmode.test.cjs", "bug-131-release-tarball-smoke-explicit-home.test.cjs", + "bug-1367-claude-local-flat-command-layout.test.cjs", "bug-14-progress-auto-flag-dropped.test.cjs", "bug-167-query-meta-command.test.cjs", "bug-17-askuserquestion-option-cap.test.cjs", diff --git a/tests/bug-1367-claude-local-flat-command-layout.test.cjs b/tests/bug-1367-claude-local-flat-command-layout.test.cjs new file mode 100644 index 000000000..02ab787fc --- /dev/null +++ b/tests/bug-1367-claude-local-flat-command-layout.test.cjs @@ -0,0 +1,162 @@ +// allow-test-rule: source-text-is-the-product #1367 +// Installed command `.md` files — their on-disk path determines the slash-command +// namespace registered by Claude Code. Asserting the layout (flat vs. subdirectory) +// IS a behavioral test of the deploy contract, not source-grep theater. + +/** + * Regression for #1367 — project-local Claude Code install writes command files to + * `.claude/commands/gsd/.md` (subdirectory, bare names), causing Claude Code + * to register them as `/gsd:` (colon namespace). The fix changes the layout to + * write flat `gsd-.md` files at `.claude/commands/` level so Claude Code + * registers `/gsd-` (hyphen form, matching hooks, statusline, and cross-command + * references everywhere in the framework). + * + * Root cause: `bin/install.js` (the `else` branch for claude local) wrote to a + * `commands/gsd/` subdirectory using `copyWithPathReplacement`. Claude Code treats + * the directory name as a namespace, so `commands/gsd/update.md` became `/gsd:update`. + * + * Fix: write each command as `gsd-.md` directly in `commands/` (flat layout). + * This is the same approach used for OpenCode/Kilo (see `copyFlattenedCommands`). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { cleanup } = require('./helpers.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Run `node install.js --claude --local --no-sdk` in cwd. + * GSD_TEST_MODE must be cleared so the install() main block executes. + */ +function runClaudeLocalInstall(cwd) { + const env = { ...process.env }; + delete env.GSD_TEST_MODE; + execFileSync(process.execPath, [INSTALL_PATH, '--claude', '--local', '--no-sdk'], { + cwd, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + env, + }); +} + +// --------------------------------------------------------------------------- +// Suite — #1367 regression: flat gsd-.md layout for claude local install +// --------------------------------------------------------------------------- + +describe('bug #1367 — Claude local install uses flat gsd-.md command layout', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1367-')); + runClaudeLocalInstall(tmpDir); + }); + + after(() => { + cleanup(tmpDir); + }); + + test('L0: commands/ directory exists after local claude install', () => { + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok( + fs.existsSync(commandsDir), + `commands/ must be created by local claude install at ${commandsDir}`, + ); + }); + + test('L1: command files use flat gsd-.md names (not bare names in a subdirectory)', () => { + // The fix: commands land as .claude/commands/gsd-.md (flat, hyphen-prefixed). + // Claude Code reads the stem of each file in commands/ as the command name, + // so gsd-update.md → /gsd-update (hyphen). The old layout (commands/gsd/update.md) + // made Claude Code use the directory as a namespace → /gsd:update (colon). + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful'); + + const flatGsdFiles = fs.readdirSync(commandsDir, { withFileTypes: true }) + .filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md')); + + assert.ok( + flatGsdFiles.length > 0, + `commands/ must contain flat gsd-*.md files (e.g. gsd-help.md, gsd-update.md). ` + + `Found none. Install may still be writing to commands/gsd/.md subdirectory ` + + `which causes /gsd: colon namespace in Claude Code.`, + ); + }); + + test('L2: known commands land as flat gsd-.md files', () => { + // Spot-check: the three commands mentioned in the issue must be present + // as flat hyphen-prefixed files. + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + const knownCommands = ['gsd-update.md', 'gsd-plan-phase.md', 'gsd-help.md']; + for (const name of knownCommands) { + const filePath = path.join(commandsDir, name); + assert.ok( + fs.existsSync(filePath), + `${name} must exist as a flat file at commands/${name}. ` + + `If missing, the flat layout is not being written correctly.`, + ); + } + }); + + test('L3: commands/gsd/ subdirectory does NOT exist (old colon-namespace layout)', () => { + // The old layout wrote to commands/gsd/.md. That directory must not + // exist after a fresh install with the fix applied. + const oldSubdir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + `commands/gsd/ subdir must NOT exist after install. ` + + `Its presence means the old layout is still being used — Claude Code would ` + + `register commands as /gsd: (colon) instead of /gsd- (hyphen).`, + ); + }); + + test('L4: total flat command file count matches the staged source', () => { + // There should be a substantial number of commands (not 0, not 1). + // The exact count varies with profile but must be >= 20 for a full install. + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + const count = fs.readdirSync(commandsDir, { withFileTypes: true }) + .filter(e => e.isFile() && e.name.startsWith('gsd-') && e.name.endsWith('.md')) + .length; + assert.ok( + count >= 20, + `commands/ must have >= 20 flat gsd-*.md files for a full install. ` + + `Got ${count}. Install may be silently dropping commands.`, + ); + }); + + test('L5: legacy migration — re-install on a pre-#1367 tree removes old commands/gsd/ subdir', () => { + // Simulate a pre-#1367 install: create a commands/gsd/ subdirectory with a bare-name file. + // Then re-run the installer and verify the old subdir is cleaned up. + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + const legacyDir = path.join(commandsDir, 'gsd'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'update.md'), '# legacy update'); + + // Re-run install — should remove commands/gsd/ and write flat gsd-*.md + runClaudeLocalInstall(tmpDir); + + assert.ok( + !fs.existsSync(legacyDir), + `commands/gsd/ legacy subdir must be removed by re-install. ` + + `The installer's legacy cleanup must remove old commands/gsd/ on upgrade.`, + ); + // Flat form must still be present + assert.ok( + fs.existsSync(path.join(commandsDir, 'gsd-update.md')), + `gsd-update.md must exist as flat file after re-install.`, + ); + }); +}); diff --git a/tests/bug-1736-local-install-commands.test.cjs b/tests/bug-1736-local-install-commands.test.cjs index f1f0b2b05..ece52e4b5 100644 --- a/tests/bug-1736-local-install-commands.test.cjs +++ b/tests/bug-1736-local-install-commands.test.cjs @@ -3,9 +3,15 @@ * * After a fresh local install (`--claude --local`), all /gsd-* commands * except /gsd-help return "Unknown skill: gsd-quick" because - * .claude/commands/gsd/ is not populated. Claude Code reads local project - * commands from .claude/commands/gsd/ (the commands/ format), not from - * .claude/skills/ — only the global ~/.claude/skills/ is used for skills. + * .claude/commands/gsd/ was not populated. Claude Code reads local project + * commands from .claude/commands/ (one level up) using the file stem as the + * command name. + * + * #1367 follow-up: the fix changed the layout from the old commands/gsd/.md + * (which caused /gsd: colon namespace) to flat commands/gsd-.md + * (which produces /gsd- hyphen form). This test has been updated to assert + * the new flat layout while preserving the core invariant from #1736: commands + * must be present and usable after a local install. */ 'use strict'; @@ -37,9 +43,9 @@ before(() => { }); }); -// ─── #1736: local install deploys commands/gsd/ ───────────────────────────── +// ─── #1736 + #1367: local install deploys commands in flat gsd-.md layout ─── -describe('#1736: local Claude install populates .claude/commands/gsd/', () => { +describe('#1736: local Claude install deploys slash commands (flat gsd-.md layout, #1367)', () => { let tmpDir; beforeEach(() => { @@ -52,48 +58,63 @@ describe('#1736: local Claude install populates .claude/commands/gsd/', () => { cleanup(tmpDir); }); - test('local install creates .claude/commands/gsd/ directory', (t) => { + test('local install creates .claude/commands/ directory with flat gsd-*.md files (#1367)', (t) => { + // #1736 invariant: commands must be deployed. + // #1367 fix: commands land as flat gsd-.md at commands/ (not commands/gsd/.md). const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); install(false, 'claude'); - const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + const commandsDir = path.join(tmpDir, '.claude', 'commands'); assert.ok( fs.existsSync(commandsDir), - '.claude/commands/gsd/ directory must exist after local install' + '.claude/commands/ directory must exist after local install' + ); + const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + flatFiles.length > 0, + `.claude/commands/ must have flat gsd-*.md files (e.g. gsd-help.md). Found: ${JSON.stringify(flatFiles)}` + ); + // The old commands/gsd/ subdirectory must NOT exist (#1367) + const oldSubdir = path.join(commandsDir, 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + '.claude/commands/gsd/ subdir must NOT exist — flat gsd-.md layout required (#1367)' ); }); - test('local install deploys at least one .md command file to .claude/commands/gsd/', (t) => { + test('local install deploys at least one .md command file to .claude/commands/ (#1736 invariant)', (t) => { const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); install(false, 'claude'); - const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + const commandsDir = path.join(tmpDir, '.claude', 'commands'); assert.ok( fs.existsSync(commandsDir), - '.claude/commands/gsd/ must exist' + '.claude/commands/ must exist' ); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); assert.ok( files.length > 0, - `.claude/commands/gsd/ must contain at least one .md file, found: ${JSON.stringify(files)}` + `.claude/commands/ must contain at least one gsd-*.md file, found: ${JSON.stringify(files)}` ); }); - test('local install deploys quick.md to .claude/commands/gsd/', (t) => { + test('local install deploys gsd-quick.md to .claude/commands/ (#1367: flat hyphen form)', (t) => { + // Was: .claude/commands/gsd/quick.md (caused /gsd:quick colon form). + // Now: .claude/commands/gsd-quick.md (produces /gsd-quick hyphen form). const origCwd = process.cwd(); t.after(() => { process.chdir(origCwd); }); process.chdir(tmpDir); install(false, 'claude'); - const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd', 'quick.md'); + const quickCmd = path.join(tmpDir, '.claude', 'commands', 'gsd-quick.md'); assert.ok( fs.existsSync(quickCmd), - '.claude/commands/gsd/quick.md must exist after local install' + '.claude/commands/gsd-quick.md must exist after local install (#1367 flat layout)' ); }); }); diff --git a/tests/bug-3683-command-colon-namespace-leak.test.cjs b/tests/bug-3683-command-colon-namespace-leak.test.cjs index 27dea80d9..c7354b8ee 100644 --- a/tests/bug-3683-command-colon-namespace-leak.test.cjs +++ b/tests/bug-3683-command-colon-namespace-leak.test.cjs @@ -138,7 +138,14 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install) // --------------------------------------------------------------------------- // E — Integration: real local claude install produces clean command bodies // --------------------------------------------------------------------------- - describe('E — integration: staged commands/gsd/*.md files contain no colon-namespace refs', () => { + // E — integration: flat gsd-*.md layout + clean bodies (#1367 fix) + // + // Prior to #1367: commands wrote to commands/gsd/.md (bare names in a + // subdir), causing Claude Code to namespace them as /gsd: (colon form). + // After #1367: commands write flat gsd-.md at commands/ level so Claude + // Code registers them as /gsd- (hyphen form, matching all framework refs). + // --------------------------------------------------------------------------- + describe('E — integration: staged gsd-*.md flat commands contain no colon-namespace refs', () => { let tmpDir; const cmdNames = readCmdNames(); const rosterRegex = buildRosterRegex(cmdNames); @@ -152,36 +159,45 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install) cleanup(tmpDir); }); - test('E0: staged commands/gsd/ directory exists after install', () => { - const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + test('E0: staged commands/ directory has flat gsd-*.md files after install (#1367)', () => { + // After #1367 fix: commands land at .claude/commands/gsd-.md (flat, + // hyphen-prefixed). The old .claude/commands/gsd/.md subdirectory + // layout must NOT be created. + const commandsDir = path.join(tmpDir, '.claude', 'commands'); assert.ok( fs.existsSync(commandsDir), - `commands/gsd/ must be created by local claude install at ${commandsDir}`, + `commands/ must be created by local claude install at ${commandsDir}`, + ); + const flatFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok( + flatFiles.length > 0, + `commands/ must contain flat gsd-*.md files (e.g. gsd-help.md). ` + + `Found none — install may still be using the old commands/gsd/.md subdirectory layout.`, + ); + // The old subdirectory must NOT exist (it caused /gsd: colon namespace) + const oldSubdir = path.join(commandsDir, 'gsd'); + assert.ok( + !fs.existsSync(oldSubdir), + `commands/gsd/ subdir must NOT exist after install (it causes /gsd: colon namespace in Claude Code). ` + + `#1367 fix: use flat gsd-.md at commands/ level instead.`, ); }); test('E1: no staged command body contains /gsd: colon refs', () => { - const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); - assert.ok(fs.existsSync(commandsDir), 'commands/gsd/ must exist for this check to be meaningful'); + const commandsDir = path.join(tmpDir, '.claude', 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ must exist for this check to be meaningful'); const offenders = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.name.endsWith('.md')) { - const content = fs.readFileSync(fullPath, 'utf-8'); - if (rosterRegex.test(content)) { - const rel = path.relative(tmpDir, fullPath); - offenders.push(rel); - } - } + for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + if (!entry.name.startsWith('gsd-')) continue; + const fullPath = path.join(commandsDir, entry.name); + const content = fs.readFileSync(fullPath, 'utf-8'); + if (rosterRegex.test(content)) { + offenders.push(path.relative(tmpDir, fullPath)); } - }; - - walk(commandsDir); + } assert.deepEqual( offenders, @@ -193,29 +209,22 @@ describe('bug #3683 — command body colon-namespace leak (Claude local install) test('E2: idempotent — re-running install does not double-mangle already-hyphenated refs', () => { // Run install a second time; if the normalizer double-applies it would - // produce garbled output like /gsd--execute-phase. Verify the directory - // still passes the same cleanliness check after a second install. + // produce garbled output like /gsd--execute-phase. Verify the commands + // still pass the same cleanliness check after a second install. runClaudeLocalInstall(tmpDir); - const commandsDir = path.join(tmpDir, '.claude', 'commands', 'gsd'); + const commandsDir = path.join(tmpDir, '.claude', 'commands'); const doubleRewriteRegex = /\/gsd--[a-z]/; const garbled = []; - const walk = (dir) => { - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - walk(fullPath); - } else if (entry.name.endsWith('.md')) { - const content = fs.readFileSync(fullPath, 'utf-8'); - if (doubleRewriteRegex.test(content)) { - garbled.push(path.relative(tmpDir, fullPath)); - } - } + for (const entry of fs.readdirSync(commandsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) continue; + if (!entry.name.startsWith('gsd-')) continue; + const content = fs.readFileSync(path.join(commandsDir, entry.name), 'utf-8'); + if (doubleRewriteRegex.test(content)) { + garbled.push(entry.name); } - }; - - walk(commandsDir); + } assert.deepEqual( garbled, diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index 76a80fba4..8d111101d 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -154,11 +154,19 @@ function manifestSkillSet(manifest) { const seg = key.split('/')[1].replace(/^gsd-/, '').replace(/\.md$/, ''); out.add(seg); } else if (key.startsWith('command/')) { + // OpenCode/Kilo: command/gsd-.md const file = key.split('/')[1]; out.add(file.replace(/^gsd-/, '').replace(/\.md$/, '')); } else if (key.startsWith('commands/gsd/')) { + // Gemini: commands/gsd/.toml (nested, colon-namespaced) const file = key.split('/')[2]; out.add(file.replace(/\.(md|toml)$/, '')); + } else if (key.startsWith('commands/') && key.split('/').length === 2) { + // Claude local (#1367 fix): flat commands/gsd-.md + const file = key.split('/')[1]; + if (file.startsWith('gsd-') && file.endsWith('.md')) { + out.add(file.replace(/^gsd-/, '').replace(/\.md$/, '')); + } } } return out; @@ -197,6 +205,15 @@ function collectSkillBasenamesOnDisk(configDir) { } } } + // Claude local (#1367 fix): flat gsd-*.md files at commands/ level + const flatCommandsDir = path.join(configDir, 'commands'); + if (fs.existsSync(flatCommandsDir)) { + for (const file of fs.readdirSync(flatCommandsDir)) { + if (file.startsWith('gsd-') && file.endsWith('.md')) { + out.add(file.replace(/^gsd-/, '').replace(/\.md$/, '')); + } + } + } return out; } diff --git a/tests/runtime-artifact-layout-descriptor-drive.test.cjs b/tests/runtime-artifact-layout-descriptor-drive.test.cjs index 572718805..0f673d21e 100644 --- a/tests/runtime-artifact-layout-descriptor-drive.test.cjs +++ b/tests/runtime-artifact-layout-descriptor-drive.test.cjs @@ -51,8 +51,8 @@ const GOLDEN = { { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-' }, ], 'claude/local': [ - { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' }, - { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, + { kind: 'commands', destSubpath: 'commands', prefix: 'gsd-' }, // #1367: flat gsd-.md + { kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' }, ], // ── cursor ─────────────────────────────────────────────────────────────────── diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs index b48298c05..6965b1803 100644 --- a/tests/runtime-artifact-layout-surface.test.cjs +++ b/tests/runtime-artifact-layout-surface.test.cjs @@ -29,7 +29,8 @@ function tmpDir(prefix) { function createFixtureRuntime() { const base = createTempDir('gsd-surface-apply-'); const runtimeConfigDir = base; - const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + // #1367: claude local uses flat commands/ (not commands/gsd/) — commandsDir is commands/. + const commandsDir = path.join(runtimeConfigDir, 'commands'); const agentsDir = path.join(runtimeConfigDir, 'agents'); fs.mkdirSync(commandsDir, { recursive: true }); fs.mkdirSync(agentsDir, { recursive: true }); @@ -59,6 +60,7 @@ function readFrontmatterDescription(markdown) { describe('applySurface', () => { test('core profile: only core skills appear in commandsDir', (t) => { + // #1367: claude local uses flat gsd-.md files at commands/ (not commands/gsd/.md). const { base, runtimeConfigDir, commandsDir } = createFixtureRuntime(); t.after(() => cleanup(base)); writeActiveProfile(runtimeConfigDir, 'core'); @@ -72,11 +74,14 @@ describe('applySurface', () => { const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + // After #1367: files are gsd-.md (not bare stem.md). Strip the gsd- prefix + // to check against the REAL_COMMANDS_DIR (which still uses bare names). + const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); for (const file of files) { - assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); + const bareName = file.slice('gsd-'.length); // gsd-help.md → help.md + assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)), `unexpected file: ${file} (no source: ${bareName})`); } - const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort(); + const expectedCore = [...resolved.skills].map(stem => `gsd-${stem}.md`).sort(); assert.deepStrictEqual( [...files].sort(), expectedCore, @@ -98,7 +103,8 @@ describe('applySurface', () => { const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + // #1367: files are gsd-.md in flat commands/ + const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'))); writeSurface(runtimeConfigDir, { baseProfile: 'core', @@ -108,11 +114,11 @@ describe('applySurface', () => { }); const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md'))); assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort(); + const expectedCore = [...resolvedCore.skills].map(stem => `gsd-${stem}.md`).sort(); assert.deepStrictEqual( [...afterCore].sort(), expectedCore, @@ -120,8 +126,9 @@ describe('applySurface', () => { ); for (const file of afterCore) { + const bareName = file.slice('gsd-'.length); assert.ok( - fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), + fs.existsSync(path.join(REAL_COMMANDS_DIR, bareName)), `file in commandsDir not a real skill: ${file}` ); } @@ -161,13 +168,14 @@ describe('applySurface', () => { const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + // #1367: flat gsd-.md files at commands/ (not commands/gsd/.md) assert.ok( - fs.existsSync(path.join(commandsDir, 'help.md')), - 'help.md should be copied from install source' + fs.existsSync(path.join(commandsDir, 'gsd-help.md')), + 'gsd-help.md should be copied from install source (#1367: flat hyphen layout)' ); assert.ok( - fs.existsSync(path.join(commandsDir, 'new-project.md')), - 'new-project.md should be copied from install source' + fs.existsSync(path.join(commandsDir, 'gsd-new-project.md')), + 'gsd-new-project.md should be copied from install source (#1367: flat hyphen layout)' ); }); @@ -231,11 +239,12 @@ describe('applySurface', () => { const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); - assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent'); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - assert.ok(files.length > 0, 'commands/gsd should contain staged skill files'); - assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); + // #1367: claude local uses flat commands/ (not commands/gsd/) + const commandsDir = path.join(runtimeConfigDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir should be created even if initially absent'); + const files = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(files.length > 0, 'commands/ should contain staged skill files (gsd-*.md)'); + assert.ok(files.includes('gsd-help.md'), 'gsd-help.md should be present after applySurface on missing dest'); }); test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => { diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 0df4ca2da..a52aa6768 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -37,7 +37,7 @@ describe('resolveRuntimeArtifactLayout — claude local', () => { assert.strictEqual(layout.configDir, FAKE_DIR); assert.strictEqual(layout.kinds.length, 2); assert.strictEqual(layout.kinds[0].kind, 'commands'); - assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); // #1367: flat gsd-.md layout assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); assert.strictEqual(layout.kinds[1].kind, 'agents');