From b371a89f23b0ffebc0e6e0c641405c6f8734496d Mon Sep 17 00:00:00 2001 From: modoojunko Date: Fri, 18 Sep 2026 11:48:33 +0800 Subject: [PATCH] feat(installer): add ZCode install target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZCode is a desktop coding agent with an MCP client, an AGENTS.md instruction surface, and a hooks runner. It is wired the same three-layer way as Claude Code (MCP entry + prompt hook + instructions block), with three config differences this target encodes: - MCP servers live NESTED under mcp.servers in ~/.zcode/cli/config.json (global) or /.zcode/config.json (local) — a top-level mcpServers key silently does nothing. Both scopes auto-connect at session start. - Server entries carry no type key (verified against ZCode's bundled servers); install normalizes a hand-copied Claude-style entry with type: "stdio" down to the verified shape. - Config-file hooks only run when hooks.enabled is true (plugin hooks auto-enable the runner; config hooks don't), and hook entries are process-shaped ({ type: 'process', command, args, timeoutMs }) rather than Claude's { type: 'command', command: '' }. The UserPromptSubmit prompt-hook is therefore now offered for ZCode too. Instructions go to ~/.zcode/AGENTS.md / /AGENTS.md — ZCode reads AGENTS.md (user file first, then workspace), so the same conditional marker block works in both scopes. The installer orchestrator, bin help text, README, and the target contract tests are updated; ZCode-specific tests cover the nested MCP shape, the enabled/process hook shape, sibling-hook preservation, opt-out round-trip, cross-platform command spelling, and uninstall surgery (hooks.enabled is deliberately left in place on removal). --- README.md | 12 +- __tests__/installer-targets.test.ts | 189 ++++++++++++++++ src/bin/codegraph.ts | 4 +- src/installer/index.ts | 12 +- src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- src/installer/targets/zcode.ts | 331 ++++++++++++++++++++++++++++ 7 files changed, 538 insertions(+), 14 deletions(-) create mode 100644 src/installer/targets/zcode.ts 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();