/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();