Files
msd-core/tests/bug-1367-claude-local-flat-command-layout.test.cjs
Tom Boucher 33ccf5f89d 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>
2026-06-20 13:37:27 -04:00

163 lines
6.9 KiB
JavaScript

// 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.`,
);
});
});