mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(setup): add CodeBuddy and Qoder coding-agent integrations Adds Tencent CodeBuddy and Alibaba Qoder to gitnexus setup/uninstall, fitted to the editor-targets registry and --coding-agent selection. - CodeBuddy: MCP entry written into the first existing file of its documented priority chain (~/.codebuddy/.mcp.json recommended, ~/.codebuddy/mcp.json deprecated, ~/.codebuddy.json legacy) so a populated deprecated config is never shadowed; skills to ~/.codebuddy/skills/ (https://www.codebuddy.ai/docs/cli/mcp) - Qoder: MCP entry in ~/.qoder.json, skills to ~/.qoder/skills/ (https://docs.qoder.com/cli/using-cli, /extensions/skills) - editor-targets gains optional legacyFiles; uninstall sweeps them - roster strings updated (CLI help, i18n en/zh-CN, READMEs); en/zh-CN setup descriptions were stale (missing Antigravity) and are refreshed Supersedes and credits PR #1030 by @zykai0302, re-fitted to the post-#2168 selective-agent architecture with documented config paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(cli): assert stable zh-CN setup-description fragment * fix(setup): surface non-ENOENT config read/stat failures instead of clobbering * fix(setup): report corrupt legacy MCP files informationally during uninstall * test(setup): cover multi-candidate uninstall sweep combinations * fix(setup): detect CodeBuddy/Qoder installs via existing MCP config files * fix(setup): skip empty and non-file candidates in the MCP config chain * docs: add CodeBuddy and Qoder manual MCP configuration sections * test(ci): run the setup-uninstall round-trip in the cross-platform matrix * fix(setup): never claim "not configured" when uninstall recorded errors * refactor(cli): share the isEnoent predicate via editor-targets * refactor(setup): share chain-file install detection between CodeBuddy and Qoder --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
151 lines
6 KiB
TypeScript
151 lines
6 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'fs/promises';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
|
|
const execFileMock = vi.fn((...args: any[]) => {
|
|
const callback = args.at(-1);
|
|
if (typeof callback === 'function') callback(null, '', '');
|
|
});
|
|
|
|
vi.mock('child_process', () => ({
|
|
execFile: execFileMock,
|
|
execFileSync: vi.fn(() => {
|
|
throw new Error('not found');
|
|
}),
|
|
}));
|
|
|
|
describe('setupCommand coding-agent selection', () => {
|
|
let tempHome: string;
|
|
let originalHome: string | undefined;
|
|
let originalUserProfile: string | undefined;
|
|
let originalExitCode: number | string | null | undefined;
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.clearAllMocks();
|
|
originalHome = process.env.HOME;
|
|
originalUserProfile = process.env.USERPROFILE;
|
|
originalExitCode = process.exitCode;
|
|
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-setup-selection-'));
|
|
process.env.HOME = tempHome;
|
|
process.env.USERPROFILE = tempHome;
|
|
process.exitCode = undefined;
|
|
await Promise.all([
|
|
fs.mkdir(path.join(tempHome, '.cursor'), { recursive: true }),
|
|
fs.mkdir(path.join(tempHome, '.claude'), { recursive: true }),
|
|
fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true }),
|
|
fs.mkdir(path.join(tempHome, '.config', 'opencode'), { recursive: true }),
|
|
fs.mkdir(path.join(tempHome, '.codex'), { recursive: true }),
|
|
]);
|
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
process.env.HOME = originalHome;
|
|
process.env.USERPROFILE = originalUserProfile;
|
|
process.exitCode = originalExitCode;
|
|
await fs.rm(tempHome, { recursive: true, force: true });
|
|
});
|
|
|
|
it('explicit -c codebuddy succeeds when only a legacy root config exists (no dot-dir)', async () => {
|
|
const legacy = path.join(tempHome, '.codebuddy.json');
|
|
await fs.writeFile(
|
|
legacy,
|
|
JSON.stringify({ mcpServers: { other: { command: 'foo' } } }),
|
|
'utf-8',
|
|
);
|
|
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand({ codingAgent: ['codebuddy'] });
|
|
|
|
const config = JSON.parse(await fs.readFile(legacy, 'utf-8'));
|
|
expect(config.mcpServers.gitnexus).toBeDefined();
|
|
expect(config.mcpServers.other).toEqual({ command: 'foo' });
|
|
// Explicit selection that configures something must not exit 1.
|
|
expect(process.exitCode).not.toBe(1);
|
|
});
|
|
|
|
it('configures only the requested coding agent', async () => {
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand({ codingAgent: ['opencode'] });
|
|
|
|
await expect(
|
|
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
|
|
).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).rejects.toThrow();
|
|
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
|
|
await expect(
|
|
fs.access(path.join(tempHome, '.gemini', 'antigravity', 'mcp_config.json')),
|
|
).rejects.toThrow();
|
|
await expect(fs.access(path.join(tempHome, '.codex', 'config.toml'))).rejects.toThrow();
|
|
});
|
|
|
|
it('accepts comma-separated and repeated selections without configuring others', async () => {
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand({ codingAgent: ['cursor,opencode', 'cursor'] });
|
|
|
|
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).resolves.toBeUndefined();
|
|
await expect(
|
|
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
|
|
).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(tempHome, '.claude.json'))).rejects.toThrow();
|
|
});
|
|
|
|
it('rejects unknown values before writing configuration', async () => {
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
await setupCommand({ codingAgent: ['opencode,unknown'] });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex',
|
|
),
|
|
);
|
|
await expect(
|
|
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it.each([
|
|
['an empty string', ''],
|
|
['an empty array', []],
|
|
])('rejects %s before writing configuration', async (_label, codingAgent) => {
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
|
|
await setupCommand({ codingAgent });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr).toHaveBeenCalledWith(expect.stringContaining('No coding agents were provided.'));
|
|
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).rejects.toThrow();
|
|
});
|
|
|
|
it('fails clearly when an explicitly selected agent is not installed', async () => {
|
|
await fs.rm(path.join(tempHome, '.codex'), { recursive: true, force: true });
|
|
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
|
|
await setupCommand({ codingAgent: ['codex'] });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(stderr).toHaveBeenCalledWith(
|
|
'None of the explicitly selected coding agents were configured.\n',
|
|
);
|
|
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).not.toContain('MCP is ready!');
|
|
});
|
|
|
|
it('preserves the no-flag default of configuring every detected agent', async () => {
|
|
const { setupCommand } = await import('../../src/cli/setup.js');
|
|
|
|
await setupCommand();
|
|
|
|
await expect(fs.access(path.join(tempHome, '.cursor', 'mcp.json'))).resolves.toBeUndefined();
|
|
await expect(fs.access(path.join(tempHome, '.claude.json'))).resolves.toBeUndefined();
|
|
await expect(
|
|
fs.access(path.join(tempHome, '.config', 'opencode', 'opencode.json')),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
});
|