Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

### New Features

- Factory Droids (the `droid` CLI) is now supported by `codegraph install`, detected and configured out of the box so the Droid agent can drive the knowledge graph like the other agents.
- `codegraph status --json` now also reports the running CLI `version`, the index directory (`indexPath`), and a `lastIndexed` timestamp (ISO-8601, or null when nothing's indexed yet), so CI and scripts can pin the CLI version and check index freshness from a single command. A matching `CodeGraph.getLastIndexedAt()` library method exposes the same freshness check without shelling out. Thanks @12122J and @eddieran. (#329)

### Fixes
Expand Down
74 changes: 74 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,79 @@ describe('Installer targets — partial-state idempotency', () => {
expect(paths.some((p) => p.endsWith('/.kiro/steering/codegraph.md'))).toBe(false);
});

it('factory: install writes ~/.factory/mcp.json (mcpServers.codegraph)', () => {
const factory = getTarget('factory')!;
const result = factory.install('global', { autoAllow: true });
const mcp = path.join(tmpHome, '.factory', 'mcp.json');
expect(result.files.some((f) => f.path === mcp)).toBe(true);
expect(fs.existsSync(mcp)).toBe(true);

const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(cfg.mcpServers.codegraph).toEqual({
type: 'stdio',
command: 'codegraph',
args: ['serve', '--mcp'],
});
});

it('factory: install preserves a pre-existing sibling MCP server in mcp.json', () => {
const factory = getTarget('factory')!;
const mcp = path.join(tmpHome, '.factory', 'mcp.json');
fs.mkdirSync(path.dirname(mcp), { recursive: true });
fs.writeFileSync(
mcp,
JSON.stringify({
mcpServers: { sibling: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n'
);

factory.install('global', { autoAllow: true });

const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(cfg.mcpServers.sibling).toEqual({ command: 'uvx', args: ['other-server'] });
expect(cfg.mcpServers.codegraph).toBeDefined();
});

it('factory: uninstall strips codegraph but leaves sibling MCP servers intact', () => {
const factory = getTarget('factory')!;
const mcp = path.join(tmpHome, '.factory', 'mcp.json');
fs.mkdirSync(path.dirname(mcp), { recursive: true });
fs.writeFileSync(
mcp,
JSON.stringify({
mcpServers: { sibling: { command: 'uvx', args: ['other-server'] } },
}, null, 2) + '\n'
);

factory.install('global', { autoAllow: true });
const result = factory.uninstall('global');

const cfg = JSON.parse(fs.readFileSync(mcp, 'utf-8'));
expect(cfg.mcpServers.sibling).toEqual({ command: 'uvx', args: ['other-server'] });
expect(cfg.mcpServers.codegraph).toBeUndefined();
expect(result.files).toContainEqual({ path: mcp, action: 'updated' });
});

it('factory: uninstall deletes the mcp.json file when no other content remains', () => {
const factory = getTarget('factory')!;
const mcp = path.join(tmpHome, '.factory', 'mcp.json');

factory.install('global', { autoAllow: true });
expect(fs.existsSync(mcp)).toBe(true);

const result = factory.uninstall('global');
expect(fs.existsSync(mcp)).toBe(false);
expect(result.files).toContainEqual({ path: mcp, action: 'removed' });
});


it('factory: local install writes ./.factory/mcp.json', () => {
const factory = getTarget('factory')!;
const result = factory.install('local', { autoAllow: true });
const paths = result.files.map((f) => f.path.replace(/\\/g, '/'));
expect(paths.some((p) => p.endsWith('/.factory/mcp.json'))).toBe(true);
});

it('antigravity: install writes to LEGACY ~/.gemini/antigravity/mcp_config.json when no migration marker', () => {
const antigravity = getTarget('antigravity')!;
antigravity.install('global', { autoAllow: true });
Expand Down Expand Up @@ -1098,6 +1171,7 @@ describe('Installer targets — registry', () => {
expect(getTarget('gemini')?.id).toBe('gemini');
expect(getTarget('antigravity')?.id).toBe('antigravity');
expect(getTarget('kiro')?.id).toBe('kiro');
expect(getTarget('factory')?.id).toBe('factory');
expect(getTarget('not-a-real-target')).toBeUndefined();
});

Expand Down
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions src/installer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* Multi-target: writes MCP server config + instructions for the
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
* Hermes Agent, Gemini CLI, Antigravity IDE).
* Hermes Agent, Gemini CLI, Antigravity IDE, Factory Droids).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
Expand Down Expand Up @@ -317,8 +317,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise<void>
const sel = await clack.select({
message: 'Remove CodeGraph from all your projects, or just this one?',
options: [
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' },
{ value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro, ~/.factory' },
{ value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro, ./.factory' },
],
initialValue: 'global' as const,
});
Expand Down
135 changes: 135 additions & 0 deletions src/installer/targets/factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* Factory Droids target. Writes:
*
* - MCP server entry to `~/.factory/mcp.json` (global) or
* `./.factory/mcp.json` (local). Standard `mcpServers.codegraph`
* shape, same as Claude / Cursor / Gemini.
*
* No permissions concept — Factory Droids gates tool invocations through its own
* UI prompts rather than an external allowlist. `autoAllow` is silently
* ignored.
*
* Paths are identical on macOS / Linux / Windows because Factory Droids resolves
* its config root from `os.homedir()` on all three (Windows `~` →
* `%USERPROFILE%\.factory`).
*
* Docs: https://app.factory.ai
*/

import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
AgentTarget,
DetectionResult,
InstallOptions,
Location,
WriteResult,
} from './types';
import {
getMcpServerConfig,
jsonDeepEqual,
readJsonFile,
writeJsonFile,
} from './shared';

function configDir(loc: Location): string {
return loc === 'global'
? path.join(os.homedir(), '.factory')
: path.join(process.cwd(), '.factory');
}

function mcpJsonPath(loc: Location): string {
return path.join(configDir(loc), 'mcp.json');
}

class FactoryDroidsTarget implements AgentTarget {
readonly id = 'factory' as const;
readonly displayName = 'Factory Droids';
readonly docsUrl = 'https://app.factory.ai';

supportsLocation(_loc: Location): boolean {
return true;
}

detect(loc: Location): DetectionResult {
const file = mcpJsonPath(loc);
const config = readJsonFile(file);
const alreadyConfigured = !!config.mcpServers?.codegraph;
const installed = loc === 'global'
? fs.existsSync(configDir('global')) || fs.existsSync(file)
: fs.existsSync(file) || fs.existsSync(configDir('local'));
return { installed, alreadyConfigured, configPath: file };
}

install(loc: Location, _opts: InstallOptions): WriteResult {
const files: WriteResult['files'] = [];
files.push(writeMcpEntry(loc));

return {
files,
notes: [
'Restart the Droid CLI for MCP changes to take effect.',
],
};
}

uninstall(loc: Location): WriteResult {
const files: WriteResult['files'] = [];

const file = mcpJsonPath(loc);
const config = readJsonFile(file);
if (config.mcpServers?.codegraph) {
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}

if (Object.keys(config).length === 0) {
if (fs.existsSync(file)) {
fs.unlinkSync(file);
}
files.push({ path: file, action: 'removed' });
} else {
writeJsonFile(file, config);
files.push({ path: file, action: 'updated' });
}
} else {
files.push({ path: file, action: 'not-found' });
}
Comment thread
lukeaus marked this conversation as resolved.

return { files };
}

printConfig(loc: Location): string {
const target = mcpJsonPath(loc);
const snippet = JSON.stringify({ mcpServers: { codegraph: getMcpServerConfig() } }, null, 2);
return `# Add to ${target}\n\n${snippet}\n`;
}

describePaths(loc: Location): string[] {
return [mcpJsonPath(loc)];
}
}

function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpJsonPath(loc);
const dir = path.dirname(file);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });

const existing = readJsonFile(file);
const before = existing.mcpServers?.codegraph;
const after = getMcpServerConfig();

if (jsonDeepEqual(before, after)) {
return { path: file, action: 'unchanged' };
}
const action: 'created' | 'updated' =
before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created');
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action };
}

export const factoryTarget: AgentTarget = new FactoryDroidsTarget();
2 changes: 2 additions & 0 deletions src/installer/targets/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes';
import { geminiTarget } from './gemini';
import { antigravityTarget } from './antigravity';
import { kiroTarget } from './kiro';
import { factoryTarget } from './factory';

export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
claudeTarget,
Expand All @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
factoryTarget,
]);

export function getTarget(id: string): AgentTarget | undefined {
Expand Down
2 changes: 1 addition & 1 deletion src/installer/targets/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type Location = 'global' | 'local';
* lookup. New targets add a value here when they're added to the
* registry. Keep these short and lowercase.
*/
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro';
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'factory';

/**
* Result of `target.detect(location)`.
Expand Down