mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-07 02:58:02 +00:00
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:
parent
ed8ab1c246
commit
d48fd4712a
6 changed files with 149 additions and 4 deletions
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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>',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue