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 .bob/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"mcpServers":{}}
62 changes: 62 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,67 @@ describe('Installer targets — partial-state idempotency', () => {
expect(fs.readFileSync(file, 'utf-8')).toBe(firstPass);
});

it('ibm-bob: global install writes ~/.bob/mcp_settings.json and ~/.bob/AGENTS.md', () => {
const bob = getTarget('ibm-bob')!;
const result = bob.install('global', { autoAllow: false });
const cfgPath = path.join(tmpHome, '.bob', 'mcp_settings.json');
const agentsPath = path.join(tmpHome, '.bob', 'AGENTS.md');
expect(result.files.some((f) => f.path === cfgPath)).toBe(true);
expect(result.files.some((f) => f.path === agentsPath)).toBe(true);
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
expect(cfg.mcpServers.codegraph.command).toBe('codegraph');
expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']);
const agentsBody = fs.readFileSync(agentsPath, 'utf-8');
expect(agentsBody).toContain('<!-- CODEGRAPH_START -->');
expect(agentsBody).toContain('<!-- CODEGRAPH_END -->');
});

it('ibm-bob: local install writes ./.bob/mcp.json and ./.bob/AGENTS.md in cwd', () => {
const bob = getTarget('ibm-bob')!;
const result = bob.install('local', { autoAllow: false });
const cfgPath = path.join(tmpCwd, '.bob', 'mcp.json');
const agentsPath = path.join(tmpCwd, '.bob', 'AGENTS.md');
expect(result.files.some((f) => f.path === cfgPath)).toBe(true);
expect(result.files.some((f) => f.path === agentsPath)).toBe(true);
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
expect(cfg.mcpServers.codegraph).toBeDefined();
});

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

bob.install('global', { autoAllow: false });
const afterInstall = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
expect(afterInstall.mcpServers.codegraph).toBeDefined();
expect(afterInstall.mcpServers.other).toBeDefined();

bob.uninstall('global');
const afterUninstall = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
expect(afterUninstall.mcpServers?.codegraph).toBeUndefined();
expect(afterUninstall.mcpServers.other).toBeDefined();
});

it('ibm-bob: AGENTS.md install preserves pre-existing user content outside markers', () => {
const bob = getTarget('ibm-bob')!;
const dir = path.join(tmpHome, '.bob');
fs.mkdirSync(dir, { recursive: true });
const agentsPath = path.join(dir, 'AGENTS.md');
fs.writeFileSync(agentsPath, '# My Bob instructions\n\nAlways be concise.\n');

bob.install('global', { autoAllow: false });
const body = fs.readFileSync(agentsPath, 'utf-8');
expect(body).toContain('# My Bob instructions');
expect(body).toContain('Always be concise.');
expect(body).toContain('<!-- CODEGRAPH_START -->');
});

it('claude: uninstall strips stale hooks written in the npx form (local)', () => {
const claude = getTarget('claude')!;
const file = seedSettings('local', {
Expand Down Expand Up @@ -616,6 +677,7 @@ describe('Installer targets — registry', () => {
expect(getTarget('cursor')?.id).toBe('cursor');
expect(getTarget('codex')?.id).toBe('codex');
expect(getTarget('opencode')?.id).toBe('opencode');
expect(getTarget('ibm-bob')?.id).toBe('ibm-bob');
expect(getTarget('hermes')?.id).toBe('hermes');
expect(getTarget('not-a-real-target')).toBeUndefined();
});
Expand Down
5 changes: 2 additions & 3 deletions package-lock.json

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

153 changes: 153 additions & 0 deletions src/installer/targets/bob.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* IBM Bob target.
*
* - MCP server entry to `~/.bob/settings/mcp_settings.json` (global) or
* `./.bob/mcp.json` (local). Uses the standard
* `{ mcpServers: { codegraph: {...} } }` shape, same as Claude
* and Cursor.
* - Instructions to `~/.bob/AGENTS.md` (global) or
* `./.bob/AGENTS.md` (local).
* - No permissions concept — Bob doesn't expose an auto-allow list
* the installer can populate. `autoAllow` is silently ignored.
*
* Config shape:
* {
* "mcpServers": {
* "codegraph": { "type": "stdio", "command": "codegraph", "args": ["serve", "--mcp"] }
* }
* }
*
* Docs: https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob
*/

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,
removeMarkedSection,
replaceOrAppendMarkedSection,
writeJsonFile,
} from './shared';
import {
CODEGRAPH_SECTION_END,
CODEGRAPH_SECTION_START,
INSTRUCTIONS_TEMPLATE,
} from '../instructions-template';

function mcpConfigPath(loc: Location): string {
return loc === 'global'
? path.join(os.homedir(), '.bob', 'settings', 'mcp_settings.json')
: path.join(process.cwd(), '.bob', 'mcp.json');
}

function instructionsPath(loc: Location): string {
return loc === 'global'
? path.join(os.homedir(), '.bob', 'AGENTS.md')
: path.join(process.cwd(), '.bob', 'AGENTS.md');
}

class BobTarget implements AgentTarget {
readonly id = 'ibm-bob' as const;
readonly displayName = 'IBM Bob';
readonly docsUrl = 'https://bob.ibm.com/docs/ide/configuration/mcp/mcp-in-bob';

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

detect(loc: Location): DetectionResult {
const cfgPath = mcpConfigPath(loc);
const config = readJsonFile(cfgPath);
const alreadyConfigured = !!config.mcpServers?.codegraph;
const installed = loc === 'global'
? fs.existsSync(path.join(os.homedir(), '.bob', 'settings'))
: fs.existsSync(path.join(process.cwd(), '.bob'));
return { installed, alreadyConfigured, configPath: cfgPath };
}

install(loc: Location, _opts: InstallOptions): WriteResult {
return {
files: [writeMcpEntry(loc), writeInstructionsEntry(loc)],
};
}

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

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

const instr = instructionsPath(loc);
const instrAction = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
files.push({ path: instr, action: instrAction });

return { files };
}

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

describePaths(loc: Location): string[] {
return [mcpConfigPath(loc), instructionsPath(loc)];
}
}

function writeMcpEntry(loc: Location): WriteResult['files'][number] {
const file = mcpConfigPath(loc);
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' = fs.existsSync(file) ? 'updated' : 'created';
if (!existing.mcpServers) existing.mcpServers = {};
existing.mcpServers.codegraph = after;
writeJsonFile(file, existing);
return { path: file, action };
}

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

const result = replaceOrAppendMarkedSection(
file,
INSTRUCTIONS_TEMPLATE,
CODEGRAPH_SECTION_START,
CODEGRAPH_SECTION_END,
);
const mapped: 'created' | 'updated' | 'unchanged' =
result === 'created' ? 'created'
: result === 'unchanged' ? 'unchanged'
: 'updated';
return { path: file, action: mapped };
}

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 @@ -12,6 +12,7 @@ import { claudeTarget } from './claude';
import { cursorTarget } from './cursor';
import { codexTarget } from './codex';
import { opencodeTarget } from './opencode';
import { bobTarget } from './bob';
import { hermesTarget } from './hermes';

export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
Expand All @@ -20,6 +21,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
codexTarget,
opencodeTarget,
hermesTarget,
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';
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'ibm-bob';

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