From 419daf776be8529910e59d70d43cf58c7992ace3 Mon Sep 17 00:00:00 2001 From: zykai0302 <15757164829zyk@gmail.com> Date: Thu, 23 Apr 2026 00:23:32 +0800 Subject: [PATCH] feat: add CodeBuddy and Qoder IDE support --- gitnexus/src/cli/ai-context.ts | 8 +- gitnexus/src/cli/setup.ts | 78 +++++++++ gitnexus/test/unit/ai-context.test.ts | 26 ++- gitnexus/test/unit/setup.test.ts | 228 ++++++++++++++++++++++++++ 4 files changed, 324 insertions(+), 16 deletions(-) diff --git a/gitnexus/src/cli/ai-context.ts b/gitnexus/src/cli/ai-context.ts index 984a16f7b..7a93338cf 100644 --- a/gitnexus/src/cli/ai-context.ts +++ b/gitnexus/src/cli/ai-context.ts @@ -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 }; } diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 8263405a5..d4b188413 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -310,6 +310,42 @@ async function upsertCodexConfigToml(configPath: string): Promise { await fs.writeFile(configPath, `${nextContent.trimEnd()}\n`, 'utf-8'); } +async function setupCodeBuddy(result: SetupResult): Promise { + 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 { + 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 { const codexDir = path.join(os.homedir(), '.codex'); if (!(await dirExists(codexDir))) { @@ -453,6 +489,44 @@ async function installOpenCodeSkills(result: SetupResult): Promise { } } +/** + * Install global CodeBuddy skills to ~/.codebuddy/skills/ + */ +async function installCodeBuddySkills(result: SetupResult): Promise { + 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 { + 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 diff --git a/gitnexus/test/unit/ai-context.test.ts b/gitnexus/test/unit/ai-context.test.ts index 7a8ed0e9b..46af1e015 100644 --- a/gitnexus/test/unit/ai-context.test.ts +++ b/gitnexus/test/unit/ai-context.test.ts @@ -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 () => { diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index a8e05fa79..6a896bd95 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -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); + }); +});