diff --git a/README.md b/README.md index 354af2463..7a73afa40 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Already installed? Run `codegraph upgrade` to update in place. Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and OpenClaw with Semantic Code Intelligence **~16% cheaper · ~58% fewer tool calls · 100% local** @@ -27,6 +27,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Codex](https://img.shields.io/badge/Codex-supported-blueviolet.svg)](#supported-agents) [![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#supported-agents) [![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#supported-agents) +[![OpenClaw](https://img.shields.io/badge/OpenClaw-supported-blueviolet.svg)](#supported-agents) [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) @@ -76,7 +77,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and OpenClaw — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -336,7 +337,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **OpenClaw** - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` / `codegraph node` commands, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -362,7 +363,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / OpenClaw) for the MCP server to load. ### 3. Initialize Projects diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 91753c39c..47d8da1f2 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -136,6 +136,11 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } + // openclaw uses `mcp.servers.` (nested). + if (target.id === 'openclaw') { + delete seed.mcpServers; + seed.mcp = { servers: { other: { command: 'x' } } }; + } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -144,6 +149,9 @@ describe('Installer targets — contract', () => { if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); + } else if (target.id === 'openclaw') { + expect(after.mcp.servers.other).toBeDefined(); + expect(after.mcp.servers.codegraph).toBeDefined(); } else { expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined(); @@ -1547,4 +1555,133 @@ describe('Installer targets — opencode XDG config path (#535)', () => { // But configuration state is read from the REAL path only. expect(opencode.detect('global').alreadyConfigured).toBe(false); }); + + // ── OpenClaw target tests ──────────────────────────────────────── + // The OpenClaw target writes to $HOME/.openclaw/openclaw.json under + // mcp.servers.codegraph, and refuses local install (OpenClaw is a + // single gateway, no per-project config layer). + + it('openclaw: install writes mcp.servers.codegraph to the resolved config file', () => { + const openclaw = getTarget('openclaw')!; + const result = openclaw.install('global', { autoAllow: true }); + const cfg = openclaw.describePaths('global')[0]; + expect(result.files.some((f) => f.path === cfg)).toBe(true); + expect(fs.existsSync(cfg)).toBe(true); + + const parsed = JSON.parse(fs.readFileSync(cfg, 'utf-8')); + expect(parsed.mcp.servers.codegraph).toBeDefined(); + expect(parsed.mcp.servers.codegraph.args).toContain('serve'); + expect(parsed.mcp.servers.codegraph.args).toContain('--mcp'); + expect(parsed.mcp.servers.codegraph.args).toContain('--path'); + expect(parsed.mcp.servers.codegraph.env.CODEGRAPH_TELEMETRY).toBe('0'); + }); + + it('openclaw: install preserves pre-existing sibling MCP servers (chrome-mcp, etc.)', () => { + const openclaw = getTarget('openclaw')!; + const cfg = openclaw.describePaths('global')[0]; + fs.mkdirSync(path.dirname(cfg), { recursive: true }); + fs.writeFileSync( + cfg, + JSON.stringify( + { + mcp: { + servers: { + 'chrome-mcp': { type: 'streamableHttp', url: 'http://example/mcp' }, + }, + }, + }, + null, + 2, + ) + '\n', + ); + + openclaw.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(cfg, 'utf-8')); + expect(after.mcp.servers['chrome-mcp']).toBeDefined(); + expect(after.mcp.servers.codegraph).toBeDefined(); + }); + + it('openclaw: install preserves other top-level config keys (channels, agents, gateway)', () => { + const openclaw = getTarget('openclaw')!; + const cfg = openclaw.describePaths('global')[0]; + fs.mkdirSync(path.dirname(cfg), { recursive: true }); + fs.writeFileSync( + cfg, + JSON.stringify( + { + channels: { telegram: { botToken: 'redacted' } }, + agents: { defaults: { model: 'deepseek-v4-pro' } }, + gateway: { bind: 'loopback' }, + }, + null, + 2, + ) + '\n', + ); + + openclaw.install('global', { autoAllow: true }); + + const after = JSON.parse(fs.readFileSync(cfg, 'utf-8')); + expect(after.channels.telegram.botToken).toBe('redacted'); + expect(after.agents.defaults.model).toBe('deepseek-v4-pro'); + expect(after.gateway.bind).toBe('loopback'); + expect(after.mcp.servers.codegraph).toBeDefined(); + }); + + it('openclaw: install is idempotent (re-run produces action=unchanged)', () => { + const openclaw = getTarget('openclaw')!; + openclaw.install('global', { autoAllow: true }); + const second = openclaw.install('global', { autoAllow: true }); + expect(second.files.some((f) => f.action === 'unchanged')).toBe(true); + expect(second.files.some((f) => f.action === 'updated')).toBe(false); + }); + + it('openclaw: uninstall strips mcp.servers.codegraph but leaves siblings intact', () => { + const openclaw = getTarget('openclaw')!; + const cfg = openclaw.describePaths('global')[0]; + fs.mkdirSync(path.dirname(cfg), { recursive: true }); + fs.writeFileSync( + cfg, + JSON.stringify( + { + mcp: { + servers: { + 'chrome-mcp': { type: 'streamableHttp', url: 'http://example/mcp' }, + }, + }, + }, + null, + 2, + ) + '\n', + ); + + openclaw.install('global', { autoAllow: true }); + openclaw.uninstall('global'); + + const after = JSON.parse(fs.readFileSync(cfg, 'utf-8')); + expect(after.mcp.servers['chrome-mcp']).toBeDefined(); + expect(after.mcp.servers.codegraph).toBeUndefined(); + }); + + it('openclaw: local install returns a note and writes nothing', () => { + const openclaw = getTarget('openclaw')!; + expect(openclaw.supportsLocation('local')).toBe(false); + const result = openclaw.install('local', { autoAllow: true }); + expect(result.files).toEqual([]); + expect(result.notes && result.notes.length > 0).toBe(true); + }); + + it('openclaw: detect before install shows alreadyConfigured=false', () => { + const openclaw = getTarget('openclaw')!; + expect(openclaw.detect('global').installed).toBe(false); + expect(openclaw.detect('global').alreadyConfigured).toBe(false); + }); + + it('openclaw: detect after install shows alreadyConfigured=true', () => { + const openclaw = getTarget('openclaw')!; + openclaw.install('global', { autoAllow: true }); + const d = openclaw.detect('global'); + expect(d.installed).toBe(true); + expect(d.alreadyConfigured).toBe(true); + }); }); diff --git a/src/installer/targets/openclaw.ts b/src/installer/targets/openclaw.ts new file mode 100644 index 000000000..d1bcbca0a --- /dev/null +++ b/src/installer/targets/openclaw.ts @@ -0,0 +1,246 @@ +/** + * OpenClaw target. + * + * - MCP server entry merged into `~/.openclaw/openclaw.json` under + * `mcp.servers.codegraph`. The orchestrator then loads it on next + * gateway restart; the same MCP surface is exposed to all agents + * (main + sub-agents). + * - No instructions file: OpenClaw's per-agent SOUL.md / AGENTS.md + * pattern is user-managed. The MCP server's own `initialize` + * response carries the tool guidance (see `server-instructions.ts`). + * - No permissions concept: OpenClaw does not gate tool calls beyond + * the channel's per-account allowlist. + * + * The `mcp.servers` shape is OpenClaw-native: + * { + * "mcp": { + * "servers": { + * "codegraph": { + * "command": "", + * "args": ["serve", "--mcp", "--path", ""], + * "env": { "CODEGRAPH_TELEMETRY": "0" }, + * "cwd": "" + * } + * } + * } + * } + * + * OpenClaw supports both stdio (this shape) and streamable-http + * (`{ "type": "streamableHttp", "url": "..." }`) — we use stdio. + * + * Idempotency: + * - If a codegraph entry already exists with the same effective + * command/args/env/cwd, we leave the file alone (action: unchanged). + * - If it exists with different fields, we update it surgically and + * report (action: updated). + * - On uninstall we drop ONLY the `mcp.servers.codegraph` key and + * preserve all sibling servers (chrome-mcp, wordpress-deltabis, + * user-defined, etc.). + * + * `--path` is REQUIRED when the MCP server's CWD is not an indexed + * project — without it, codegraph's `initialize` response says + * "workspace not indexed" and exposes no tools. The installer + * auto-detects the project root with the same heuristic as `codegraph + * init`: walk up from cwd looking for a `.codegraph/` directory, falling + * back to cwd. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { atomicWriteFileSync, jsonDeepEqual } from './shared'; + +/** + * OpenClaw is a single-user gateway with one global config dir + * (XDG-style, like opencode). Local install is not supported — there + * is no per-project config layer to write into. + */ +function openclawConfigDir(): string { + const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0 + ? process.env.XDG_CONFIG_HOME + : path.join(os.homedir(), '.config'); + // OpenClaw keeps its own dir at $XDG_CONFIG_HOME/openclaw OR $HOME/.openclaw; + // the latter is the documented default and what 99% of users have. + return fs.existsSync(path.join(os.homedir(), '.openclaw')) + ? path.join(os.homedir(), '.openclaw') + : path.join(xdg, 'openclaw'); +} + +function configPath(): string { + return path.join(openclawConfigDir(), 'openclaw.json'); +} + +function readConfigText(file: string): string { + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function parseConfig(text: string): Record { + if (!text.trim()) return {}; + try { + const result = JSON.parse(text); + if (result == null || typeof result !== 'object' || Array.isArray(result)) { + return {}; + } + return result as Record; + } catch { + return {}; + } +} + +/** + * Find the project root by walking up from cwd looking for an existing + * `.codegraph/` directory. Falls back to cwd if none found. This + * mirrors what `codegraph init` does for the auto-detected case. + */ +function detectProjectRoot(): string { + let dir = process.cwd(); + for (let i = 0; i < 10; i++) { + if (fs.existsSync(path.join(dir, '.codegraph'))) return dir; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +/** + * Resolve the codegraph binary path. Prefer the install location + * populated by `install.sh` / `npm i -g`; fall back to `which codegraph` + * via PATH; finally fall back to `codegraph` (PATH-resolved by OpenClaw). + */ +function codegraphBinaryPath(): string { + const home = os.homedir(); + const candidates = [ + path.join(home, '.local', 'bin', 'codegraph'), + path.join(home, '.codegraph', 'bin', 'codegraph'), + '/usr/local/bin/codegraph', + '/opt/homebrew/bin/codegraph', + ]; + for (const c of candidates) { + if (fs.existsSync(c)) return c; + } + return 'codegraph'; // PATH-resolved fallback +} + +function buildServerEntry(): Record { + const project = detectProjectRoot(); + return { + command: codegraphBinaryPath(), + args: ['serve', '--mcp', '--path', project], + env: { CODEGRAPH_TELEMETRY: '0' }, + cwd: project, + }; +} + +class OpenclawTarget implements AgentTarget { + readonly id = 'openclaw' as const; + readonly displayName = 'OpenClaw'; + readonly docsUrl = 'https://github.com/openclaw/openclaw'; + + supportsLocation(_loc: Location): boolean { + // OpenClaw is global-only; it has no per-project config layer. + return _loc === 'global'; + } + + detect(loc: Location): DetectionResult { + if (loc !== 'global') return { installed: false, alreadyConfigured: false }; + const file = configPath(); + const dir = openclawConfigDir(); + const installed = fs.existsSync(dir) && fs.existsSync(file); + const config = parseConfig(readConfigText(file)); + const alreadyConfigured = !!config.mcp?.servers?.codegraph; + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc !== 'global') { + return { + files: [], + notes: ['OpenClaw only supports global install (single gateway, no per-project config).'], + }; + } + return { files: [writeMcpEntry()] }; + } + + uninstall(loc: Location): WriteResult { + if (loc !== 'global') return { files: [] }; + return { files: [removeMcpEntry()] }; + } + + printConfig(loc: Location): string { + if (loc !== 'global') { + return '# OpenClaw only supports global install.\n'; + } + const target = configPath(); + const snippet = JSON.stringify( + { mcp: { servers: { codegraph: buildServerEntry() } } }, + null, + 2, + ); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return loc === 'global' ? [configPath()] : []; + } +} + +function writeMcpEntry(): WriteResult['files'][number] { + const file = configPath(); + const dir = path.dirname(file); + const existed = fs.existsSync(file); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + const text = readConfigText(file); + const config = parseConfig(text); + const before = config.mcp?.servers?.codegraph; + const after = buildServerEntry(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + + // Initialize nested mcp.servers.codegraph structure if absent + if (!config.mcp || typeof config.mcp !== 'object') { + config.mcp = {}; + } + if (!config.mcp.servers || typeof config.mcp.servers !== 'object') { + config.mcp.servers = {}; + } + config.mcp.servers.codegraph = after; + + atomicWriteFileSync(file, JSON.stringify(config, null, 2) + '\n'); + return { path: file, action: existed ? 'updated' : 'created' }; +} + +/** + * Drop ONLY `mcp.servers.codegraph`; preserve every other server, + * channel, agent, etc. If `mcp.servers` is empty afterwards, leave + * the wrapper (OpenClaw schema tolerates an empty servers object). + */ +function removeMcpEntry(): WriteResult['files'][number] { + const file = configPath(); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + const text = readConfigText(file); + const config = parseConfig(text); + if (!config.mcp?.servers?.codegraph) { + return { path: file, action: 'not-found' }; + } + + delete config.mcp.servers.codegraph; + atomicWriteFileSync(file, JSON.stringify(config, null, 2) + '\n'); + return { path: file, action: 'removed' }; +} + +export const openclawTarget: AgentTarget = new OpenclawTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..2236d497e 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { openclawTarget } from './openclaw'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + openclawTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 4b3267e97..ce4d9280b 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' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'openclaw'; /** * Result of `target.detect(location)`.