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
53 changes: 53 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1086,6 +1086,58 @@ describe('Installer targets — partial-state idempotency', () => {
// Both events emptied → the whole `hooks` object is removed.
expect(after.hooks).toBeUndefined();
});
it('bob: global install writes ~/.bob/settings/mcp_settings.json (mcpServers.codegraph)', () => {
const bob = getTarget('bob')!;
const result = bob.install('global', { autoAllow: true });
const settings = path.join(tmpHome, '.bob', 'settings', 'mcp_settings.json');
expect(result.files.some((f) => f.path === settings)).toBe(true);
expect(fs.existsSync(settings)).toBe(true);

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

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

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

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

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

const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeDefined();
});

it('bob: uninstall removes codegraph but preserves sibling MCP server', () => {
const bob = getTarget('bob')!;
const settings = path.join(tmpHome, '.bob', 'settings', 'mcp_settings.json');
fs.mkdirSync(path.dirname(settings), { recursive: true });
fs.writeFileSync(settings, JSON.stringify({
mcpServers: { other: { type: 'stdio', command: 'other-server', args: [] } },
}, null, 2) + '\n');

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

const after = JSON.parse(fs.readFileSync(settings, 'utf-8'));
expect(after.mcpServers.other).toBeDefined();
expect(after.mcpServers.codegraph).toBeUndefined();
});
});

describe('Installer targets — registry', () => {
Expand All @@ -1098,6 +1150,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('bob')?.id).toBe('bob');
expect(getTarget('not-a-real-target')).toBeUndefined();
});

Expand Down
4 changes: 2 additions & 2 deletions src/bin/codegraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1607,7 +1607,7 @@ program
*/
program
.command('install')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Bob)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
Expand Down Expand Up @@ -1674,7 +1674,7 @@ program
*/
program
.command('uninstall')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
.description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Bob)')
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "all". Default: all')
.option('-l, --location <where>', 'Uninstall location: "global" or "local". Default: prompt')
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all')
Expand Down
2 changes: 1 addition & 1 deletion 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, Bob IDE).
* Defaults to the Claude-only behavior for backwards compatibility
* when no targets are explicitly chosen and nothing else is detected.
*
Expand Down
123 changes: 123 additions & 0 deletions src/installer/targets/bob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* Bob target.
*
* Bob currently uses a simple JSON MCP config surface mirroring the
* standard `{ mcpServers: { ... } }` shape used by Claude / Cursor /
* Gemini / Kiro. We write:
*
* - MCP server entry to `~/.bob/settings/mcp_settings.json` (global) or
* `./.bob/mcp.json` (local) under `mcpServers.codegraph`.
*
* No permissions concept — `autoAllow` is ignored.
*
* Bob-specific instructions files are intentionally NOT written. The
* codegraph usage guidance now ships in the MCP server's `initialize`
* response, which is the single source of truth for supported clients.
*/

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(), '.bob')
: path.join(process.cwd(), '.bob');
}

function settingsJsonPath(loc: Location): string {
if (loc === 'global') {
return path.join(configDir(loc), 'settings', 'mcp_settings.json');
} else {
return path.join(configDir(loc), 'mcp.json');
}
}

class BobTarget implements AgentTarget {
readonly id = 'bob' as const;
readonly displayName = 'Bob';
readonly docsUrl = 'https://bob.ibm.com';

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

detect(loc: Location): DetectionResult {
const file = settingsJsonPath(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 {
return { files: [writeMcpEntry(loc)] };
}

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

const file = settingsJsonPath(loc);
const config = readJsonFile(file);
if (config.mcpServers?.codegraph) {
delete config.mcpServers.codegraph;
if (Object.keys(config.mcpServers).length === 0) {
delete config.mcpServers;
}
writeJsonFile(file, config);
files.push({ path: file, action: 'removed' });
} else {
files.push({ path: file, action: 'not-found' });
}

return { files };
}

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

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

function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = settingsJsonPath(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 bobTarget: AgentTarget = new BobTarget();
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 { bobTarget } from './bob';

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,
bobTarget,
]);

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' | 'bob';

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