diff --git a/README.md b/README.md index 693fcd487a..56a35645ad 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Already installed? Run `codegraph upgrade` Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and GitHub Copilot with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, GitHub Copilot, and ZCode with Semantic Code Intelligence **The fastest complete code graph · surgical context · built for how agents actually work · 100% local** @@ -36,6 +36,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) [![GitHub Copilot](https://img.shields.io/badge/GitHub_Copilot-supported-blueviolet.svg)](#supported-agents) +[![ZCode](https://img.shields.io/badge/ZCode-supported-blueviolet.svg)](#supported-agents)
@@ -105,7 +106,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, Kiro, and GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs) — 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. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (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, GitHub Copilot (VS Code, Copilot CLI, JetBrains IDEs), and ZCode — 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. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -389,7 +390,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**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs) +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **GitHub Copilot** (VS Code, Copilot CLI, JetBrains IDEs), **ZCode** - 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` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -420,7 +421,7 @@ codegraph install --print-config copilot-vscode # same, for Copilot in VS C ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, or your JetBrains IDE for GitHub Copilot) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / VS Code, the Copilot CLI, your JetBrains IDE for GitHub Copilot, or a ZCode session) for the MCP server to load. ### 3. Initialize Projects @@ -802,6 +803,7 @@ is written): - **Antigravity IDE** - **Kiro** - **GitHub Copilot** — Copilot Chat in VS Code (`copilot-vscode`), the Copilot CLI (`copilot-cli`), and the Copilot plugin in JetBrains IDEs (`copilot-jetbrains`) +- **ZCode** — MCP entry is ZCode's nested `mcp.servers` shape in `~/.zcode/cli/config.json` (global) or `/.zcode/config.json` (local); entries carry no `type` key. The prompt-hook uses ZCode's process-shaped hooks (`type: "process"` + `args`) and sets `hooks.enabled: true` — ZCode runs config-file hooks only when that flag is set. Instructions go to `~/.zcode/AGENTS.md` / `/AGENTS.md` ## Supported Languages @@ -904,7 +906,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and GitHub Copilot** +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, and ZCode** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 3b740c7ed7..5d7d8dd94e 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -23,6 +23,10 @@ import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targ import { uninstallTargets, refreshTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude'; +import { + writePromptHookEntry as zcodeWritePromptHookEntry, + removePromptHookEntry as zcodeRemovePromptHookEntry, +} from '../src/installer/targets/zcode'; function mkTmpDir(label: string): string { return fs.mkdtempSync(path.join(os.tmpdir(), `cg-targets-${label}-`)); @@ -154,6 +158,13 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.servers = { other: { command: 'x' } }; } + // ZCode's config.json nests servers under `mcp.servers` + // (and carries sibling top-level keys like `plugins`). + if (target.id === 'zcode') { + delete seed.mcpServers; + seed.mcp = { servers: { other: { command: 'x' } } }; + seed.plugins = { enabledPlugins: { 'some@plugin': true } }; + } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -165,6 +176,13 @@ describe('Installer targets — contract', () => { expect(after.mcp.servers.codegraph.codemode).toBe(false); expect(after.mcp.servers.codegraph.disabled).toBe(false); expect(after.mcp.codegraph).toBeUndefined(); + } else if (target.id === 'zcode') { + expect(after.mcp.servers.other).toBeDefined(); + expect(after.mcp.servers.codegraph).toBeDefined(); + // A Claude-style `type` key must not creep back in, and + // sibling top-level keys survive. + expect(after.mcp.servers.codegraph.type).toBeUndefined(); + expect(after.plugins.enabledPlugins['some@plugin']).toBe(true); } else if (target.id === 'copilot-vscode' || target.id === 'copilot-jetbrains') { expect(after.servers.other).toBeDefined(); expect(after.servers.codegraph).toBeDefined(); @@ -1350,6 +1368,7 @@ describe('Installer targets — registry', () => { expect(getTarget('copilot-vscode')?.id).toBe('copilot-vscode'); expect(getTarget('copilot-cli')?.id).toBe('copilot-cli'); expect(getTarget('copilot-jetbrains')?.id).toBe('copilot-jetbrains'); + expect(getTarget('zcode')?.id).toBe('zcode'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); @@ -2878,3 +2897,173 @@ describe('Installer targets — Codex CODEX_HOME override (#1627)', () => { expect(fs.existsSync(path.join(custom, 'config.toml'))).toBe(false); }); }); + +describe('Installer targets — ZCode', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('zcode-home'); + tmpCwd = mkTmpDir('zcode-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + // ZCode hook entries are process-shaped (`{ type: 'process', command, + // args, timeoutMs }`), not Claude's shell-string form, and the command + // is platform-aware for the same #1466 reason (Windows must spawn + // `codegraph.cmd` — a bare `codegraph` doesn't resolve PATHEXT). + const ZCODE_HOOK_CMD = process.platform === 'win32' ? 'codegraph.cmd' : 'codegraph'; + const globalConfig = () => path.join(tmpHome, '.zcode', 'cli', 'config.json'); + const globalAgents = () => path.join(tmpHome, '.zcode', 'AGENTS.md'); + const hookCommands = (c: any): string[] => + (c.hooks?.UserPromptSubmit ?? []).flatMap((g: any) => (g.hooks ?? []).map((h: any) => h.command)); + + it('install with promptHook:true writes nested mcp.servers AND an enabled process-shaped hook', () => { + const zcode = getTarget('zcode')!; + const result = zcode.install('global', { autoAllow: true, promptHook: true }); + expect(result.files.map((f) => f.path)).toEqual( + expect.arrayContaining([globalConfig(), globalAgents()]), + ); + + const c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + // Nested shape — a top-level mcpServers key would silently no-op. + expect(c.mcpServers).toBeUndefined(); + expect(c.mcp.servers.codegraph).toEqual({ + command: 'codegraph', + args: ['serve', '--mcp'], + }); + expect(c.mcp.servers.codegraph.type).toBeUndefined(); + + // Config-file hooks are inert unless hooks.enabled is true. + expect(c.hooks.enabled).toBe(true); + expect(c.hooks.UserPromptSubmit).toHaveLength(1); + expect(c.hooks.UserPromptSubmit[0].hooks[0]).toEqual({ + type: 'process', + command: ZCODE_HOOK_CMD, + args: ['prompt-hook'], + timeoutMs: 30000, + }); + + // Instructions land in the user-scope AGENTS.md. + const md = fs.readFileSync(globalAgents(), 'utf-8'); + expect(md).toContain(''); + expect(md).toContain('codegraph explore'); + }); + + it('install without promptHook does NOT create a hooks block', () => { + getTarget('zcode')!.install('global', { autoAllow: true }); + const c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(c.hooks).toBeUndefined(); + }); + + it('re-install with promptHook:true is byte-identical (no duplicate hook)', () => { + const zcode = getTarget('zcode')!; + zcode.install('global', { autoAllow: true, promptHook: true }); + const first = fs.readFileSync(globalConfig(), 'utf-8'); + const second = zcode.install('global', { autoAllow: true, promptHook: true }); + expect(second.files.every((f) => f.action === 'unchanged')).toBe(true); + expect(fs.readFileSync(globalConfig(), 'utf-8')).toBe(first); + expect(hookCommands(JSON.parse(first)).filter((x) => x === ZCODE_HOOK_CMD)).toHaveLength(1); + }); + + it('promptHook:false strips the hook (opt-out round-trips); enabled stays as-is', () => { + const zcode = getTarget('zcode')!; + zcode.install('global', { autoAllow: true, promptHook: true }); + zcode.install('global', { autoAllow: true, promptHook: false }); + const c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(hookCommands(c)).not.toContain(ZCODE_HOOK_CMD); + expect(c.hooks.UserPromptSubmit).toBeUndefined(); + // `enabled` provenance is unknowable (the user may rely on it for + // other config hooks), so removal deliberately leaves it behind. + expect(c.hooks.enabled).toBe(true); + }); + + it('normalizes a hand-copied Claude-style entry (drops `type`) and stays idempotent', () => { + fs.mkdirSync(path.dirname(globalConfig()), { recursive: true }); + fs.writeFileSync(globalConfig(), JSON.stringify({ + mcp: { servers: { codegraph: { type: 'stdio', command: 'codegraph', args: ['serve', '--mcp'] } } }, + }, null, 2) + '\n'); + + const first = getTarget('zcode')!.install('global', { autoAllow: true }); + expect(first.files[0].action).toBe('updated'); + const c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(c.mcp.servers.codegraph).toEqual({ command: 'codegraph', args: ['serve', '--mcp'] }); + + const second = getTarget('zcode')!.install('global', { autoAllow: true }); + expect(second.files[0].action).toBe('unchanged'); + }); + + it('zcodeWritePromptHookEntry preserves a sibling process hook; uninstall keeps it', () => { + fs.mkdirSync(path.dirname(globalConfig()), { recursive: true }); + fs.writeFileSync(globalConfig(), JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { hooks: [{ type: 'process', command: 'my-own-hook', args: ['run'] }] }, + ], + }, + }, null, 2) + '\n'); + + expect(zcodeWritePromptHookEntry('global').action).toBe('updated'); + let c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(hookCommands(c)).toEqual(['my-own-hook', ZCODE_HOOK_CMD]); + expect(c.hooks.enabled).toBe(true); + + getTarget('zcode')!.uninstall('global'); + c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(hookCommands(c)).toEqual(['my-own-hook']); + }); + + it('zcodeRemovePromptHookEntry accepts the other platform\'s command spelling', () => { + fs.mkdirSync(path.dirname(globalConfig()), { recursive: true }); + const otherCmd = process.platform === 'win32' ? 'codegraph' : 'codegraph.cmd'; + fs.writeFileSync(globalConfig(), JSON.stringify({ + hooks: { + enabled: true, + UserPromptSubmit: [{ hooks: [{ type: 'process', command: otherCmd, args: ['prompt-hook'], timeoutMs: 30000 }] }], + }, + }, null, 2) + '\n'); + + expect(zcodeRemovePromptHookEntry('global').action).toBe('removed'); + const c = JSON.parse(fs.readFileSync(globalConfig(), 'utf-8')); + expect(c.hooks.UserPromptSubmit).toBeUndefined(); + expect(c.hooks.enabled).toBe(true); + }); + + it('uninstall strips the AGENTS.md block but keeps user content; local install writes workspace files only', () => { + fs.mkdirSync(tmpCwd, { recursive: true }); + fs.writeFileSync(path.join(tmpCwd, 'AGENTS.md'), '# My project rules\n\nkeep me\n'); + getTarget('zcode')!.install('local', { autoAllow: true, promptHook: true }); + + // Workspace scope: ./.zcode/config.json + ./AGENTS.md. + expect(fs.existsSync(path.join(tmpCwd, '.zcode', 'config.json'))).toBe(true); + expect(fs.existsSync(path.join(tmpCwd, 'AGENTS.md'))).toBe(true); + expect(fs.existsSync(globalConfig())).toBe(false); + + getTarget('zcode')!.uninstall('local'); + const md = fs.readFileSync(path.join(tmpCwd, 'AGENTS.md'), 'utf-8'); + expect(md).not.toContain('CODEGRAPH'); + expect(md).toContain('keep me'); + const c = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.zcode', 'config.json'), 'utf-8')); + expect(c.mcp).toBeUndefined(); + }); + + it('printConfig shows the nested mcp.servers shape and writes nothing', () => { + const out = getTarget('zcode')!.printConfig('global'); + expect(out).toContain('mcp'); + expect(out).toContain('"codegraph"'); + const parsed = JSON.parse(out.slice(out.indexOf('{'))); + expect(parsed.mcp.servers.codegraph).toEqual({ command: 'codegraph', args: ['serve', '--mcp'] }); + expect(fs.existsSync(globalConfig())).toBe(false); + }); +}); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index a6fe54c98e..9d68b86dea 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2568,7 +2568,7 @@ program */ program .command('install') - .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)') + .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, ZCode)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') @@ -2682,7 +2682,7 @@ program */ program .command('uninstall') - .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot)') + .description('Remove codegraph from your agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, GitHub Copilot, ZCode)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "all". Default: all') .option('-l, --location ', 'Uninstall location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=all') diff --git a/src/installer/index.ts b/src/installer/index.ts index 199a6de75e..06c19902f3 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -205,21 +205,21 @@ export async function runInstallerWithOptions(opts: RunInstallerOptions): Promis } } - // Step 4¾: front-load prompt hook (Claude Code only). A UserPromptSubmit hook + // Step 4¾: front-load prompt hook (Claude Code and ZCode). A UserPromptSubmit hook // that runs `codegraph prompt-hook` — it injects codegraph_explore context on // structural ("how / where / trace / impact") prompts so the agent reliably // reaches for the graph instead of grepping. Opt-in, default-yes. Only Claude - // Code has UserPromptSubmit, so it's offered only when Claude is a target; - // other targets ignore the option. `undefined` (no Claude / not asked) leaves - // any existing hook untouched. + // Code and ZCode have UserPromptSubmit, so it's offered only when one of them + // is a target; other targets ignore the option. `undefined` (neither targeted / + // not asked) leaves any existing hook untouched. let promptHook: boolean | undefined; - if (targets.some((t) => t.id === 'claude')) { + if (targets.some((t) => t.id === 'claude' || t.id === 'zcode')) { if (useDefaults) { promptHook = true; // --yes → on } else { const ans = await clack.confirm({ message: - 'Front-load CodeGraph on “how / where / trace” prompts? Auto-injects structural context so answers need fewer steps (adds a moment to those prompts; Claude Code only).', + 'Front-load CodeGraph on “how / where / trace” prompts? Auto-injects structural context so answers need fewer steps (adds a moment to those prompts; Claude Code / ZCode only).', initialValue: true, }); if (clack.isCancel(ans)) { diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 3798b39ad5..8cb49cf20f 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -19,6 +19,7 @@ import { kiroTarget } from './kiro'; import { copilotVscodeTarget } from './copilot-vscode'; import { copilotCliTarget } from './copilot-cli'; import { copilotJetbrainsTarget } from './copilot-jetbrains'; +import { zcodeTarget } from './zcode'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -32,6 +33,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ copilotVscodeTarget, copilotCliTarget, copilotJetbrainsTarget, + zcodeTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index d93680573b..2bd8c9f734 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' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'copilot-vscode' | 'copilot-cli' | 'copilot-jetbrains' | 'zcode'; /** * Result of `target.detect(location)`. diff --git a/src/installer/targets/zcode.ts b/src/installer/targets/zcode.ts new file mode 100644 index 0000000000..43efe7a348 --- /dev/null +++ b/src/installer/targets/zcode.ts @@ -0,0 +1,331 @@ +/** + * ZCode target — the ZCode desktop coding agent. + * + * Writes: + * + * - MCP server entry to `~/.zcode/cli/config.json` under `mcp.servers` + * (global = user scope, loads in every workspace) or + * `./.zcode/config.json` (local = workspace scope). Both scopes + * auto-connect at session start. + * - Instructions to `~/.zcode/AGENTS.md` (global) or `./AGENTS.md` + * (local). ZCode reads AGENTS.md — the user file loads first, then + * the workspace file narrows it — so the conditional block wording + * works in both scopes. + * - UserPromptSubmit prompt-hook into the same config.json under + * `hooks`. + * + * Three ZCode-specific differences from the other JSON targets, all + * verified against a live ZCode install: + * + * 1. MCP servers are NESTED under `mcp.servers` — not the top-level + * `mcpServers` key Claude/Cursor/opencode use. A Claude-style + * snippet pasted verbatim silently does nothing. + * 2. Server entries carry no `type` field. ZCode's own bundled + * servers (`node_repl`, `computer-use`) and a verified-working + * codegraph entry are bare `{ command, args }`; tolerance for a + * `type: "stdio"` key is unverified, so we don't write one (and we + * normalize a hand-copied Claude-style entry down to the verified + * shape on install). + * 3. Hooks share the config.json under `hooks`, and a config-file + * hooks block only runs when `hooks.enabled: true` — hooks + * contributed by plugins auto-enable the hook runner, config-file + * hooks do not. Hook entries are process-shaped + * (`{ type: 'process', command, args, timeoutMs }`), not Claude's + * `{ type: 'command', command: '' }`. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + jsonDeepEqual, + readJsonFile, + removeMarkedSection, + writeJsonFile, + upsertInstructionsEntry, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, +} from '../instructions-template'; + +function configPath(loc: Location): string { + return loc === 'global' + ? path.join(os.homedir(), '.zcode', 'cli', 'config.json') + : path.join(process.cwd(), '.zcode', 'config.json'); +} + +function instructionsPath(loc: Location): string { + return loc === 'global' + ? path.join(os.homedir(), '.zcode', 'AGENTS.md') + : path.join(process.cwd(), 'AGENTS.md'); +} + +/** + * The verified ZCode server-entry shape: no `type` key (see module + * comment, point 2). Everything else ZCode needs to connect is the + * plain stdio command line. + */ +function getZcodeMcpServerConfig(): { command: string; args: string[] } { + return { + command: 'codegraph', + args: ['serve', '--mcp'], + }; +} + +/** + * The prompt-hook entry the installer writes. ZCode spawns hooks as a + * process with an argv array, so the platform handling differs from + * Claude's shell-string hooks (#1466): on Windows the spawned binary + * must be `codegraph.cmd` (a bare `codegraph` does not resolve through + * PATHEXT when spawned directly), elsewhere plain `codegraph`. + */ +const PROMPT_HOOK_COMMAND = process.platform === 'win32' ? 'codegraph.cmd' : 'codegraph'; +const PROMPT_HOOK_ARGS = ['prompt-hook']; +/** Matches the 30s ceiling the bundled ZCode plugins use for UserPromptSubmit. */ +const PROMPT_HOOK_TIMEOUT_MS = 30000; + +/** + * True when a hooks-array entry is the prompt hook we write (either + * platform's command spelling — a config carried across machines can + * hold the other one). Sibling process hooks never match: the command + * basename must be `codegraph`/`codegraph.cmd` AND argv must carry + * `prompt-hook`. + */ +function isPromptHookEntry(h: unknown): boolean { + if (!h || typeof h !== 'object') return false; + const hook = h as Record; + if (hook.type !== 'process' || typeof hook.command !== 'string') return false; + const base = path.basename(hook.command).replace(/\.exe$/i, ''); + if (base !== 'codegraph' && base !== 'codegraph.cmd') return false; + return Array.isArray(hook.args) && hook.args.some((a) => a === 'prompt-hook'); +} + +class ZcodeTarget implements AgentTarget { + readonly id = 'zcode' as const; + readonly displayName = 'ZCode'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const mcpPath = configPath(loc); + const config = readJsonFile(mcpPath); + const alreadyConfigured = !!config.mcp?.servers?.codegraph; + const installed = + fs.existsSync(mcpPath) || + fs.existsSync(instructionsPath(loc)) || + (loc === 'global' && fs.existsSync(path.join(os.homedir(), '.zcode'))); + return { installed, alreadyConfigured, configPath: mcpPath }; + } + + install(loc: Location, opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + + // 1. MCP server entry (nested mcp.servers). + files.push(writeMcpEntry(loc)); + + // 2. Front-load prompt hook. `promptHook === true` writes it; + // `=== false` strips a prior install's hook so opting out + // round-trips; `undefined` leaves it untouched. ZCode has no + // permissions/auto-allow surface, so `autoAllow` is a no-op here. + if (opts.promptHook === true) { + files.push(writePromptHookEntry(loc)); + } else if (opts.promptHook === false) { + const removed = removePromptHookEntry(loc); + if (removed.action === 'removed') files.push(removed); + } + + // 3. AGENTS.md instructions — same marker-fenced block as Claude + // (#704): ZCode subagents see AGENTS.md but not the MCP initialize + // instructions, and the shell fallback covers non-MCP sessions. + files.push(upsertInstructionsEntry(instructionsPath(loc))); + + return { + files, + notes: ['Restart ZCode sessions to apply (instructions and hooks load at session start).'], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + // 1. MCP server entry — surgical: only `mcp.servers.codegraph` is + // removed; sibling servers, `plugins`, and every other top-level + // key in config.json are preserved. + const mcpFile = configPath(loc); + const config = readJsonFile(mcpFile); + if (config.mcp?.servers?.codegraph) { + delete config.mcp.servers.codegraph; + if (config.mcp.servers && Object.keys(config.mcp.servers).length === 0) { + delete config.mcp.servers; + } + if (config.mcp && Object.keys(config.mcp).length === 0) { + delete config.mcp; + } + writeJsonFile(mcpFile, config); + files.push({ path: mcpFile, action: 'removed' }); + } else { + files.push({ path: mcpFile, action: 'not-found' }); + } + + // 2. Prompt hook. + const hookCleanup = removePromptHookEntry(loc); + if (hookCleanup.action === 'removed') files.push(hookCleanup); + + // 3. Instructions — strip the marker block, keep user content. + files.push(removeInstructionsEntry(loc)); + + return { files }; + } + + printConfig(loc: Location): string { + const target = configPath(loc); + const snippet = JSON.stringify( + { mcp: { servers: { codegraph: getZcodeMcpServerConfig() } } }, + null, + 2, + ); + return `# Add to ${target} (merge under the existing "mcp"."servers" when present)\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [configPath(loc), instructionsPath(loc)]; + } +} + +/** + * Write the `mcp.servers.codegraph` entry into the ZCode config.json. + * Idempotent (byte-equal re-runs report `unchanged`), and normalizes a + * hand-copied Claude-style entry (which carries `type: "stdio"`) down + * to the verified ZCode shape. All sibling keys — including other + * servers under `mcp.servers` and the top-level `plugins` block — are + * preserved verbatim. + */ +export function writeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = configPath(loc); + const existing = readJsonFile(file); + if (!existing.mcp || typeof existing.mcp !== 'object' || Array.isArray(existing.mcp)) { + existing.mcp = {}; + } + if (!existing.mcp.servers || typeof existing.mcp.servers !== 'object' || Array.isArray(existing.mcp.servers)) { + existing.mcp.servers = {}; + } + const before = existing.mcp.servers.codegraph; + const after = getZcodeMcpServerConfig(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = before || fs.existsSync(file) ? 'updated' : 'created'; + existing.mcp.servers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +/** + * Write the front-load `UserPromptSubmit` hook into the ZCode + * config.json (see the class comment for the shape). Sets + * `hooks.enabled: true` — config-file hooks are inert without it — and + * appends our process entry only when no prompt-hook entry exists yet. + * Sibling hooks in the same event survive untouched. Idempotent: + * re-runs leave a byte-identical file and report `unchanged`. + */ +export function writePromptHookEntry(loc: Location): WriteResult['files'][number] { + const file = configPath(loc); + const created = !fs.existsSync(file); + const config = readJsonFile(file); + + if (!config.hooks || typeof config.hooks !== 'object' || Array.isArray(config.hooks)) { + config.hooks = {}; + } + if (!Array.isArray(config.hooks.UserPromptSubmit)) config.hooks.UserPromptSubmit = []; + + let changed = false; + if (config.hooks.enabled !== true) { + config.hooks.enabled = true; + changed = true; + } + + const already = config.hooks.UserPromptSubmit.some( + (g: any) => g && Array.isArray(g.hooks) && g.hooks.some(isPromptHookEntry), + ); + if (!already) { + config.hooks.UserPromptSubmit.push({ + hooks: [ + { + type: 'process', + command: PROMPT_HOOK_COMMAND, + args: PROMPT_HOOK_ARGS, + timeoutMs: PROMPT_HOOK_TIMEOUT_MS, + }, + ], + }); + changed = true; + } + + if (!changed) { + return { path: file, action: 'unchanged' }; + } + writeJsonFile(file, config); + return { path: file, action: created ? 'created' : 'updated' }; +} + +/** + * Remove the prompt-hook entries this installer wrote, surgically: + * only entries matching `isPromptHookEntry` are dropped, a matcher + * group is pruned once its `hooks` array empties, and the + * `UserPromptSubmit` event once it has no groups left. `hooks.enabled` + * is deliberately LEFT in place — its provenance is unknowable (the + * user may rely on it for other config hooks), and a lingering + * `enabled: true` with no config-file hooks is inert. + */ +export function removePromptHookEntry(loc: Location): WriteResult['files'][number] { + const file = configPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + + const config = readJsonFile(file); + const hooks = config.hooks; + if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) { + return { path: file, action: 'unchanged' }; + } + const groups = hooks.UserPromptSubmit; + if (!Array.isArray(groups)) return { path: file, action: 'unchanged' }; + + let removedAny = false; + hooks.UserPromptSubmit = groups.filter((g: any) => { + if (!g || !Array.isArray(g.hooks)) return true; + const kept = g.hooks.filter((h: any) => !isPromptHookEntry(h)); + if (kept.length !== g.hooks.length) removedAny = true; + g.hooks = kept; + return g.hooks.length > 0; + }); + + if (!removedAny) return { path: file, action: 'unchanged' }; + if (hooks.UserPromptSubmit.length === 0) delete hooks.UserPromptSubmit; + + writeJsonFile(file, config); + return { path: file, action: 'removed' }; +} + +/** + * Strip the marker-delimited CodeGraph block from AGENTS.md if a prior + * install wrote one (uninstall, and nothing else — install upserts). + * `removeMarkedSection` returns `not-found`/`kept` when there's + * nothing to strip. + */ +export function removeInstructionsEntry(loc: Location): WriteResult['files'][number] { + const file = instructionsPath(loc); + const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + return { path: file, action }; +} + +export const zcodeTarget: AgentTarget = new ZcodeTarget();