feat: support CodeBuddy runtime (#1887)

Add CodeBuddy (Tencent Cloud AI coding IDE/CLI) as a first-class
runtime in the GSD installer.

- Add --codebuddy CLI flag and interactive menu option
- Add directory mapping (.codebuddy/ local, ~/.codebuddy/ global)
- Add CODEBUDDY_CONFIG_DIR env var support
- Add markdown conversion (CLAUDE.md -> CODEBUDDY.md, .claude/ -> .codebuddy/)
- Preserve tool names (CodeBuddy uses same names as Claude Code)
- Configure settings.json hooks (Claude Code compatible hook spec)
- Add copyCommandsAsCodebuddySkills for SKILL.md format
- Add 15 tests (dir mapping, env vars, conversion, E2E install/uninstall)
- Update README.md and README.zh-CN.md
- Update existing tests for new runtime numbering

Co-authored-by: happyu <happyu@tencent.com>
This commit is contained in:
storyandwine
2026-04-10 22:46:21 +08:00
committed by GitHub
parent 7e2217186a
commit 50cce89a7c
8 changed files with 498 additions and 75 deletions

View File

@@ -0,0 +1,257 @@
process.env.GSD_TEST_MODE = '1';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { createTempDir, cleanup } = require('./helpers.cjs');
const {
getDirName,
getGlobalDir,
getConfigDirFromHome,
convertClaudeToCodebuddyMarkdown,
convertClaudeCommandToCodebuddySkill,
convertClaudeAgentToCodebuddyAgent,
copyCommandsAsCodebuddySkills,
install,
uninstall,
writeManifest,
} = require('../bin/install.js');
describe('CodeBuddy runtime directory mapping', () => {
test('maps CodeBuddy to .codebuddy for local installs', () => {
assert.strictEqual(getDirName('codebuddy'), '.codebuddy');
});
test('maps CodeBuddy to ~/.codebuddy for global installs', () => {
assert.strictEqual(getGlobalDir('codebuddy'), path.join(os.homedir(), '.codebuddy'));
});
test('returns .codebuddy config fragments for local and global installs', () => {
assert.strictEqual(getConfigDirFromHome('codebuddy', false), "'.codebuddy'");
assert.strictEqual(getConfigDirFromHome('codebuddy', true), "'.codebuddy'");
});
});
describe('getGlobalDir (CodeBuddy)', () => {
let originalCodebuddyConfigDir;
beforeEach(() => {
originalCodebuddyConfigDir = process.env.CODEBUDDY_CONFIG_DIR;
});
afterEach(() => {
if (originalCodebuddyConfigDir !== undefined) {
process.env.CODEBUDDY_CONFIG_DIR = originalCodebuddyConfigDir;
} else {
delete process.env.CODEBUDDY_CONFIG_DIR;
}
});
test('returns ~/.codebuddy with no env var or explicit dir', () => {
delete process.env.CODEBUDDY_CONFIG_DIR;
const result = getGlobalDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), '.codebuddy'));
});
test('returns explicit dir when provided', () => {
const result = getGlobalDir('codebuddy', '/custom/codebuddy-path');
assert.strictEqual(result, '/custom/codebuddy-path');
});
test('respects CODEBUDDY_CONFIG_DIR env var', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/custom-codebuddy';
const result = getGlobalDir('codebuddy');
assert.strictEqual(result, path.join(os.homedir(), 'custom-codebuddy'));
});
test('explicit dir takes priority over CODEBUDDY_CONFIG_DIR', () => {
process.env.CODEBUDDY_CONFIG_DIR = '~/from-env';
const result = getGlobalDir('codebuddy', '/explicit/path');
assert.strictEqual(result, '/explicit/path');
});
test('does not break other runtimes', () => {
assert.strictEqual(getGlobalDir('claude'), path.join(os.homedir(), '.claude'));
assert.strictEqual(getGlobalDir('codex'), path.join(os.homedir(), '.codex'));
});
});
describe('CodeBuddy markdown conversion', () => {
test('converts Claude-specific references to CodeBuddy equivalents', () => {
const input = [
'Claude Code reads CLAUDE.md before using .claude/skills/.',
'Run /gsd:plan-phase with $ARGUMENTS.',
'Use Bash(command) and Edit(file).',
].join('\n');
const result = convertClaudeToCodebuddyMarkdown(input);
assert.ok(result.includes('CodeBuddy reads CODEBUDDY.md before using .codebuddy/skills/.'), result);
assert.ok(result.includes('/gsd-plan-phase'), result);
assert.ok(result.includes('{{GSD_ARGS}}'), result);
// CodeBuddy uses the same tool names as Claude Code — no conversion needed
assert.ok(result.includes('Bash('), result);
assert.ok(result.includes('Edit('), result);
});
test('converts commands and agents to CodeBuddy frontmatter', () => {
const command = `---
name: gsd:new-project
description: Initialize a project
---
Use .claude/skills/ and /gsd:help.
`;
const agent = `---
name: gsd-planner
description: Planner agent
tools: Read, Write
color: blue
---
Read CLAUDE.md before acting.
`;
const convertedCommand = convertClaudeCommandToCodebuddySkill(command, 'gsd-new-project');
const convertedAgent = convertClaudeAgentToCodebuddyAgent(agent);
assert.ok(convertedCommand.includes('name: gsd-new-project'), convertedCommand);
assert.ok(convertedCommand.includes('.codebuddy/skills/'), convertedCommand);
assert.ok(convertedCommand.includes('/gsd-help'), convertedCommand);
assert.ok(convertedAgent.includes('name: gsd-planner'), convertedAgent);
assert.ok(!convertedAgent.includes('color:'), convertedAgent);
assert.ok(convertedAgent.includes('CODEBUDDY.md'), convertedAgent);
});
});
describe('copyCommandsAsCodebuddySkills', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-codebuddy-copy-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('creates one skill directory per GSD command', () => {
const srcDir = path.join(__dirname, '..', 'commands', 'gsd');
const skillsDir = path.join(tmpDir, '.codebuddy', 'skills');
copyCommandsAsCodebuddySkills(srcDir, skillsDir, 'gsd', '$HOME/.codebuddy/', 'codebuddy');
const generated = path.join(skillsDir, 'gsd-help', 'SKILL.md');
assert.ok(fs.existsSync(generated), generated);
const content = fs.readFileSync(generated, 'utf8');
assert.ok(content.includes('name: gsd-help'), content);
});
});
describe('CodeBuddy local install/uninstall', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-codebuddy-install-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('installs GSD into ./.codebuddy and removes it cleanly', () => {
const result = install(false, 'codebuddy');
const targetDir = path.join(tmpDir, '.codebuddy');
// CodeBuddy supports settings.json hooks (Claude Code compatible)
assert.strictEqual(result.runtime, 'codebuddy');
assert.ok(result.settingsPath, 'should have settingsPath (CodeBuddy supports hooks)');
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'codebuddy');
assert.ok(Object.keys(manifest.files).some(file => file.startsWith('skills/gsd-help/')), JSON.stringify(manifest));
uninstall(false, 'codebuddy');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')), 'CodeBuddy skill directory removed');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')), 'get-shit-done removed');
});
});
describe('E2E: CodeBuddy uninstall skills cleanup', () => {
let tmpDir;
let previousCwd;
beforeEach(() => {
tmpDir = createTempDir('gsd-codebuddy-uninstall-');
previousCwd = process.cwd();
process.chdir(tmpDir);
});
afterEach(() => {
process.chdir(previousCwd);
cleanup(tmpDir);
});
test('removes all gsd-* skill directories on --codebuddy --uninstall', () => {
const targetDir = path.join(tmpDir, '.codebuddy');
install(false, 'codebuddy');
const skillsDir = path.join(targetDir, 'skills');
assert.ok(fs.existsSync(skillsDir), 'skills dir exists after install');
const installedSkills = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.ok(installedSkills.length > 0, `found ${installedSkills.length} gsd-* skill dirs before uninstall`);
uninstall(false, 'codebuddy');
if (fs.existsSync(skillsDir)) {
const remainingGsd = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(e => e.isDirectory() && e.name.startsWith('gsd-'));
assert.strictEqual(remainingGsd.length, 0,
`Expected 0 gsd-* skill dirs after uninstall, found: ${remainingGsd.map(e => e.name).join(', ')}`);
}
});
test('preserves non-GSD skill directories during --codebuddy --uninstall', () => {
const targetDir = path.join(tmpDir, '.codebuddy');
install(false, 'codebuddy');
const customSkillDir = path.join(targetDir, 'skills', 'my-custom-skill');
fs.mkdirSync(customSkillDir, { recursive: true });
fs.writeFileSync(path.join(customSkillDir, 'SKILL.md'), '# My Custom Skill\n');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')), 'custom skill exists before uninstall');
uninstall(false, 'codebuddy');
assert.ok(fs.existsSync(path.join(customSkillDir, 'SKILL.md')),
'Non-GSD skill directory should be preserved after CodeBuddy uninstall');
});
test('removes engine directory on --codebuddy --uninstall', () => {
const targetDir = path.join(tmpDir, '.codebuddy');
install(false, 'codebuddy');
assert.ok(fs.existsSync(path.join(targetDir, 'get-shit-done', 'VERSION')),
'engine exists before uninstall');
uninstall(false, 'codebuddy');
assert.ok(!fs.existsSync(path.join(targetDir, 'get-shit-done')),
'get-shit-done engine should be removed after CodeBuddy uninstall');
});
});

