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:
5
.changeset/1367-claude-local-flat-command-layout.md
Normal file
5
.changeset/1367-claude-local-flat-command-layout.md
Normal 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)
|
||||
142
bin/install.js
142
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/<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
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"local": [
|
||||
{
|
||||
"kind": "commands",
|
||||
"destSubpath": "commands/gsd",
|
||||
"destSubpath": "commands",
|
||||
"prefix": "gsd-",
|
||||
"nesting": "flat",
|
||||
"recursive": false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal file
162
tests/bug-1367-claude-local-flat-command-layout.test.cjs
Normal 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.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user