* feat(#789): elevate CodeBuddy — emit slash commands (+ document subagent/MCP scope) Emit a CodeBuddy slash-command surface so GSD workflows appear in the '/' menu, reaching parity with other elevated runtimes. - Add convertClaudeCommandToCodebuddyCommand and register a commands/ artifact kind for the codebuddy runtime (commands/gsd-<name>.md), consistent with the Cursor (#785) and Augment (#790) commands surfaces. - Mark emitted skills user-invocable:false so the commands surface is the sole '/' entry point (no duplicate /gsd-* entries); skills stay model-invocable. CodeBuddy's SKILL.md supports this field. - Normalize $HOME/.codebuddy (bare + slash) path forms in runtime rewrites so --config-dir/local installs don't leak the default home. - Report installed commands/ count on install; uninstall prunes gsd-* commands while preserving user-owned commands. Scope: subagents (~/.codebuddy/agents/) are already emitted by the generic agents block (unchanged); no mcp.json is written (gsd ships no MCP server, and CodeBuddy's mcp.json registers only external servers). Closes #789 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#789): set changeset pr number to 830 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/789-codebuddy-slash-commands.md
Normal file
7
.changeset/789-codebuddy-slash-commands.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: Added
|
||||
pr: 830
|
||||
---
|
||||
**CodeBuddy (Tencent) installs now emit `/gsd-*` slash commands.** A `--codebuddy` install writes `commands/gsd-<name>.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-<name>/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.
|
||||
|
||||
<!-- docs-exempt-not-used: user-facing install behaviour is documented in docs/USER-GUIDE.md and docs/how-to/install-on-your-runtime.md -->
|
||||
@@ -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/<name>.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:<x>`
|
||||
* 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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-<name>.md — slash commands shown in the '/' menu (#789)
|
||||
// 2. skills/gsd-<name>/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':
|
||||
|
||||
259
tests/enh-789-codebuddy-commands.test.cjs
Normal file
259
tests/enh-789-codebuddy-commands.test.cjs
Normal file
@@ -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-<stem>.md — slash commands shown in the '/' menu
|
||||
* - skills/gsd-<stem>/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-<stem>.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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user