feat(setup): add Factory Droid (MCP + skills) to gitnexus setup

Register 'droid' in the editor-targets abstraction so `gitnexus setup -c droid`
writes the MCP server to ~/.factory/mcp.json and installs skills to
~/.factory/skills/ from the single canonical skills/ source (no per-editor
copies). uninstall.ts is target-driven, so removal is covered automatically.
Adds unit + round-trip coverage.
This commit is contained in:
joseph02 2026-07-17 17:13:05 -07:00
parent ed8ab1c246
commit d48fd4712a
6 changed files with 149 additions and 4 deletions

View file

@ -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[] = [

View file

@ -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 <agents>',

View file

@ -95,6 +95,7 @@ const CODING_AGENT_IDS = {
codebuddy: 'codebuddy',
qoder: 'qoder',
codex: 'codex',
droid: 'droid',
} as const satisfies Record<EditorId, EditorId>;
const SUPPORTED_CODING_AGENTS = Object.values(CODING_AGENT_IDS);
@ -951,6 +952,59 @@ async function installQoderSkills(result: SetupResult): Promise<void> {
}
}
/**
* Configure the GitNexus MCP server for Factory Droid.
*
* Factory stores user-scope MCP config in ~/.factory/mcp.json using the same
* `{ mcpServers: { <name>: {...} } }` 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<void> {
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<void> {
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) {

View file

@ -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 });

View file

@ -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(

View file

@ -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;