feat: add CodeBuddy and Qoder IDE support

This commit is contained in:
zykai0302 2026-04-23 00:23:32 +08:00
parent ea418c0126
commit 419daf776b
4 changed files with 324 additions and 16 deletions

View file

@ -2,7 +2,7 @@
* AI Context Generator
*
* Creates AGENTS.md and CLAUDE.md with full inline GitNexus context.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, etc.
* AGENTS.md is the standard read by Cursor, Windsurf, OpenCode, Codex, Cline, CodeBuddy, Qoder, etc.
* CLAUDE.md is for Claude Code which only reads that file.
*/
@ -300,11 +300,5 @@ export async function generateAIContextFiles(
createdFiles.push('CLAUDE.md (skipped via --skip-agents-md)');
}
// Install skills to .claude/skills/gitnexus/
const installedSkills = await installSkills(repoPath);
if (installedSkills.length > 0) {
createdFiles.push(`.claude/skills/gitnexus/ (${installedSkills.length} skills)`);
}
return { files: createdFiles };
}

View file

@ -310,6 +310,42 @@ async function upsertCodexConfigToml(configPath: string): Promise<void> {
await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8');
}
async function setupCodeBuddy(result: SetupResult): Promise<void> {
const codebuddyDir = path.join(os.homedir(), '.codebuddy');
if (!(await dirExists(codebuddyDir))) {
result.skipped.push('CodeBuddy (not installed)');
return;
}
const mcpPath = path.join(codebuddyDir, 'mcp.json');
try {
const existing = await readJsonFile(mcpPath);
const updated = mergeMcpConfig(existing);
await writeJsonFile(mcpPath, updated);
result.configured.push('CodeBuddy');
} catch (err: any) {
result.errors.push(`CodeBuddy: ${err.message}`);
}
}
async function setupQoder(result: SetupResult): Promise<void> {
const qoderDir = path.join(os.homedir(), '.qoder');
if (!(await dirExists(qoderDir))) {
result.skipped.push('Qoder (not installed)');
return;
}
const mcpPath = path.join(qoderDir, 'mcp.json');
try {
const existing = await readJsonFile(mcpPath);
const updated = mergeMcpConfig(existing);
await writeJsonFile(mcpPath, updated);
result.configured.push('Qoder');
} catch (err: any) {
result.errors.push(`Qoder: ${err.message}`);
}
}
async function setupCodex(result: SetupResult): Promise<void> {
const codexDir = path.join(os.homedir(), '.codex');
if (!(await dirExists(codexDir))) {
@ -453,6 +489,44 @@ async function installOpenCodeSkills(result: SetupResult): Promise<void> {
}
}
/**
* Install global CodeBuddy skills to ~/.codebuddy/skills/
*/
async function installCodeBuddySkills(result: SetupResult): Promise<void> {
const codebuddyDir = path.join(os.homedir(), '.codebuddy');
if (!(await dirExists(codebuddyDir))) return;
const skillsDir = path.join(codebuddyDir, 'skills');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(
`CodeBuddy skills (${installed.length} skills → ~/.codebuddy/skills/)`,
);
}
} catch (err: any) {
result.errors.push(`CodeBuddy skills: ${err.message}`);
}
}
/**
* Install global Qoder skills to ~/.qoder/skills/
*/
async function installQoderSkills(result: SetupResult): Promise<void> {
const qoderDir = path.join(os.homedir(), '.qoder');
if (!(await dirExists(qoderDir))) return;
const skillsDir = path.join(qoderDir, 'skills');
try {
const installed = await installSkillsTo(skillsDir);
if (installed.length > 0) {
result.configured.push(`Qoder skills (${installed.length} skills → ~/.qoder/skills/)`);
}
} catch (err: any) {
result.errors.push(`Qoder skills: ${err.message}`);
}
}
/**
* Install global Codex skills to ~/.agents/skills/gitnexus/
*/
@ -493,6 +567,8 @@ export const setupCommand = async () => {
await setupCursor(result);
await setupClaudeCode(result);
await setupOpenCode(result);
await setupCodeBuddy(result);
await setupQoder(result);
await setupCodex(result);
// Install global skills for platforms that support them
@ -500,6 +576,8 @@ export const setupCommand = async () => {
await installClaudeCodeHooks(result);
await installCursorSkills(result);
await installOpenCodeSkills(result);
await installCodeBuddySkills(result);
await installQoderSkills(result);
await installCodexSkills(result);
// Print results

View file

@ -123,18 +123,26 @@ describe('generateAIContextFiles', () => {
expect(starts).toBe(1);
});
it('installs skills files', async () => {
it('does not install skills to .claude/skills/gitnexus/', async () => {
const stats = { nodes: 10 };
await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
// Should NOT create .claude/skills/gitnexus/ during analyze
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
const exists = await fs
.stat(skillsDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
it('does not install skills to ~/.codebuddy/skills/ or ~/.qoder/skills/', async () => {
const stats = { nodes: 10 };
const result = await generateAIContextFiles(tmpDir, storagePath, 'TestProject', stats);
// Should have installed skill files
const skillsDir = path.join(tmpDir, '.claude', 'skills', 'gitnexus');
try {
const entries = await fs.readdir(skillsDir, { recursive: true });
expect(entries.length).toBeGreaterThan(0);
} catch {
// Skills dir may not be created if skills source doesn't exist in test context
}
// analyze should not install any skills
expect(result.files.find((f) => f.includes('.codebuddy/skills/'))).toBeUndefined();
expect(result.files.find((f) => f.includes('.qoder/skills/'))).toBeUndefined();
});
it('preserves manual AGENTS.md and CLAUDE.md edits when skipAgentsMd is enabled', async () => {

View file

@ -192,3 +192,231 @@ describe('setupClaudeCode', () => {
});
});
});
describe('setupCodeBuddy', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let platformDescriptor: PropertyDescriptor | undefined;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-codebuddy-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Create ~/.codebuddy for both MCP config and skills
await fs.mkdir(path.join(tempHome, '.codebuddy'), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes MCP config to ~/.codebuddy/mcp.json', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.codebuddy', 'mcp.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('skips when ~/.codebuddy directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(path.join(tempHome, '.codebuddy', 'mcp.json'))).rejects.toThrow();
});
it('preserves existing config in ~/.codebuddy/mcp.json', async () => {
setPlatform('linux');
await fs.writeFile(
path.join(tempHome, '.codebuddy', 'mcp.json'),
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.codebuddy', 'mcp.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('installs skills to ~/.codebuddy/skills/', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.codebuddy', 'skills');
const exists = await fs
.stat(skillsDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
});
it('skips skills when ~/.codebuddy/ does not exist', async () => {
setPlatform('linux');
await fs.rm(path.join(tempHome, '.codebuddy'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.codebuddy', 'skills');
const exists = await fs
.stat(skillsDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});
describe('setupQoder', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalUserProfile: string | undefined;
let platformDescriptor: PropertyDescriptor | undefined;
const setPlatform = (value: NodeJS.Platform) => {
Object.defineProperty(process, 'platform', {
value,
configurable: true,
});
};
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
originalHome = process.env.HOME;
originalUserProfile = process.env.USERPROFILE;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-qoder-setup-'));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
// Create ~/.qoder for both MCP config and skills
await fs.mkdir(path.join(tempHome, '.qoder'), { recursive: true });
platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
if (platformDescriptor) {
Object.defineProperty(process, 'platform', platformDescriptor);
}
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
await fs.rm(tempHome, { recursive: true, force: true });
});
it('writes MCP config to ~/.qoder/mcp.json', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.qoder', 'mcp.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('skips when ~/.qoder directory does not exist', async () => {
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
await expect(fs.access(path.join(tempHome, '.qoder', 'mcp.json'))).rejects.toThrow();
});
it('preserves existing config in ~/.qoder/mcp.json', async () => {
setPlatform('linux');
await fs.writeFile(
path.join(tempHome, '.qoder', 'mcp.json'),
JSON.stringify({ existingKey: 'keep-me', mcpServers: { other: { command: 'foo' } } }),
'utf-8',
);
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const raw = await fs.readFile(path.join(tempHome, '.qoder', 'mcp.json'), 'utf-8');
const config = JSON.parse(raw);
expect(config.existingKey).toBe('keep-me');
expect(config.mcpServers.other).toEqual({ command: 'foo' });
expect(config.mcpServers.gitnexus).toBeDefined();
});
it('installs skills to ~/.qoder/skills/', async () => {
setPlatform('linux');
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.qoder', 'skills');
const exists = await fs
.stat(skillsDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
});
it('skips skills when ~/.qoder/ does not exist', async () => {
setPlatform('linux');
await fs.rm(path.join(tempHome, '.qoder'), { recursive: true, force: true });
const { setupCommand } = await import('../../src/cli/setup.js');
await setupCommand();
const skillsDir = path.join(tempHome, '.qoder', 'skills');
const exists = await fs
.stat(skillsDir)
.then(() => true)
.catch(() => false);
expect(exists).toBe(false);
});
});