fix(#1367): project-local install uses flat gsd-<cmd>.md layout (fixes /gsd: colon namespace) (#1489)

* fix(#1367): project-local install uses flat gsd-<cmd>.md layout

Claude Code project-local installs now write command files as flat
gsd-<cmd>.md at .claude/commands/ level instead of commands/gsd/<cmd>.md
(subdirectory), so Claude Code registers /gsd-<cmd> (hyphen form)
matching hooks, statusline, and all cross-command references.

- capabilities/claude/capability.json: local destSubpath commands/gsd → commands
- bin/install.js else branch: flat gsd-<stem>.md loop with runtime rewrites
- bin/install.js uninstall (1c): remove flat files + legacy subdir cleanup
- bin/install.js writeManifest: record flat commands/gsd-<cmd>.md keys
- legacy migration: preserves dev-preferences.md across reinstall and uninstall
- gsd-core/bin/lib/capability-registry.cjs: regenerated
- 6 new regression tests (L0–L5) in bug-1367-*.test.cjs
- Updated E suite in bug-3683 + bug-1736, layout + surface + descriptor tests
- scripts/lint-regression-test-names.allowlist.json: grandfathered bug-1367 test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(#1367): add issue reference to allow-test-rule comment

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-20 13:37:27 -04:00
committed by GitHub
parent fa1ffb4824
commit 33ccf5f89d
12 changed files with 409 additions and 111 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1367
---
**Project-local Claude Code install now produces `/gsd-<cmd>` (hyphen) slash commands** — the installer was writing command files to `.claude/commands/gsd/<cmd>.md` (subdirectory with bare names), causing Claude Code to namespace them as `/gsd:<cmd>` (colon form). The fix writes flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code registers `/gsd-<cmd>` (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)

View File

@@ -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/<cmd>.md (colon-namespaced).
// After #1367, Claude-local uses flat commands/gsd-<cmd>.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/<cmd>.toml (nested, colon-namespaced)
// Claude local (#1367 fix): flat gsd-<cmd>.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-<cmd>.md layout — Claude Code registers
// commands from .claude/commands/ using the filename stem as the command
// name, so gsd-<cmd>.md produces the /gsd-<cmd> hyphen form used everywhere
// in the framework. The old commands/gsd/<cmd>.md subdirectory layout caused
// Claude Code to namespace commands as /gsd:<cmd> (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-<stem>.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-<cmd>.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-<cmd>.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

View File

@@ -32,7 +32,7 @@
"local": [
{
"kind": "commands",
"destSubpath": "commands/gsd",
"destSubpath": "commands",
"prefix": "gsd-",
"nesting": "flat",
"recursive": false,

View File

@@ -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,

View File

@@ -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",

View File

@@ -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/<cmd>.md` (subdirectory, bare names), causing Claude Code
* to register them as `/gsd:<cmd>` (colon namespace). The fix changes the layout to
* write flat `gsd-<cmd>.md` files at `.claude/commands/` level so Claude Code
* registers `/gsd-<cmd>` (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-<stem>.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-<cmd>.md layout for claude local install
// ---------------------------------------------------------------------------
describe('bug #1367 — Claude local install uses flat gsd-<cmd>.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-<cmd>.md names (not bare names in a subdirectory)', () => {
// The fix: commands land as .claude/commands/gsd-<cmd>.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/<cmd>.md subdirectory ` +
`which causes /gsd:<cmd> colon namespace in Claude Code.`,
);
});
test('L2: known commands land as flat gsd-<cmd>.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/<cmd>.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:<cmd> (colon) instead of /gsd-<cmd> (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.`,
);
});
});

View File

@@ -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/<cmd>.md
* (which caused /gsd:<cmd> colon namespace) to flat commands/gsd-<cmd>.md
* (which produces /gsd-<cmd> 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-<cmd>.md layout ───
describe('#1736: local Claude install populates .claude/commands/gsd/', () => {
describe('#1736: local Claude install deploys slash commands (flat gsd-<cmd>.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-<cmd>.md at commands/ (not commands/gsd/<cmd>.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-<cmd>.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)'
);
});
});

View File

@@ -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/<cmd>.md (bare names in a
// subdir), causing Claude Code to namespace them as /gsd:<cmd> (colon form).
// After #1367: commands write flat gsd-<cmd>.md at commands/ level so Claude
// Code registers them as /gsd-<cmd> (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-<cmd>.md (flat,
// hyphen-prefixed). The old .claude/commands/gsd/<cmd>.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/<cmd>.md subdirectory layout.`,
);
// The old subdirectory must NOT exist (it caused /gsd:<cmd> colon namespace)
const oldSubdir = path.join(commandsDir, 'gsd');
assert.ok(
!fs.existsSync(oldSubdir),
`commands/gsd/ subdir must NOT exist after install (it causes /gsd:<cmd> colon namespace in Claude Code). ` +
`#1367 fix: use flat gsd-<cmd>.md at commands/ level instead.`,
);
});
test('E1: no staged command body contains /gsd:<known-cmd> 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,

View File

@@ -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-<cmd>.md
const file = key.split('/')[1];
out.add(file.replace(/^gsd-/, '').replace(/\.md$/, ''));
} else if (key.startsWith('commands/gsd/')) {
// Gemini: commands/gsd/<cmd>.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-<cmd>.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;
}

View File

@@ -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-<cmd>.md
{ kind: 'agents', destSubpath: 'agents', prefix: 'gsd-' },
],
// ── cursor ───────────────────────────────────────────────────────────────────

View File

@@ -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-<stem>.md files at commands/ (not commands/gsd/<stem>.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-<stem>.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-<stem>.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-<stem>.md files at commands/ (not commands/gsd/<stem>.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) => {

View File

@@ -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-<cmd>.md layout
assert.strictEqual(layout.kinds[0].prefix, 'gsd-');
assert.strictEqual(typeof layout.kinds[0].stage, 'function');
assert.strictEqual(layout.kinds[1].kind, 'agents');