From dc2b4aa8cd325754d09266c2b3d009f0357f142b Mon Sep 17 00:00:00 2001 From: Ujjwal Kumar Date: Sat, 23 May 2026 15:36:41 +0530 Subject: [PATCH 1/3] feat: add IBM Bob installer target --- __tests__/installer-targets.test.ts | 62 +++++++++++ src/installer/targets/bob.ts | 153 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 4 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/bob.ts diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 59e869e2..79f44a30 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -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(''); + expect(agentsBody).toContain(''); + }); + + 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(''); + }); + it('claude: uninstall strips stale hooks written in the npx form (local)', () => { const claude = getTarget('claude')!; const file = seedSettings('local', { @@ -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(); }); diff --git a/src/installer/targets/bob.ts b/src/installer/targets/bob.ts new file mode 100644 index 00000000..3a2fc4ef --- /dev/null +++ b/src/installer/targets/bob.ts @@ -0,0 +1,153 @@ +/** + * IBM Bob target. + * + * - MCP server entry to `~/.bob/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', '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')) + : 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(); \ No newline at end of file diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 0091ab64..f0c30a75 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -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([ @@ -20,6 +21,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ codexTarget, opencodeTarget, hermesTarget, + bobTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 290f13ce..3a96a589 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -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)`. From 1889b03c9fa97eadbd50cfdd6ab28b60c2bb410a Mon Sep 17 00:00:00 2001 From: Ujjwal Kumar Date: Sat, 23 May 2026 15:37:22 +0530 Subject: [PATCH 2/3] chore: sync package-lock.json after npm install --- package-lock.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36c592b1..d9b3b484 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@colbymchenry/codegraph", - "version": "0.9.3", + "version": "0.9.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@colbymchenry/codegraph", - "version": "0.9.3", + "version": "0.9.4", "license": "MIT", "dependencies": { "@clack/prompts": "^1.3.0", @@ -1431,7 +1431,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", From c511ede55cc853ab63ee939ca34912d2f69fb7bf Mon Sep 17 00:00:00 2001 From: Ujjwal Kumar Date: Sat, 23 May 2026 15:41:27 +0530 Subject: [PATCH 3/3] fix: updated bob paths --- .bob/mcp.json | 1 + src/installer/targets/bob.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 .bob/mcp.json diff --git a/.bob/mcp.json b/.bob/mcp.json new file mode 100644 index 00000000..6b0a486d --- /dev/null +++ b/.bob/mcp.json @@ -0,0 +1 @@ +{"mcpServers":{}} \ No newline at end of file diff --git a/src/installer/targets/bob.ts b/src/installer/targets/bob.ts index 3a2fc4ef..a69bb0c4 100644 --- a/src/installer/targets/bob.ts +++ b/src/installer/targets/bob.ts @@ -1,7 +1,7 @@ /** * IBM Bob target. * - * - MCP server entry to `~/.bob/mcp_settings.json` (global) or + * - 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. @@ -46,7 +46,7 @@ import { function mcpConfigPath(loc: Location): string { return loc === 'global' - ? path.join(os.homedir(), '.bob', 'mcp_settings.json') + ? path.join(os.homedir(), '.bob', 'settings', 'mcp_settings.json') : path.join(process.cwd(), '.bob', 'mcp.json'); } @@ -70,7 +70,7 @@ class BobTarget implements AgentTarget { const config = readJsonFile(cfgPath); const alreadyConfigured = !!config.mcpServers?.codegraph; const installed = loc === 'global' - ? fs.existsSync(path.join(os.homedir(), '.bob')) + ? fs.existsSync(path.join(os.homedir(), '.bob', 'settings')) : fs.existsSync(path.join(process.cwd(), '.bob')); return { installed, alreadyConfigured, configPath: cfgPath }; }