View File

@@ -139,8 +139,8 @@ describe('Source code integration (Copilot)', () => {
assert.ok(src.includes('--copilot'), 'help text has --copilot option');
});
test('CLI-02: promptRuntime runtimeMap has Copilot as option 5', () => {
assert.ok(src.includes("'5': 'copilot'"), 'runtimeMap has 5 -> copilot');
test('CLI-02: promptRuntime runtimeMap has Copilot as option 6', () => {
assert.ok(src.includes("'6': 'copilot'"), 'runtimeMap has 6 -> copilot');
});
test('CLI-02: promptRuntime allRuntimes array includes copilot', () => {

View File

@@ -221,12 +221,12 @@ describe('Source code integration (Kilo)', () => {
assert.ok(src.includes("'kilo'"), '--all includes kilo runtime');
});
test('promptRuntime runtimeMap has Kilo as option 8', () => {
assert.ok(src.includes("'8': 'kilo'"), 'runtimeMap has 8 -> kilo');
test('promptRuntime runtimeMap has Kilo as option 9', () => {
assert.ok(src.includes("'9': 'kilo'"), 'runtimeMap has 9 -> kilo');
});
test('prompt text shows Kilo above OpenCode without marketing copy', () => {
assert.ok(src.includes('8${reset}) Kilo'), 'prompt lists Kilo as option 8');
assert.ok(src.includes('9${reset}) Kilo'), 'prompt lists Kilo as option 9');
assert.ok(!src.includes('the #1 AI coding platform on OpenRouter'), 'prompt does not include marketing tagline');
});

View File

@@ -21,16 +21,17 @@ const runtimeMap = {
'1': 'claude',
'2': 'antigravity',
'3': 'augment',
'4': 'codex',
'5': 'copilot',
'6': 'cursor',
'7': 'gemini',
'8': 'kilo',
'9': 'opencode',
'10': 'trae',
'11': 'windsurf'
'4': 'codebuddy',
'5': 'codex',
'6': 'copilot',
'7': 'cursor',
'8': 'gemini',
'9': 'kilo',
'10': 'opencode',
'11': 'trae',
'12': 'windsurf'
};
const allRuntimes = ['claude', 'antigravity', 'augment', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'trae', 'windsurf'];
const allRuntimes = ['claude', 'antigravity', 'augment', 'codebuddy', 'codex', 'copilot', 'cursor', 'gemini', 'kilo', 'opencode', 'trae', 'windsurf'];
/**
* Simulate the parsing logic from promptRuntime without requiring readline.
@@ -39,7 +40,7 @@ const allRuntimes = ['claude', 'antigravity', 'augment', 'codex', 'copilot', 'cu
function parseRuntimeInput(input) {
input = input.trim() || '1';
if (input === '12') {
if (input === '13') {
return allRuntimes;
}
@@ -60,41 +61,42 @@ describe('multi-runtime selection parsing', () => {
assert.deepStrictEqual(parseRuntimeInput('1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('2'), ['antigravity']);
assert.deepStrictEqual(parseRuntimeInput('3'), ['augment']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('5'), ['copilot']);
assert.deepStrictEqual(parseRuntimeInput('6'), ['cursor']);
assert.deepStrictEqual(parseRuntimeInput('4'), ['codebuddy']);
assert.deepStrictEqual(parseRuntimeInput('5'), ['codex']);
assert.deepStrictEqual(parseRuntimeInput('6'), ['copilot']);
assert.deepStrictEqual(parseRuntimeInput('7'), ['cursor']);
});
test('comma-separated choices return multiple runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('1,5,7'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('1,6,8'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('2,3'), ['antigravity', 'augment']);
assert.deepStrictEqual(parseRuntimeInput('3,4'), ['augment', 'codex']);
assert.deepStrictEqual(parseRuntimeInput('3,5'), ['augment', 'codex']);
});
test('space-separated choices return multiple runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('1 5 7'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('6 8'), ['cursor', 'kilo']);
assert.deepStrictEqual(parseRuntimeInput('1 6 8'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('7 9'), ['cursor', 'kilo']);
});
test('mixed comma and space separators work', () => {
assert.deepStrictEqual(parseRuntimeInput('1, 5, 7'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('2 , 6'), ['antigravity', 'cursor']);
assert.deepStrictEqual(parseRuntimeInput('1, 6, 8'), ['claude', 'copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('2 , 7'), ['antigravity', 'cursor']);
});
test('single choice for opencode', () => {
assert.deepStrictEqual(parseRuntimeInput('9'), ['opencode']);
assert.deepStrictEqual(parseRuntimeInput('10'), ['opencode']);
});
test('single choice for trae', () => {
assert.deepStrictEqual(parseRuntimeInput('10'), ['trae']);
assert.deepStrictEqual(parseRuntimeInput('11'), ['trae']);
});
test('single choice for windsurf', () => {
assert.deepStrictEqual(parseRuntimeInput('11'), ['windsurf']);
assert.deepStrictEqual(parseRuntimeInput('12'), ['windsurf']);
});
test('choice 12 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('12'), allRuntimes);
test('choice 13 returns all runtimes', () => {
assert.deepStrictEqual(parseRuntimeInput('13'), allRuntimes);
});
test('empty input defaults to claude', () => {
@@ -103,29 +105,29 @@ describe('multi-runtime selection parsing', () => {
});
test('invalid choices are ignored, falls back to claude if all invalid', () => {
assert.deepStrictEqual(parseRuntimeInput('13'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('14'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('0'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('abc'), ['claude']);
});
test('invalid choices mixed with valid are filtered out', () => {
assert.deepStrictEqual(parseRuntimeInput('1,13,5'), ['claude', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('1,14,6'), ['claude', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('abc 3 xyz'), ['augment']);
});
test('duplicate choices are deduplicated', () => {
assert.deepStrictEqual(parseRuntimeInput('1,1,1'), ['claude']);
assert.deepStrictEqual(parseRuntimeInput('5,5,7,7'), ['copilot', 'gemini']);
assert.deepStrictEqual(parseRuntimeInput('6,6,8,8'), ['copilot', 'gemini']);
});
test('preserves selection order', () => {
assert.deepStrictEqual(parseRuntimeInput('7,1,5'), ['gemini', 'claude', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('8,2,6'), ['kilo', 'antigravity', 'cursor']);
assert.deepStrictEqual(parseRuntimeInput('8,1,6'), ['gemini', 'claude', 'copilot']);
assert.deepStrictEqual(parseRuntimeInput('9,2,7'), ['kilo', 'antigravity', 'cursor']);
});
});
describe('install.js source contains multi-select support', () => {
test('runtimeMap is defined with all 11 runtimes', () => {
test('runtimeMap is defined with all 12 runtimes', () => {
for (const [key, name] of Object.entries(runtimeMap)) {
assert.ok(
installSrc.includes(`'${key}': '${name}'`),
@@ -142,21 +144,21 @@ describe('install.js source contains multi-select support', () => {
}
});
test('all shortcut uses option 12', () => {
test('all shortcut uses option 13', () => {
assert.ok(
installSrc.includes("if (input === '12')"),
'all shortcut uses option 12'
installSrc.includes("if (input === '13')"),
'all shortcut uses option 13'
);
});
test('prompt lists Trae as option 10 and All as option 12', () => {
test('prompt lists Trae as option 11 and All as option 13', () => {
assert.ok(
installSrc.includes('10${reset}) Trae'),
'prompt lists Trae as option 10'
installSrc.includes('11${reset}) Trae'),
'prompt lists Trae as option 11'
);
assert.ok(
installSrc.includes('12${reset}) All'),
'prompt lists All as option 12'
installSrc.includes('13${reset}) All'),
'prompt lists All as option 13'
);
});

View File

@@ -66,6 +66,7 @@ function isTestInput(filePath, line) {
'tests/copilot-install.test.cjs',
'tests/codex-config.test.cjs',
'tests/trae-install.test.cjs',
'tests/codebuddy-install.test.cjs',
];
if (conversionTestFiles.includes(rel)) {