diff --git a/.changeset/789-codebuddy-slash-commands.md b/.changeset/789-codebuddy-slash-commands.md new file mode 100644 index 000000000..244771897 --- /dev/null +++ b/.changeset/789-codebuddy-slash-commands.md @@ -0,0 +1,7 @@ +--- +type: Added +pr: 830 +--- +**CodeBuddy (Tencent) installs now emit `/gsd-*` slash commands.** A `--codebuddy` install writes `commands/gsd-.md` files to `~/.codebuddy/commands/` so GSD workflows are invokable from CodeBuddy's `/` menu (`/gsd-phase`, `/gsd-ship`, etc.), matching the integration depth of other fully-elevated runtimes (#789). The existing `skills/gsd-/SKILL.md` files are now emitted with `user-invocable: false` so they stay out of the `/` menu — the commands surface is the single `/` entry point (no duplicate entries) and skills remain available for model invocation. Subagents (`~/.codebuddy/agents/`) were already emitted and are unchanged. Uninstall removes the `gsd-*` command files while preserving user-owned commands. No `mcp.json` is written — gsd ships no MCP server and CodeBuddy's `mcp.json` only registers external MCP servers. + + diff --git a/bin/install.js b/bin/install.js index a86cb149f..8a88ad2de 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2843,7 +2843,47 @@ function convertClaudeCommandToCodebuddySkill(content, skillName) { const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; // #2876: quote so YAML flow indicators (`[BETA] …`) don't break // CodeBuddy's frontmatter parser. - return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\n---\n${body}`; + // + // #789: mark user-invocable:false so the skill is NOT shown in CodeBuddy's + // '/' menu (it defaults to true). The commands/ surface (#789) is the sole + // '/' entry point; skills remain model-invocable background knowledge, + // avoiding a duplicated /gsd-* entry per workflow. + return `---\nname: ${yamlIdentifier(skillName)}\ndescription: ${yamlQuote(shortDescription)}\nuser-invocable: false\n---\n${body}`; +} + +/** + * Convert a Claude Code slash-command (.md) to a CodeBuddy slash-command (.md). + * + * CodeBuddy reads user-level slash commands from ~/.codebuddy/commands/.md + * (https://www.codebuddy.ai/docs/cli/slash-commands). The filename determines the + * command name (gsd-help.md → /gsd-help), so the Claude-specific `name: gsd:` + * frontmatter field is dropped. CodeBuddy command frontmatter supports + * `description` and `argument-hint`; both are preserved when present. The body is + * brand/path-converted via convertClaudeToCodebuddyMarkdown. + * + * @param {string} content raw Claude command markdown + * @param {string} commandName installed command name (e.g. 'gsd-help') + * @returns {string} + */ +function convertClaudeCommandToCodebuddyCommand(content, commandName) { + const converted = convertClaudeToCodebuddyMarkdown(content); + const { frontmatter, body } = extractFrontmatterAndBody(converted); + let description = `Run GSD workflow ${commandName}.`; + let argumentHint = ''; + if (frontmatter) { + const maybeDescription = extractFrontmatterField(frontmatter, 'description'); + if (maybeDescription) description = maybeDescription; + const maybeArgHint = extractFrontmatterField(frontmatter, 'argument-hint'); + if (maybeArgHint) argumentHint = maybeArgHint; + } + description = toSingleLine(description); + const shortDescription = description.length > 180 ? `${description.slice(0, 177)}...` : description; + // #2876: quote values so YAML flow indicators (`[BETA] …`, `[name]`) don't + // break CodeBuddy's frontmatter parser. + const lines = ['---', `description: ${yamlQuote(shortDescription)}`]; + if (argumentHint) lines.push(`argument-hint: ${yamlQuote(toSingleLine(argumentHint))}`); + lines.push('---', body.trimStart()); + return lines.join('\n'); } function convertClaudeAgentToCodebuddyAgent(content) { @@ -6934,7 +6974,14 @@ function _applyRuntimeRewrites(content, runtime, pathPrefix) { content = content.replace(/~\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\$HOME\/\.claude\b/g, normalizedPathPrefix); content = content.replace(/\.\/\.claude\b/g, `./${dirName}`); + // The codebuddy converter rewrites `.claude/` → `.codebuddy/` at stage + // time, so `$HOME/.claude/...` arrives here as `$HOME/.codebuddy/...`. + // Normalize BOTH the `~/` and `$HOME/` forms (slash + bare) to the install + // target so `--config-dir`/local installs don't leak the default home. content = content.replace(/~\/\.codebuddy\//g, pathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\//g, pathPrefix); + content = content.replace(/~\/\.codebuddy\b/g, normalizedPathPrefix); + content = content.replace(/\$HOME\/\.codebuddy\b/g, normalizedPathPrefix); content = processAttribution(content, getCommitAttribution(runtime)); break; @@ -9664,6 +9711,22 @@ function install(isGlobal, runtime = 'claude', options = {}) { failures.push('commands/gsd-*'); } } + + // CodeBuddy only: also report the commands/ output (#789 — slash commands) + if (isCodebuddy) { + const commandsDir = path.join(targetDir, 'commands'); + if (fs.existsSync(commandsDir)) { + const cmdCount = fs.readdirSync(commandsDir) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + if (cmdCount > 0) { + console.log(` ${green}✓${reset} Installed ${cmdCount} slash commands to commands/`); + } else { + failures.push('commands/gsd-*'); + } + } else { + failures.push('commands/gsd-*'); + } + } } } else if (isOpencode || isKilo) { // OpenCode/Kilo: flat structure in command/ directory @@ -12122,6 +12185,7 @@ module.exports = { convertClaudeAgentToTraeAgent, convertClaudeToCodebuddyMarkdown, convertClaudeCommandToCodebuddySkill, + convertClaudeCommandToCodebuddyCommand, convertClaudeAgentToCodebuddyAgent, convertClaudeToCliineMarkdown, convertClaudeCommandToClineSkill, diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 46400e0be..0c555656f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -808,7 +808,7 @@ The migration-specific ownership and source snapshots live in | Trae | `~/.trae` | `./.trae` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Rule references under `rules/`; no GSD hooks | | Qwen Code | `~/.qwen` | `./.qwen` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | | Hermes Agent | `~/.hermes` | `./.hermes` | `skills/gsd/DESCRIPTION.md` plus `skills/gsd/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | -| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` | `agents/gsd-*.md` | Common GSD settings and hook entries where supported | +| CodeBuddy | `~/.codebuddy` | `./.codebuddy` | `skills/gsd-*/SKILL.md` (`user-invocable: false`) | `agents/gsd-*.md` | `/gsd-*` slash commands under `commands/`; common GSD settings and hook entries where supported | | Cline | `~/.cline` | project root | `.clinerules` | Rules only | No GSD hooks or statusline | ### Upstream Contract Sources diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 839c29642..2ab41eb79 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -2290,7 +2290,7 @@ Test suite that scans all agent, workflow, and command files for embedded inject **Requirements:** - REQ-CLINE-02: Cline install MUST write `.clinerules` to `~/.cline/` (global) or `./.cline/` (local). No custom slash commands — rules-based integration only. Flag: `--cline`. -- REQ-CODEBUDDY-01: CodeBuddy install MUST deploy skills to `~/.codebuddy/skills/gsd-*/SKILL.md`. Flag: `--codebuddy`. +- REQ-CODEBUDDY-01: CodeBuddy install MUST deploy skills to `~/.codebuddy/skills/gsd-*/SKILL.md` (emitted `user-invocable: false`), `/gsd-*` slash commands to `~/.codebuddy/commands/gsd-*.md`, and subagents to `~/.codebuddy/agents/gsd-*.md`. The commands surface is the sole `/` menu entry point. No `mcp.json` is written (gsd ships no MCP server). Flag: `--codebuddy`. - REQ-QWEN-01: Qwen Code install MUST deploy skills to `~/.qwen/skills/gsd-*/SKILL.md`, following the open standard used by Claude Code 2.1.88+. `QWEN_CONFIG_DIR` env var overrides the default path. Flag: `--qwen`. **Runtime summary:** diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 1a766a9cf..c4f114a76 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -746,6 +746,8 @@ npx @opengsd/gsd-core --cline --local # this project only npx @opengsd/gsd-core --codebuddy --global ``` +GSD installs four surfaces for CodeBuddy: `/gsd-*` slash commands in `~/.codebuddy/commands/`, subagents in `~/.codebuddy/agents/`, model-invocable skills in `~/.codebuddy/skills/`, and `settings.json` hooks. The skills are emitted with `user-invocable: false` so the slash commands are the single `/` menu surface (no duplicate entries). + ### Installing for Qwen Code ```bash diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 152aaf083..6d10fbbf5 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -281,7 +281,7 @@ directory is created for local scope. npx @opengsd/gsd-core@latest --codebuddy --global ``` -Skills land in `~/.codebuddy/skills/gsd-*/SKILL.md`. +GSD installs four surfaces. Slash command definitions land in `~/.codebuddy/commands/gsd-*.md` and appear as `/gsd-help`, `/gsd-phase`, `/gsd-ship`, etc. in the `/` menu. Subagents land in `~/.codebuddy/agents/gsd-*.md`. Skills land in `~/.codebuddy/skills/gsd-*/SKILL.md` — emitted with `user-invocable: false` so they stay out of the `/` menu (the commands surface is the sole `/` entry point) and remain available for model invocation. CodeBuddy hooks are written to `settings.json`. No `mcp.json` is written: GSD ships no MCP server. --- diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 949a57e3c..41c7eae40 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -345,7 +345,17 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: break; case 'codebuddy': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir)]; + // CodeBuddy (Tencent) reads two user-level surfaces (codebuddy.ai/docs/cli): + // 1. commands/gsd-.md — slash commands shown in the '/' menu (#789) + // 2. skills/gsd-/SKILL.md — model-invocable skills, emitted with + // user-invocable:false so they stay OUT of '/' (the commands surface is + // the sole '/' entry point) — avoids a duplicated /gsd-* per workflow. + // Subagents (~/.codebuddy/agents/) are already emitted by the generic agents + // block in bin/install.js; MCP is excluded (gsd ships no MCP server). + kinds = [ + convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCodebuddyCommand', configDir), + skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir), + ]; break; case 'cline': diff --git a/tests/enh-789-codebuddy-commands.test.cjs b/tests/enh-789-codebuddy-commands.test.cjs new file mode 100644 index 000000000..56ca77e69 --- /dev/null +++ b/tests/enh-789-codebuddy-commands.test.cjs @@ -0,0 +1,259 @@ +// allow-test-rule: source-text-is-the-product +// Workflow .md / command .md / SKILL.md files — their text IS what the runtime +// loads. Testing emitted text tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * Regression guard — enh(#789): elevate CodeBuddy slash-command surface. + * + * CodeBuddy (Tencent, @tencent-ai/codebuddy-code) reads user-level surfaces + * (https://www.codebuddy.ai/docs/cli/slash-commands, /skills): + * - commands/gsd-.md — slash commands shown in the '/' menu + * - skills/gsd-/SKILL.md — model-invocable skills + * + * Before #789 gsd emitted only skills/. Because CodeBuddy skills default to + * user-invocable:true (appear in '/'), emitting a commands/ surface AND leaving + * skills user-invocable would duplicate every /gsd-* entry. #789 therefore: + * 1. emits commands/gsd-.md (the '/' surface, peer-consistent with + * Cursor #785 and Augment #790), + * 2. marks skills user-invocable:false so they become model-invocable + * background knowledge and the commands/ surface is the sole '/' surface. + * + * Subagents are already emitted via the generic agents block + convertClaude + * AgentToCodebuddyAgent (~/.codebuddy/agents/), so #789 adds no agents change. + * + * mcp.json is intentionally NOT written: gsd ships no MCP server, and CodeBuddy's + * mcp.json holds an `mcpServers` map of *external* servers to connect to — + * there is nothing for gsd to register. Same exclusion as #784/#785/#790. + */ +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const { + installRuntimeArtifacts, + uninstallRuntimeArtifacts, + convertClaudeCommandToCodebuddyCommand, + convertClaudeCommandToCodebuddySkill, +} = require('../bin/install.js'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); +const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs'); + +const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); +const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR); +const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST }); + +// ─── Layout contract ───────────────────────────────────────────────────────── + +describe('enh-789 — codebuddy layout has commands + skills kinds', () => { + test('resolveRuntimeArtifactLayout codebuddy returns 2 kinds', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + assert.strictEqual(layout.kinds.length, 2, 'codebuddy must have exactly 2 artifact kinds'); + const kindNames = layout.kinds.map(k => k.kind).sort(); + assert.deepStrictEqual(kindNames, ['commands', 'skills']); + }); + + test('codebuddy commands kind targets commands/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'must have commands kind'); + assert.strictEqual(commandsKind.destSubpath, 'commands'); + assert.strictEqual(commandsKind.prefix, 'gsd-'); + assert.strictEqual(typeof commandsKind.stage, 'function'); + }); + + test('codebuddy skills kind targets skills/ with gsd- prefix', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', '/tmp/fake-codebuddy-dir'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'must have skills kind'); + assert.strictEqual(skillsKind.destSubpath, 'skills'); + assert.strictEqual(skillsKind.prefix, 'gsd-'); + }); +}); + +// ─── Command converter contract ────────────────────────────────────────────── + +describe('enh-789 — convertClaudeCommandToCodebuddyCommand', () => { + const SRC = [ + '---', + 'name: gsd:new-project', + 'description: Initialize a project', + 'argument-hint: "[name]"', + 'allowed-tools:', + ' - Read', + '---', + '', + 'Use .claude/skills/ and run /gsd:help. Claude Code reads CLAUDE.md.', + '', + ].join('\n'); + + test('emits a description-only frontmatter (no Claude-specific name: gsd:)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.startsWith('---\n'), 'must begin with frontmatter'); + assert.ok(/^description:/m.test(out), 'must carry a description field'); + assert.ok(!out.includes('name: gsd:new-project'), 'must drop Claude colon-form name field'); + }); + + test('preserves a present argument-hint (CodeBuddy supports it)', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(/^argument-hint:\s*["']?\[name\]["']?\s*$/m.test(out), + `argument-hint must be carried through when present in source. Got:\n${out}`); + }); + + test('converts body Claude-isms to CodeBuddy equivalents', () => { + const out = convertClaudeCommandToCodebuddyCommand(SRC, 'gsd-new-project'); + assert.ok(out.includes('.codebuddy/skills/'), out); + assert.ok(out.includes('/gsd-help'), out); + assert.ok(out.includes('CODEBUDDY.md'), out); + assert.ok(!/\bClaude Code\b/.test(out), 'must rebrand "Claude Code"'); + }); +}); + +describe('enh-789 — skills marked user-invocable:false', () => { + test('convertClaudeCommandToCodebuddySkill emits user-invocable: false', () => { + const src = [ + '---', + 'name: gsd:help', + 'description: Show help', + '---', + '', + '# body', + '', + ].join('\n'); + const out = convertClaudeCommandToCodebuddySkill(src, 'gsd-help'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(out), + `SKILL.md frontmatter must hide skill from '/' menu (user-invocable: false). Got:\n${out}`); + }); +}); + +// ─── Install contract ──────────────────────────────────────────────────────── + +describe('enh-789 — installRuntimeArtifacts codebuddy emits commands and skills', () => { + test('global codebuddy install: commands/gsd-help.md and skills/gsd-help/SKILL.md exist', (t) => { + const configDir = createTempDir('gsd-enh789-codebuddy-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const commandsDir = path.join(configDir, 'commands'); + assert.ok(fs.existsSync(commandsDir), 'commands/ dir must exist'); + const cmdFiles = fs.readdirSync(commandsDir).filter(f => f.startsWith('gsd-') && f.endsWith('.md')); + assert.ok(cmdFiles.length > 0, 'at least one gsd-*.md command file must be installed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'commands/gsd-help.md must exist'); + + const skillsDir = path.join(configDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); + assert.ok(fs.existsSync(path.join(skillsDir, 'gsd-help', 'SKILL.md')), 'skills/gsd-help/SKILL.md must exist'); + }); + + test('installed commands/gsd-help.md is CodeBuddy-compatible (no raw ~/.claude/, rebranded)', (t) => { + const configDir = createTempDir('gsd-enh789-content-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const helpCmd = path.join(configDir, 'commands', 'gsd-help.md'); + const content = fs.readFileSync(helpCmd, 'utf8'); + assert.ok(!content.includes('~/.claude/'), 'commands must not contain raw ~/.claude/ refs'); + assert.ok(content.startsWith('---'), 'commands must carry frontmatter'); + }); + + test('installed skills/gsd-help/SKILL.md is hidden from the / menu', (t) => { + const configDir = createTempDir('gsd-enh789-skillhide-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const skill = fs.readFileSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md'), 'utf8'); + assert.ok(/^user-invocable:\s*false\s*$/m.test(skill), + 'installed SKILL.md must set user-invocable: false'); + }); + + test('command count matches skill count (profile parity)', (t) => { + const configDir = createTempDir('gsd-enh789-parity-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + const cmdCount = fs.readdirSync(path.join(configDir, 'commands')) + .filter(f => f.startsWith('gsd-') && f.endsWith('.md')).length; + const skillCount = fs.readdirSync(path.join(configDir, 'skills'), { withFileTypes: true }) + .filter(e => e.isDirectory() && e.name.startsWith('gsd-')).length; + assert.strictEqual(cmdCount, skillCount, 'command count must equal skill count for same profile'); + }); + + test('full profile install: no $HOME/.codebuddy or ~/.codebuddy leak in any command', (t) => { + // The codebuddy converter rewrites `.claude/` → `.codebuddy/`, so source + // refs like `@$HOME/.claude/gsd-core/...` (e.g. plan-review-convergence.md) + // must be normalized to the install target — not left as $HOME/.codebuddy. + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + const configDir = createTempDir('gsd-enh789-noleak-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const commandsDir = path.join(configDir, 'commands'); + for (const f of fs.readdirSync(commandsDir).filter(n => n.endsWith('.md'))) { + const content = fs.readFileSync(path.join(commandsDir, f), 'utf8'); + assert.ok(!content.includes('$HOME/.codebuddy'), `${f} must not leak $HOME/.codebuddy`); + assert.ok(!content.includes('~/.codebuddy'), `${f} must not leak ~/.codebuddy`); + assert.ok(!content.includes('.claude/'), `${f} must not retain raw .claude/ refs`); + } + }); + + test('full profile install does NOT mutate source commands/gsd/ files', (t) => { + const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + assert.strictEqual(RESOLVED_FULL.skills, '*', 'full profile must have skills === "*"'); + + const configDir = createTempDir('gsd-enh789-full-'); + t.after(() => cleanup(configDir)); + + const srcHelpPath = path.join(REAL_COMMANDS_DIR, 'help.md'); + const before = fs.readFileSync(srcHelpPath, 'utf8'); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_FULL); + + const after = fs.readFileSync(srcHelpPath, 'utf8'); + assert.strictEqual(before, after, 'source commands/gsd/help.md must not be mutated by the install'); + }); +}); + +// ─── Uninstall contract ────────────────────────────────────────────────────── + +describe('enh-789 — uninstallRuntimeArtifacts removes codebuddy commands', () => { + test('uninstall removes gsd-* commands but preserves user commands', (t) => { + const configDir = createTempDir('gsd-enh789-uninstall-'); + t.after(() => cleanup(configDir)); + + const commandsDir = path.join(configDir, 'commands'); + fs.mkdirSync(commandsDir, { recursive: true }); + fs.writeFileSync(path.join(commandsDir, 'gsd-help.md'), '# help\n'); + fs.writeFileSync(path.join(commandsDir, 'user-custom.md'), '# user\n'); + + uninstallRuntimeArtifacts('codebuddy', configDir, 'global'); + + assert.ok(!fs.existsSync(path.join(commandsDir, 'gsd-help.md')), 'gsd-help.md must be removed'); + assert.ok(fs.existsSync(path.join(commandsDir, 'user-custom.md')), 'user-custom.md must be preserved'); + }); +}); + +// ─── mcp.json exclusion ────────────────────────────────────────────────────── + +describe('enh-789 — mcp.json excluded (gsd ships no MCP server)', () => { + test('codebuddy install does not write mcp.json / .mcp.json', (t) => { + const configDir = createTempDir('gsd-enh789-mcp-excluded-'); + t.after(() => cleanup(configDir)); + + installRuntimeArtifacts('codebuddy', configDir, 'global', RESOLVED_CORE); + + assert.ok(!fs.existsSync(path.join(configDir, 'mcp.json')), 'must not write mcp.json'); + assert.ok(!fs.existsSync(path.join(configDir, '.mcp.json')), 'must not write .mcp.json'); + }); +}); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index fedf9ed20..2bfa835b9 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -203,15 +203,21 @@ describe('resolveRuntimeArtifactLayout — hermes', () => { }); describe('resolveRuntimeArtifactLayout — codebuddy', () => { - test('returns correct layout for codebuddy', () => { + test('returns correct layout for codebuddy (commands + skills — #789)', () => { const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR); assert.strictEqual(layout.runtime, 'codebuddy'); assert.strictEqual(layout.configDir, FAKE_DIR); - assert.strictEqual(layout.kinds.length, 1); - assert.strictEqual(layout.kinds[0].kind, 'skills'); - assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds.length, 2); + // commands kind first + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands'); assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + // skills kind second + assert.strictEqual(layout.kinds[1].kind, 'skills'); + assert.strictEqual(layout.kinds[1].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); }); });