diff --git a/gitnexus/src/cli/editor-targets.ts b/gitnexus/src/cli/editor-targets.ts index a4c511fa0..f46193c76 100644 --- a/gitnexus/src/cli/editor-targets.ts +++ b/gitnexus/src/cli/editor-targets.ts @@ -26,7 +26,8 @@ export type EditorId = | 'opencode' | 'codebuddy' | 'qoder' - | 'codex'; + | 'codex' + | 'droid'; /** An editor whose MCP config is a JSONC document (server keyed by name). */ export interface McpJsoncTarget { @@ -145,6 +146,15 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { file: path.join(home, '.qoder.json'), keyPath: ['mcpServers', 'gitnexus'], }, + { + id: 'droid', + label: 'Factory Droid', + // Factory's user-scope MCP config (https://docs.factory.ai/cli/configuration/mcp). + // Same `mcpServers` object shape as Cursor/Claude; user config takes + // precedence over project-level .factory/mcp.json. + file: path.join(home, '.factory', 'mcp.json'), + keyPath: ['mcpServers', 'gitnexus'], + }, ]; const codex: CodexMcpTarget = { @@ -169,6 +179,9 @@ export function getEditorTargets(home: string = os.homedir()): EditorTargets { { id: 'qoder', label: 'Qoder', dir: path.join(home, '.qoder', 'skills') }, // Codex reads skills from ~/.agents/skills (not ~/.codex). { id: 'codex', label: 'Codex', dir: path.join(home, '.agents', 'skills') }, + // Factory Droid reads user-scope skills from ~/.factory/skills/{name}/SKILL.md + // (https://docs.factory.ai/cli/configuration/skills). + { id: 'droid', label: 'Factory Droid', dir: path.join(home, '.factory', 'skills') }, ]; const hooks: HookTarget[] = [ diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index b92cc5a26..d3a3a48ac 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -24,7 +24,7 @@ program.name('gitnexus').description('GitNexus local CLI and MCP server').versio program .command('setup') .description( - 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex', + 'One-time setup: configure MCP for Cursor, Claude Code, Antigravity, OpenCode, CodeBuddy, Qoder, Codex, Factory Droid', ) .option( '-c, --coding-agent ', diff --git a/gitnexus/src/cli/setup.ts b/gitnexus/src/cli/setup.ts index 3088ec3da..12e617f24 100644 --- a/gitnexus/src/cli/setup.ts +++ b/gitnexus/src/cli/setup.ts @@ -95,6 +95,7 @@ const CODING_AGENT_IDS = { codebuddy: 'codebuddy', qoder: 'qoder', codex: 'codex', + droid: 'droid', } as const satisfies Record; const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS); @@ -951,6 +952,59 @@ async function installQoderSkills(result: SetupResult): Promise { } } +/** + * Configure the GitNexus MCP server for Factory Droid. + * + * Factory stores user-scope MCP config in ~/.factory/mcp.json using the same + * `{ mcpServers: { : {...} } }` JSONC shape as Cursor/Claude + * (https://docs.factory.ai/cli/configuration/mcp), so we reuse mergeJsoncFile + * rather than shelling out to `droid mcp add` — that keeps setup working even + * when the `droid` binary isn't on PATH. + */ +async function setupDroid(result: SetupResult): Promise { + const factoryDir = path.join(os.homedir(), '.factory'); + if (!(await dirExists(factoryDir))) { + result.skipped.push('Factory Droid (not installed)'); + return; + } + + const { file: mcpPath, keyPath } = mcpTarget('droid'); + try { + const ok = await mergeJsoncFile(mcpPath, keyPath, getMcpEntry()); + if (ok) { + result.configured.push('Factory Droid'); + } else { + result.errors.push( + 'Factory Droid: mcp.json is corrupt — skipping to preserve existing content', + ); + } + } catch (err: any) { + result.errors.push(`Factory Droid: ${err.message}`); + } +} + +/** + * Install global Factory Droid skills to ~/.factory/skills/{name}/SKILL.md + * (https://docs.factory.ai/cli/configuration/skills — same SKILL.md layout as + * Claude Code). + */ +async function installDroidSkills(result: SetupResult): Promise { + const factoryDir = path.join(os.homedir(), '.factory'); + if (!(await dirExists(factoryDir))) return; + + const skillsDir = skillTarget('droid').dir; + try { + const installed = await installSkillsTo(skillsDir); + if (installed.length > 0) { + result.configured.push( + `Factory Droid skills (${installed.length} skills → ~/.factory/skills/)`, + ); + } + } catch (err: any) { + result.errors.push(`Factory Droid skills: ${err.message}`); + } +} + /** * Build a TOML section for Codex MCP config (~/.codex/config.toml). */ @@ -1197,6 +1251,7 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string } if (selected.has('codebuddy')) await setupCodeBuddy(result); if (selected.has('qoder')) await setupQoder(result); if (selected.has('codex')) await setupCodex(result); + if (selected.has('droid')) await setupDroid(result); // Install global skills for platforms that support them if (selected.has('claude')) { @@ -1215,6 +1270,10 @@ export const setupCommand = async (options?: { codingAgent?: string[] | string } await installCodexSkills(result); await installClaudeSchemaHooks(result, 'codex'); } + // MCP + skills only. Factory hooks (Execute matcher, PostToolUse) ship in the + // standalone gitnexus-factory-plugin instead of the setup path — add a droid + // hook target + adapter here if setup-installed hooks are wanted. + if (selected.has('droid')) await installDroidSkills(result); // Print results if (result.configured.length > 0) { diff --git a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts index b72774372..100b1007a 100644 --- a/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts +++ b/gitnexus/test/integration/setup-uninstall-roundtrip.test.ts @@ -82,7 +82,7 @@ describe('setup → uninstall round-trip', () => { process.env.USERPROFILE = tempHome; // Mark every editor as "installed" so setup configures all of them. - for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder']) { + for (const dir of ['.cursor', '.claude', '.codex', '.codebuddy', '.qoder', '.factory']) { await fs.mkdir(path.join(tempHome, dir), { recursive: true }); } await fs.mkdir(path.join(tempHome, '.gemini', 'antigravity'), { recursive: true }); diff --git a/gitnexus/test/unit/setup-selection.test.ts b/gitnexus/test/unit/setup-selection.test.ts index 7978ec2dc..a57689550 100644 --- a/gitnexus/test/unit/setup-selection.test.ts +++ b/gitnexus/test/unit/setup-selection.test.ts @@ -101,7 +101,7 @@ describe('setupCommand coding-agent selection', () => { expect(process.exitCode).toBe(1); expect(stderr).toHaveBeenCalledWith( expect.stringContaining( - 'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex', + 'Valid values: cursor, claude, antigravity, opencode, codebuddy, qoder, codex, droid', ), ); await expect( diff --git a/gitnexus/test/unit/setup.test.ts b/gitnexus/test/unit/setup.test.ts index 448074669..314b771cc 100644 --- a/gitnexus/test/unit/setup.test.ts +++ b/gitnexus/test/unit/setup.test.ts @@ -715,6 +715,79 @@ describe('setupQoder', () => { }); }); +describe('setupDroid (Factory)', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalUserProfile: string | undefined; + + const configPath = () => path.join(tempHome, '.factory', 'mcp.json'); + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + originalHome = process.env.HOME; + originalUserProfile = process.env.USERPROFILE; + tempHome = await fs.mkdtemp(path.join(os.tmpdir(), 'gn-droid-setup-')); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + + // Only create ~/.factory so other editors skip and don't pollute assertions. + await fs.mkdir(path.join(tempHome, '.factory'), { recursive: true }); + + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + process.env.HOME = originalHome; + process.env.USERPROFILE = originalUserProfile; + await fs.rm(tempHome, { recursive: true, force: true }); + }); + + it('writes the MCP entry to ~/.factory/mcp.json under mcpServers', async () => { + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const config = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('preserves existing servers in ~/.factory/mcp.json', async () => { + await fs.writeFile( + configPath(), + JSON.stringify({ mcpServers: { other: { command: 'foo' } } }), + 'utf-8', + ); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + const config = JSON.parse(await fs.readFile(configPath(), 'utf-8')); + expect(config.mcpServers.other).toEqual({ command: 'foo' }); + expect(config.mcpServers.gitnexus).toBeDefined(); + }); + + it('skips when ~/.factory directory does not exist', async () => { + await fs.rm(path.join(tempHome, '.factory'), { recursive: true, force: true }); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + await expect(fs.access(configPath())).rejects.toThrow(); + }); + + it('leaves a corrupt ~/.factory/mcp.json untouched', async () => { + const corrupt = '{ this is not valid json !!!'; + await fs.writeFile(configPath(), corrupt, 'utf-8'); + + const { setupCommand } = await import('../../src/cli/setup.js'); + await setupCommand(); + + expect(await fs.readFile(configPath(), 'utf-8')).toBe(corrupt); + }); +}); + describe('Codex hooks (installClaudeSchemaHooks)', () => { let tempHome: string; let originalHome: string | undefined;