From 5a29a679530991087127b46a39ac87c7077662b9 Mon Sep 17 00:00:00 2001
From: Justin Scroggins <60281870+itz4blitz@users.noreply.github.com>
Date: Sun, 6 Sep 2026 10:55:34 -0400
Subject: [PATCH 1/3] feat(mcp): add Grok/ZCode adapters, native formats, and
PalamHealth scope
Codex now writes [mcp_servers] TOML and OpenCode writes opencode.json mcp
entries, merging into existing user files instead of inventing fake JSON
paths. Servers can be user- or project-layered with whenPathContains so
PalamHealth MCPs stay in PalamHealth repos. `ai-mcp audit` flags
interactive op-run-palamhealth and misnamed PalamHealth Linear.
---
README.md | 6 +-
packages/cli/README.md | 6 +-
packages/cli/src/plugins/hosts.ts | 20 ++
packages/cli/src/plugins/types.ts | 5 +-
packages/mcp/CLAUDE.md | 6 +-
packages/mcp/src/adapters/all.ts | 2 +
packages/mcp/src/adapters/base.ts | 12 +-
packages/mcp/src/adapters/codex.test.ts | 53 +++--
packages/mcp/src/adapters/codex.ts | 80 ++++----
packages/mcp/src/adapters/grok.test.ts | 97 ++++++++++
packages/mcp/src/adapters/grok.ts | 63 ++++++
packages/mcp/src/adapters/opencode.test.ts | 49 +++--
packages/mcp/src/adapters/opencode.ts | 145 +++++++++-----
packages/mcp/src/adapters/zcode.test.ts | 101 ++++++++++
packages/mcp/src/adapters/zcode.ts | 166 ++++++++++++++++
packages/mcp/src/audit.test.ts | 76 ++++++++
packages/mcp/src/audit.ts | 66 +++++++
packages/mcp/src/cli/index.test.ts | 43 +++++
packages/mcp/src/cli/index.ts | 56 +++++-
packages/mcp/src/config/define.test.ts | 4 +
packages/mcp/src/scope.test.ts | 70 +++++++
packages/mcp/src/scope.ts | 19 ++
packages/mcp/src/toml-mcp.test.ts | 116 +++++++++++
packages/mcp/src/toml-mcp.ts | 213 +++++++++++++++++++++
packages/mcp/src/types/config.ts | 2 +-
packages/mcp/src/types/definition.ts | 9 +-
packages/mcp/src/types/index.ts | 2 +-
27 files changed, 1327 insertions(+), 160 deletions(-)
create mode 100644 packages/mcp/src/adapters/grok.test.ts
create mode 100644 packages/mcp/src/adapters/grok.ts
create mode 100644 packages/mcp/src/adapters/zcode.test.ts
create mode 100644 packages/mcp/src/adapters/zcode.ts
create mode 100644 packages/mcp/src/audit.test.ts
create mode 100644 packages/mcp/src/audit.ts
create mode 100644 packages/mcp/src/scope.test.ts
create mode 100644 packages/mcp/src/scope.ts
create mode 100644 packages/mcp/src/toml-mcp.test.ts
create mode 100644 packages/mcp/src/toml-mcp.ts
diff --git a/README.md b/README.md
index ea36f96..8818e18 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Grok / grok build | | | | | |
-| zcode | | | | | |
+| Grok / grok build | | ✓ | | | |
+| zcode | | ✓ | | | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and zcode adapters are not in yet.
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode currently ship MCP adapters (TOML / zcode `mcp.servers`); other engines can follow.
## 60-second path
diff --git a/packages/cli/README.md b/packages/cli/README.md
index ea36f96..8818e18 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Grok / grok build | | | | | |
-| zcode | | | | | |
+| Grok / grok build | | ✓ | | | |
+| zcode | | ✓ | | | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and zcode adapters are not in yet.
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode currently ship MCP adapters (TOML / zcode `mcp.servers`); other engines can follow.
## 60-second path
diff --git a/packages/cli/src/plugins/hosts.ts b/packages/cli/src/plugins/hosts.ts
index e01e123..3311bc4 100644
--- a/packages/cli/src/plugins/hosts.ts
+++ b/packages/cli/src/plugins/hosts.ts
@@ -58,6 +58,26 @@ export const KNOWN_PLUGIN_HOSTS: Record = {
notes:
"OpenCode ships as a terminal app, desktop app, and IDE extension with first-class MCP support plus a native plugin system.",
},
+ grok: {
+ id: "grok",
+ name: "Grok",
+ kind: "cli",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: true,
+ notes:
+ "Grok reads project `.grok/config.toml` and user `~/.grok/config.toml`. MCP lives under [mcp_servers]. PalamHealth servers must be project-layer.",
+ },
+ zcode: {
+ id: "zcode",
+ name: "ZCode",
+ kind: "cli",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes:
+ "ZCode user MCP is ~/.zcode/cli/config.json (mcp.servers). Project MCP is .zcode/mcp.json. Do not put PalamHealth in the user file.",
+ },
"antigravity-cli": {
id: "antigravity-cli",
name: "Antigravity CLI",
diff --git a/packages/cli/src/plugins/types.ts b/packages/cli/src/plugins/types.ts
index 78f2c42..423c4a7 100644
--- a/packages/cli/src/plugins/types.ts
+++ b/packages/cli/src/plugins/types.ts
@@ -1,6 +1,7 @@
export type PluginMCPTransport =
| { type: "stdio"; command: string; args?: string[]; env?: Record }
- | { type: "sse"; url: string; headers?: Record };
+ | { type: "sse"; url: string; headers?: Record }
+ | { type: "http"; url: string; headers?: Record };
export type PluginMCPServerDefinition = {
id: string;
@@ -9,6 +10,8 @@ export type PluginMCPServerDefinition = {
transport: PluginMCPTransport;
enabled?: boolean;
tags?: string[];
+ layer?: "user" | "project";
+ whenPathContains?: string[];
};
export type PluginSkillDefinition = {
diff --git a/packages/mcp/CLAUDE.md b/packages/mcp/CLAUDE.md
index 39146cc..c34b437 100644
--- a/packages/mcp/CLAUDE.md
+++ b/packages/mcp/CLAUDE.md
@@ -6,10 +6,12 @@ MCP (Model Context Protocol) server configuration management. Simpler than hooks
`MCPServerDefinition`: `id`, `name`, `description?`, `transport`, `enabled?`, `tags?`
-Two transport types:
+Two transport types (plus `http`, which adapters map to native URL/remote forms):
- `{ type: "stdio", command: string, args?: string[], env?: Record }`
-- `{ type: "sse", url: string, headers?: Record }`
+- `{ type: "sse" | "http", url: string, headers?: Record }`
+
+Servers may set `layer: "user" | "project"` (default project) and `whenPathContains` so PalamHealth MCPs install only in PalamHealth repos. `ai-mcp audit` scans user-global configs for interactive `op-run-palamhealth` and PalamHealth servers that leaked out of project scope.
Config: `MCPConfig { servers: MCPServerDefinition[] }`
diff --git a/packages/mcp/src/adapters/all.ts b/packages/mcp/src/adapters/all.ts
index b5671b5..d8ad24a 100644
--- a/packages/mcp/src/adapters/all.ts
+++ b/packages/mcp/src/adapters/all.ts
@@ -8,7 +8,9 @@ import "./copilot.js";
import "./cursor.js";
import "./droid.js";
import "./gemini-cli.js";
+import "./grok.js";
import "./kiro.js";
import "./opencode.js";
import "./roo-code.js";
import "./windsurf.js";
+import "./zcode.js";
diff --git a/packages/mcp/src/adapters/base.ts b/packages/mcp/src/adapters/base.ts
index 9ab6c16..ec50819 100644
--- a/packages/mcp/src/adapters/base.ts
+++ b/packages/mcp/src/adapters/base.ts
@@ -1,13 +1,15 @@
import type { MCPServerDefinition, GeneratedFile } from "../types/index.js";
import { existsSync } from "node:fs";
import { readFile, writeFile, mkdir, rm } from "node:fs/promises";
-import { dirname, resolve } from "node:path";
+import { dirname, isAbsolute, resolve } from "node:path";
export abstract class BaseMCPAdapter {
abstract readonly id: string;
abstract readonly name: string;
abstract readonly nativeSupport: boolean;
abstract readonly configPath: string;
+ /** Absolute user-global config path when this host has one. */
+ readonly userConfigPath?: string;
/** Whether this adapter can use symlinks ("symlink") or needs file transformation ("transform"). */
readonly installStrategy: "symlink" | "transform" = "transform";
@@ -18,6 +20,12 @@ export abstract class BaseMCPAdapter {
abstract generate(servers: MCPServerDefinition[]): Promise;
abstract import(cwd?: string): Promise;
+ /** Read the user-global config. Defaults to import(homedir()). */
+ async importUser(): Promise {
+ const { homedir } = await import("node:os");
+ return this.import(homedir());
+ }
+
async detect(cwd?: string): Promise {
const dir = cwd ?? process.cwd();
const hasConfig = existsSync(resolve(dir, this.configPath));
@@ -39,7 +47,7 @@ export abstract class BaseMCPAdapter {
async install(files: GeneratedFile[], cwd?: string): Promise {
const dir = cwd ?? process.cwd();
for (const file of files) {
- const fullPath = resolve(dir, file.path);
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
await mkdir(dirname(fullPath), { recursive: true });
await writeFile(fullPath, file.content, "utf-8");
}
diff --git a/packages/mcp/src/adapters/codex.test.ts b/packages/mcp/src/adapters/codex.test.ts
index b2f8a63..10c173d 100644
--- a/packages/mcp/src/adapters/codex.test.ts
+++ b/packages/mcp/src/adapters/codex.test.ts
@@ -38,18 +38,17 @@ describe("CodexMCPAdapter", () => {
it("has correct id", () => expect(adapter.id).toBe("codex"));
it("has correct name", () => expect(adapter.name).toBe("Codex"));
it("has native support", () => expect(adapter.nativeSupport).toBe(true));
- it("has correct config path", () => expect(adapter.configPath).toBe(".codex/mcp.json"));
+ it("has correct config path", () => expect(adapter.configPath).toBe(".codex/config.toml"));
});
describe("generate", () => {
- it("generates valid JSON config", async () => {
+ it("generates native Codex TOML", async () => {
const files = await adapter.generate([testServer]);
expect(files).toHaveLength(1);
- expect(files[0]!.path).toBe(".codex/mcp.json");
- expect(files[0]!.format).toBe("json");
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["test-server"]).toBeDefined();
- expect(parsed.mcpServers["test-server"].command).toBe("npx");
+ expect(files[0]!.path).toBe(".codex/config.toml");
+ expect(files[0]!.format).toBe("toml");
+ expect(files[0]!.content).toContain("[mcp_servers.test-server]");
+ expect(files[0]!.content).toContain('command = "npx"');
});
it("handles SSE transport", async () => {
@@ -59,21 +58,20 @@ describe("CodexMCPAdapter", () => {
transport: { type: "sse", url: "http://localhost:3000" },
};
const files = await adapter.generate([sseServer]);
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000");
+ expect(files[0]!.content).toContain("[mcp_servers.sse-server]");
+ expect(files[0]!.content).toContain('url = "http://localhost:3000"');
});
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
- const parsed = JSON.parse(files[0]!.content);
- expect(Object.keys(parsed.mcpServers)).toHaveLength(0);
+ expect(files[0]!.content).toBe("");
});
it("handles multiple servers", async () => {
const servers = [testServer, { ...testServer, id: "server-2", name: "Server 2" }];
const files = await adapter.generate(servers);
- const parsed = JSON.parse(files[0]!.content);
- expect(Object.keys(parsed.mcpServers)).toHaveLength(2);
+ expect(files[0]!.content).toContain("[mcp_servers.test-server]");
+ expect(files[0]!.content).toContain("[mcp_servers.server-2]");
});
it("generates with optional fields undefined", async () => {
@@ -83,9 +81,8 @@ describe("CodexMCPAdapter", () => {
transport: { type: "stdio", command: "node" },
};
const files = await adapter.generate([server]);
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["minimal"].args).toEqual([]);
- expect(parsed.mcpServers["minimal"].env).toEqual({});
+ expect(files[0]!.content).toContain('command = "node"');
+ expect(files[0]!.content).not.toContain("args =");
});
});
@@ -98,11 +95,13 @@ describe("CodexMCPAdapter", () => {
it("imports stdio servers from config", async () => {
vi.mocked(existsSync).mockReturnValue(true);
- vi.mocked(readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: { "my-server": { command: "npx", args: ["-y", "test"], env: { A: "1" } } },
- }),
- );
+ vi.mocked(readFile).mockResolvedValue(`[mcp_servers.my-server]
+command = "npx"
+args = ["-y", "test"]
+
+[mcp_servers.my-server.env]
+A = "1"
+`);
const result = await adapter.import("/test");
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe("my-server");
@@ -111,19 +110,17 @@ describe("CodexMCPAdapter", () => {
it("imports SSE servers from config", async () => {
vi.mocked(existsSync).mockReturnValue(true);
- vi.mocked(readFile).mockResolvedValue(
- JSON.stringify({
- mcpServers: { sse: { url: "http://localhost:3000" } },
- }),
- );
+ vi.mocked(readFile).mockResolvedValue(`[mcp_servers.sse]
+url = "http://localhost:3000"
+`);
const result = await adapter.import("/test");
expect(result).toHaveLength(1);
- expect(result[0]!.transport.type).toBe("sse");
+ expect(result[0]!.transport.type).toBe("http");
});
it("imports with missing servers key", async () => {
vi.mocked(existsSync).mockReturnValue(true);
- vi.mocked(readFile).mockResolvedValue(JSON.stringify({}));
+ vi.mocked(readFile).mockResolvedValue("[ui]\nyolo = true\n");
const result = await adapter.import("/test");
expect(result).toEqual([]);
});
diff --git a/packages/mcp/src/adapters/codex.ts b/packages/mcp/src/adapters/codex.ts
index 0b44607..47f03cc 100644
--- a/packages/mcp/src/adapters/codex.ts
+++ b/packages/mcp/src/adapters/codex.ts
@@ -1,38 +1,26 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { MCPServerDefinition, GeneratedFile } from "../types/index.js";
-import { existsSync } from "node:fs";
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import { encodeMcpToml, mergeMcpToml, parseMcpToml } from "../toml-mcp.js";
class CodexMCPAdapter extends BaseMCPAdapter {
readonly id = "codex";
readonly name = "Codex";
readonly nativeSupport = true;
- readonly configPath = ".codex/mcp.json";
+ readonly configPath = ".codex/config.toml";
+ readonly userConfigPath = `${homedir()}/.codex/config.toml`;
readonly command = "codex";
async generate(servers: MCPServerDefinition[]): Promise {
- const mcpServers: Record = {};
- for (const server of servers) {
- if (server.transport.type === "stdio") {
- mcpServers[server.id] = {
- command: server.transport.command,
- args: server.transport.args ?? [],
- env: server.transport.env ?? {},
- };
- } else {
- mcpServers[server.id] = {
- url: server.transport.url,
- headers: server.transport.headers ?? {},
- };
- }
- }
return [
{
path: this.configPath,
- content: JSON.stringify({ mcpServers }, null, 2) + "\n",
- format: "json",
+ content: encodeMcpToml(servers),
+ format: "toml",
},
];
}
@@ -41,34 +29,30 @@ class CodexMCPAdapter extends BaseMCPAdapter {
const dir = cwd ?? process.cwd();
const fullPath = resolve(dir, this.configPath);
if (!existsSync(fullPath)) return [];
- const raw = await readFile(fullPath, "utf-8");
- const data = JSON.parse(raw) as { mcpServers?: Record> };
- const servers: MCPServerDefinition[] = [];
- for (const [id, config] of Object.entries(data.mcpServers ?? {})) {
- if (config.command) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "stdio",
- command: config.command as string,
- args: config.args as string[] | undefined,
- env: config.env as Record | undefined,
- },
- });
- } else if (config.url) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "sse",
- url: config.url as string,
- headers: config.headers as Record | undefined,
- },
- });
+ return parseMcpToml(await readFile(fullPath, "utf-8"));
+ }
+
+ override async importUser(): Promise {
+ if (!existsSync(this.userConfigPath)) return [];
+ return parseMcpToml(await readFile(this.userConfigPath, "utf-8"));
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ const managedIds = parseMcpToml(file.content).map((server) => server.id);
+ let content = file.content;
+ if (existsSync(fullPath)) {
+ content = mergeMcpToml(
+ await readFile(fullPath, "utf-8"),
+ parseMcpToml(file.content),
+ managedIds,
+ );
}
+ await mkdir(dirname(fullPath), { recursive: true });
+ await writeFile(fullPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
}
- return servers;
}
}
diff --git a/packages/mcp/src/adapters/grok.test.ts b/packages/mcp/src/adapters/grok.test.ts
new file mode 100644
index 0000000..35de140
--- /dev/null
+++ b/packages/mcp/src/adapters/grok.test.ts
@@ -0,0 +1,97 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { homedir } from "node:os";
+
+vi.mock("./registry.js", () => ({
+ registry: { register: vi.fn() },
+}));
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn(),
+ rm: vi.fn(),
+}));
+
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { GrokMCPAdapter } from "./grok.js";
+import type { MCPServerDefinition } from "../types/index.js";
+
+describe("GrokMCPAdapter", () => {
+ let adapter: GrokMCPAdapter;
+ const stdio: MCPServerDefinition = {
+ id: "context7",
+ name: "context7",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-mcp-run-grok",
+ args: ["npx", "-y", "@upstash/context7-mcp"],
+ env: { CONTEXT7_API_KEY: "op://mcp-secrets/Context7 API Key/password" },
+ },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adapter = new GrokMCPAdapter();
+ });
+
+ it("uses Grok native TOML paths", () => {
+ expect(adapter.id).toBe("grok");
+ expect(adapter.name).toBe("Grok");
+ expect(adapter.configPath).toBe(".grok/config.toml");
+ expect(adapter.userConfigPath).toBe(`${homedir()}/.grok/config.toml`);
+ expect(adapter.command).toBe("grok");
+ });
+
+ it("generates [mcp_servers] TOML, not JSON", async () => {
+ const files = await adapter.generate([stdio]);
+ expect(files[0]?.path).toBe(".grok/config.toml");
+ expect(files[0]?.format).toBe("toml");
+ expect(files[0]?.content).toContain("[mcp_servers.context7]");
+ expect(files[0]?.content).toContain("[mcp_servers.context7.env]");
+ expect(files[0]?.content).not.toContain("mcpServers");
+ });
+
+ it("generates quoted tables for names with spaces", async () => {
+ const files = await adapter.generate([
+ {
+ id: "GWS - Premier Studio",
+ name: "GWS - Premier Studio",
+ transport: { type: "stdio", command: "/bin/gws-mcp-premier-studio" },
+ },
+ ]);
+ expect(files[0]?.content).toContain('[mcp_servers."GWS - Premier Studio"]');
+ });
+
+ it("imports nothing when neither config exists", async () => {
+ vi.mocked(existsSync).mockReturnValue(false);
+ expect(await adapter.import("/tmp/project")).toEqual([]);
+ });
+
+ it("imports project TOML servers", async () => {
+ vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith(".grok/config.toml"));
+ vi.mocked(readFile).mockResolvedValue(`[mcp_servers.github]
+command = "gh-mcp"
+`);
+ const servers = await adapter.import("/tmp/project");
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("github");
+ expect(servers[0]?.transport).toEqual({ type: "stdio", command: "gh-mcp" });
+ });
+
+ it("merges into an existing user config instead of replacing it", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(`[ui]\nyolo = false\n`);
+ const files = await adapter.generate([stdio]);
+ await adapter.install(files, "/tmp/project");
+ const written = String(vi.mocked(writeFile).mock.calls[0]?.[1]);
+ expect(written).toContain("[ui]");
+ expect(written).toContain("yolo = false");
+ expect(written).toContain("[mcp_servers.context7]");
+ expect(vi.mocked(mkdir)).toHaveBeenCalled();
+ });
+});
diff --git a/packages/mcp/src/adapters/grok.ts b/packages/mcp/src/adapters/grok.ts
new file mode 100644
index 0000000..a7fa7a4
--- /dev/null
+++ b/packages/mcp/src/adapters/grok.ts
@@ -0,0 +1,63 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
+import { BaseMCPAdapter } from "./base.js";
+import { registry } from "./registry.js";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import { encodeMcpToml, mergeMcpToml, parseMcpToml } from "../toml-mcp.js";
+
+class GrokMCPAdapter extends BaseMCPAdapter {
+ readonly id = "grok";
+ readonly name = "Grok";
+ readonly nativeSupport = true;
+ readonly configPath = ".grok/config.toml";
+ readonly userConfigPath = `${homedir()}/.grok/config.toml`;
+ readonly command = "grok";
+
+ async generate(servers: MCPServerDefinition[]): Promise {
+ return [
+ {
+ path: this.configPath,
+ content: encodeMcpToml(servers),
+ format: "toml",
+ },
+ ];
+ }
+
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ return this.readToml(resolve(dir, this.configPath));
+ }
+
+ override async importUser(): Promise {
+ return this.readToml(this.userConfigPath);
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ const managedIds = parseMcpToml(file.content).map((server) => server.id);
+ let content = file.content;
+ if (existsSync(fullPath)) {
+ const existing = await readFile(fullPath, "utf-8");
+ content = mergeMcpToml(existing, parseMcpToml(file.content), managedIds);
+ }
+ await mkdir(dirname(fullPath), { recursive: true });
+ await writeFile(fullPath, content.endsWith("\n") ? content : `${content}\n`, "utf-8");
+ }
+ }
+
+ private async readToml(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const raw = await readFile(fullPath, "utf-8");
+ return parseMcpToml(raw);
+ }
+}
+
+const adapter = new GrokMCPAdapter();
+registry.register(adapter);
+
+export { GrokMCPAdapter };
+export default adapter;
diff --git a/packages/mcp/src/adapters/opencode.test.ts b/packages/mcp/src/adapters/opencode.test.ts
index 0a3c14b..559085e 100644
--- a/packages/mcp/src/adapters/opencode.test.ts
+++ b/packages/mcp/src/adapters/opencode.test.ts
@@ -38,18 +38,20 @@ describe("OpenCodeMCPAdapter", () => {
it("has correct id", () => expect(adapter.id).toBe("opencode"));
it("has correct name", () => expect(adapter.name).toBe("OpenCode"));
it("has native support", () => expect(adapter.nativeSupport).toBe(true));
- it("has correct config path", () => expect(adapter.configPath).toBe(".opencode/mcp.json"));
+ it("has correct config path", () => expect(adapter.configPath).toBe("opencode.json"));
});
describe("generate", () => {
- it("generates valid JSON config", async () => {
+ it("generates native OpenCode mcp entries", async () => {
const files = await adapter.generate([testServer]);
expect(files).toHaveLength(1);
- expect(files[0]!.path).toBe(".opencode/mcp.json");
+ expect(files[0]!.path).toBe("opencode.json");
expect(files[0]!.format).toBe("json");
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["test-server"]).toBeDefined();
- expect(parsed.mcpServers["test-server"].command).toBe("npx");
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcp: Record;
+ };
+ expect(parsed.mcp["test-server"]?.type).toBe("local");
+ expect(parsed.mcp["test-server"]?.command[0]).toBe("npx");
});
it("handles SSE transport", async () => {
@@ -59,21 +61,24 @@ describe("OpenCodeMCPAdapter", () => {
transport: { type: "sse", url: "http://localhost:3000" },
};
const files = await adapter.generate([sseServer]);
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000");
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcp: Record;
+ };
+ expect(parsed.mcp["sse-server"]?.type).toBe("remote");
+ expect(parsed.mcp["sse-server"]?.url).toBe("http://localhost:3000");
});
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
- const parsed = JSON.parse(files[0]!.content);
- expect(Object.keys(parsed.mcpServers)).toHaveLength(0);
+ const parsed = JSON.parse(files[0]!.content) as { mcp: Record };
+ expect(Object.keys(parsed.mcp)).toHaveLength(0);
});
it("handles multiple servers", async () => {
const servers = [testServer, { ...testServer, id: "server-2", name: "Server 2" }];
const files = await adapter.generate(servers);
- const parsed = JSON.parse(files[0]!.content);
- expect(Object.keys(parsed.mcpServers)).toHaveLength(2);
+ const parsed = JSON.parse(files[0]!.content) as { mcp: Record };
+ expect(Object.keys(parsed.mcp)).toHaveLength(2);
});
it("generates with optional fields undefined", async () => {
@@ -83,9 +88,11 @@ describe("OpenCodeMCPAdapter", () => {
transport: { type: "stdio", command: "node" },
};
const files = await adapter.generate([server]);
- const parsed = JSON.parse(files[0]!.content);
- expect(parsed.mcpServers["minimal"].args).toEqual([]);
- expect(parsed.mcpServers["minimal"].env).toEqual({});
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcp: Record }>;
+ };
+ expect(parsed.mcp["minimal"]?.command).toEqual(["node"]);
+ expect(parsed.mcp["minimal"]?.environment).toEqual({});
});
});
@@ -100,7 +107,13 @@ describe("OpenCodeMCPAdapter", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
- mcpServers: { "my-server": { command: "npx", args: ["-y", "test"], env: { A: "1" } } },
+ mcp: {
+ "my-server": {
+ type: "local",
+ command: ["npx", "-y", "test"],
+ environment: { A: "1" },
+ },
+ },
}),
);
const result = await adapter.import("/test");
@@ -113,12 +126,12 @@ describe("OpenCodeMCPAdapter", () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({
- mcpServers: { sse: { url: "http://localhost:3000" } },
+ mcp: { sse: { type: "remote", url: "http://localhost:3000" } },
}),
);
const result = await adapter.import("/test");
expect(result).toHaveLength(1);
- expect(result[0]!.transport.type).toBe("sse");
+ expect(result[0]!.transport.type).toBe("http");
});
it("imports with missing servers key", async () => {
diff --git a/packages/mcp/src/adapters/opencode.ts b/packages/mcp/src/adapters/opencode.ts
index cfa1365..433b6f6 100644
--- a/packages/mcp/src/adapters/opencode.ts
+++ b/packages/mcp/src/adapters/opencode.ts
@@ -1,37 +1,33 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { MCPServerDefinition, GeneratedFile } from "../types/index.js";
-import { existsSync } from "node:fs";
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+
+type OpenCodeEntry = {
+ type?: string;
+ command?: string[];
+ url?: string;
+ enabled?: boolean;
+ environment?: Record;
+ oauth?: Record;
+};
class OpenCodeMCPAdapter extends BaseMCPAdapter {
readonly id = "opencode";
readonly name = "OpenCode";
readonly nativeSupport = true;
- readonly configPath = ".opencode/mcp.json";
+ readonly configPath = "opencode.json";
+ readonly userConfigPath = `${homedir()}/.config/opencode/opencode.json`;
readonly command = "opencode";
async generate(servers: MCPServerDefinition[]): Promise {
- const mcpServers: Record = {};
- for (const server of servers) {
- if (server.transport.type === "stdio") {
- mcpServers[server.id] = {
- command: server.transport.command,
- args: server.transport.args ?? [],
- env: server.transport.env ?? {},
- };
- } else {
- mcpServers[server.id] = {
- url: server.transport.url,
- headers: server.transport.headers ?? {},
- };
- }
- }
return [
{
path: this.configPath,
- content: JSON.stringify({ mcpServers }, null, 2) + "\n",
+ content: `${JSON.stringify({ mcp: toOpenCodeMcp(servers) }, null, 2)}\n`,
format: "json",
},
];
@@ -39,37 +35,88 @@ class OpenCodeMCPAdapter extends BaseMCPAdapter {
async import(cwd?: string): Promise {
const dir = cwd ?? process.cwd();
- const fullPath = resolve(dir, this.configPath);
- if (!existsSync(fullPath)) return [];
- const raw = await readFile(fullPath, "utf-8");
- const data = JSON.parse(raw) as { mcpServers?: Record> };
- const servers: MCPServerDefinition[] = [];
- for (const [id, config] of Object.entries(data.mcpServers ?? {})) {
- if (config.command) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "stdio",
- command: config.command as string,
- args: config.args as string[] | undefined,
- env: config.env as Record | undefined,
- },
- });
- } else if (config.url) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "sse",
- url: config.url as string,
- headers: config.headers as Record | undefined,
- },
- });
+ return this.readOpenCode(resolve(dir, this.configPath));
+ }
+
+ override async importUser(): Promise {
+ return this.readOpenCode(this.userConfigPath);
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ await mkdir(dirname(fullPath), { recursive: true });
+ if (existsSync(fullPath)) {
+ const existing = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ mcp?: Record;
+ };
+ const generated = JSON.parse(file.content) as { mcp?: Record };
+ existing.mcp = { ...existing.mcp, ...generated.mcp };
+ await writeFile(fullPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
+ continue;
}
+ await writeFile(fullPath, file.content, "utf-8");
+ }
+ }
+
+ private async readOpenCode(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const data = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ mcp?: Record;
+ };
+ return fromOpenCodeMcp(data.mcp ?? {});
+ }
+}
+
+function toOpenCodeMcp(servers: MCPServerDefinition[]): Record {
+ const mcp: Record = {};
+ for (const server of servers) {
+ if (server.transport.type === "stdio") {
+ mcp[server.id] = {
+ type: "local",
+ command: [server.transport.command, ...(server.transport.args ?? [])],
+ enabled: server.enabled !== false,
+ environment: server.transport.env ?? {},
+ };
+ } else {
+ mcp[server.id] = {
+ type: "remote",
+ url: server.transport.url,
+ enabled: server.enabled !== false,
+ };
+ }
+ }
+ return mcp;
+}
+
+function fromOpenCodeMcp(mcp: Record): MCPServerDefinition[] {
+ const servers: MCPServerDefinition[] = [];
+ for (const [id, entry] of Object.entries(mcp)) {
+ if ((entry.type === "local" || entry.command) && entry.command && entry.command.length > 0) {
+ const [command, ...args] = entry.command;
+ if (command === undefined) continue;
+ servers.push({
+ id,
+ name: id,
+ transport: {
+ type: "stdio",
+ command,
+ args,
+ env: entry.environment,
+ },
+ enabled: entry.enabled,
+ });
+ } else if (entry.url) {
+ servers.push({
+ id,
+ name: id,
+ transport: { type: "http", url: entry.url },
+ enabled: entry.enabled,
+ });
}
- return servers;
}
+ return servers;
}
const adapter = new OpenCodeMCPAdapter();
diff --git a/packages/mcp/src/adapters/zcode.test.ts b/packages/mcp/src/adapters/zcode.test.ts
new file mode 100644
index 0000000..9b5a9a6
--- /dev/null
+++ b/packages/mcp/src/adapters/zcode.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { homedir } from "node:os";
+
+vi.mock("./registry.js", () => ({
+ registry: { register: vi.fn() },
+}));
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ writeFile: vi.fn(),
+ mkdir: vi.fn(),
+ rm: vi.fn(),
+}));
+
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { ZcodeMCPAdapter } from "./zcode.js";
+import type { MCPServerDefinition } from "../types/index.js";
+
+describe("ZcodeMCPAdapter", () => {
+ let adapter: ZcodeMCPAdapter;
+ const stdio: MCPServerDefinition = {
+ id: "github",
+ name: "github",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp", args: [] },
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adapter = new ZcodeMCPAdapter();
+ });
+
+ it("uses ZCode user and project paths", () => {
+ expect(adapter.id).toBe("zcode");
+ expect(adapter.name).toBe("ZCode");
+ expect(adapter.configPath).toBe(".zcode/mcp.json");
+ expect(adapter.userConfigPath).toBe(`${homedir()}/.zcode/cli/config.json`);
+ expect(adapter.command).toBe("zcode");
+ });
+
+ it("generates project mcpServers JSON", async () => {
+ const files = await adapter.generate([stdio]);
+ expect(files[0]?.path).toBe(".zcode/mcp.json");
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcpServers: Record;
+ };
+ expect(parsed.mcpServers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp");
+ });
+
+ it("imports user mcp.servers without clobbering other config keys", async () => {
+ vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith("config.json"));
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({
+ model: "keep-me",
+ mcp: {
+ servers: {
+ palamhealth: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/palamhealth-mcp-stdio",
+ args: [],
+ env: { OP_ACCOUNT: "palamhealth.1password.com" },
+ },
+ },
+ },
+ }),
+ );
+ const servers = await adapter.importUser();
+ expect(servers).toHaveLength(1);
+ expect(servers[0]?.id).toBe("palamhealth");
+ expect(servers[0]?.transport.type).toBe("stdio");
+ });
+
+ it("merges generated servers into existing user config.json", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({ model: "keep-me", mcp: { servers: {} } }),
+ );
+ await adapter.install(
+ [
+ {
+ path: `${homedir()}/.zcode/cli/config.json`,
+ content: JSON.stringify({ mcpServers: { github: { command: "gh-mcp", args: [] } } }),
+ format: "json",
+ },
+ ],
+ "/tmp",
+ );
+ const written = JSON.parse(String(vi.mocked(writeFile).mock.calls[0]?.[1])) as {
+ model: string;
+ mcp: { servers: Record };
+ };
+ expect(written.model).toBe("keep-me");
+ expect(written.mcp.servers.github?.type).toBe("stdio");
+ expect(written.mcp.servers.github?.command).toBe("gh-mcp");
+ expect(vi.mocked(mkdir)).toHaveBeenCalled();
+ });
+});
diff --git a/packages/mcp/src/adapters/zcode.ts b/packages/mcp/src/adapters/zcode.ts
new file mode 100644
index 0000000..81c8173
--- /dev/null
+++ b/packages/mcp/src/adapters/zcode.ts
@@ -0,0 +1,166 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
+import { BaseMCPAdapter } from "./base.js";
+import { registry } from "./registry.js";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+
+type ZcodeServer = {
+ type?: string;
+ command?: string;
+ args?: string[];
+ env?: Record;
+ url?: string;
+ timeoutMs?: number;
+};
+
+class ZcodeMCPAdapter extends BaseMCPAdapter {
+ readonly id = "zcode";
+ readonly name = "ZCode";
+ readonly nativeSupport = true;
+ readonly configPath = ".zcode/mcp.json";
+ readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`;
+ readonly command = "zcode";
+
+ async generate(servers: MCPServerDefinition[]): Promise {
+ return [
+ {
+ path: this.configPath,
+ content: `${JSON.stringify({ mcpServers: toMcpServers(servers) }, null, 2)}\n`,
+ format: "json",
+ },
+ ];
+ }
+
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ return this.readMcpServers(resolve(dir, this.configPath));
+ }
+
+ override async importUser(): Promise {
+ return this.readUserServers(this.userConfigPath);
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ await mkdir(dirname(fullPath), { recursive: true });
+ if (fullPath.endsWith("config.json") && existsSync(fullPath)) {
+ const existing = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ mcp?: { servers?: Record };
+ };
+ const generated = JSON.parse(file.content) as {
+ mcpServers?: Record<
+ string,
+ { command?: string; args?: string[]; env?: Record; url?: string }
+ >;
+ };
+ const servers = existing.mcp?.servers ?? {};
+ for (const [id, spec] of Object.entries(generated.mcpServers ?? {})) {
+ servers[id] = toZcodeServer(spec);
+ }
+ existing.mcp = { ...existing.mcp, servers };
+ await writeFile(fullPath, `${JSON.stringify(existing, null, 2)}\n`, "utf-8");
+ continue;
+ }
+ await writeFile(fullPath, file.content, "utf-8");
+ }
+ }
+
+ private async readMcpServers(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const data = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ mcpServers?: Record>;
+ };
+ return fromGeneric(data.mcpServers ?? {});
+ }
+
+ private async readUserServers(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const data = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ mcp?: { servers?: Record };
+ };
+ const servers = data.mcp?.servers ?? {};
+ const out: MCPServerDefinition[] = [];
+ for (const [id, spec] of Object.entries(servers)) {
+ if (spec.command) {
+ out.push({
+ id,
+ name: id,
+ transport: { type: "stdio", command: spec.command, args: spec.args, env: spec.env },
+ });
+ } else if (spec.url) {
+ out.push({ id, name: id, transport: { type: "http", url: spec.url } });
+ }
+ }
+ return out;
+ }
+}
+
+function toMcpServers(servers: MCPServerDefinition[]): Record> {
+ const mcpServers: Record> = {};
+ for (const server of servers) {
+ if (server.transport.type === "stdio") {
+ mcpServers[server.id] = {
+ command: server.transport.command,
+ args: server.transport.args ?? [],
+ env: server.transport.env ?? {},
+ };
+ } else {
+ mcpServers[server.id] = {
+ url: server.transport.url,
+ headers: server.transport.headers ?? {},
+ };
+ }
+ }
+ return mcpServers;
+}
+
+function toZcodeServer(spec: {
+ command?: string;
+ args?: string[];
+ env?: Record;
+ url?: string;
+}): ZcodeServer {
+ if (spec.command) {
+ return { type: "stdio", command: spec.command, args: spec.args ?? [], env: spec.env ?? {} };
+ }
+ return { type: "http", url: spec.url };
+}
+
+function fromGeneric(mcpServers: Record>): MCPServerDefinition[] {
+ const servers: MCPServerDefinition[] = [];
+ for (const [id, config] of Object.entries(mcpServers)) {
+ if (typeof config.command === "string") {
+ servers.push({
+ id,
+ name: id,
+ transport: {
+ type: "stdio",
+ command: config.command,
+ args: config.args as string[] | undefined,
+ env: config.env as Record | undefined,
+ },
+ });
+ } else if (typeof config.url === "string") {
+ servers.push({
+ id,
+ name: id,
+ transport: {
+ type: "http",
+ url: config.url,
+ headers: config.headers as Record | undefined,
+ },
+ });
+ }
+ }
+ return servers;
+}
+
+const adapter = new ZcodeMCPAdapter();
+registry.register(adapter);
+
+export { ZcodeMCPAdapter };
+export default adapter;
diff --git a/packages/mcp/src/audit.test.ts b/packages/mcp/src/audit.test.ts
new file mode 100644
index 0000000..4973112
--- /dev/null
+++ b/packages/mcp/src/audit.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from "vitest";
+import { auditServers } from "./audit.js";
+import type { MCPServerDefinition } from "./types/index.js";
+
+describe("auditServers", () => {
+ it("flags interactive op-run-palamhealth but not the service-account wrapper", () => {
+ const interactive: MCPServerDefinition = {
+ id: "outline",
+ name: "outline",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-run-palamhealth",
+ args: ["npx", "mcp-remote", "https://docs.palamhealth.dev/mcp"],
+ },
+ };
+ const headless: MCPServerDefinition = {
+ id: "outline-sa",
+ name: "outline-sa",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-run-palamhealth-sa",
+ args: ["npx"],
+ },
+ };
+ const findings = [
+ ...auditServers("cursor", [interactive], "user"),
+ ...auditServers("cursor", [headless], "user"),
+ ];
+ expect(findings.some((f) => f.kind === "interactive-palamhealth-account")).toBe(true);
+ expect(
+ findings.filter(
+ (f) => f.server === "outline-sa" && f.kind === "interactive-palamhealth-account",
+ ),
+ ).toEqual([]);
+ });
+
+ it("flags PalamHealth servers in user-global configs, not project configs", () => {
+ const palam: MCPServerDefinition = {
+ id: "palamhealth",
+ name: "palamhealth",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/palamhealth-mcp-stdio" },
+ };
+ expect(auditServers("zcode", [palam], "user").map((f) => f.kind)).toContain(
+ "palamhealth-in-user-config",
+ );
+ expect(auditServers("zcode", [palam], "project").map((f) => f.kind)).not.toContain(
+ "palamhealth-in-user-config",
+ );
+ });
+
+ it("flags PalamHealth Linear registered as generic linear", () => {
+ const linear: MCPServerDefinition = {
+ id: "linear",
+ name: "linear",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/linear-mcp-palamhealth" },
+ };
+ expect(auditServers("cursor", [linear], "user").map((f) => f.kind)).toContain(
+ "misnamed-palamhealth-linear",
+ );
+ });
+
+ it("flags OP_ACCOUNT=palamhealth.1password.com", () => {
+ const server: MCPServerDefinition = {
+ id: "linear-palamhealth",
+ name: "linear-palamhealth",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-run-palamhealth",
+ env: { OP_ACCOUNT: "palamhealth.1password.com" },
+ },
+ };
+ const kinds = auditServers("zcode", [server], "user").map((f) => f.kind);
+ expect(kinds).toContain("interactive-palamhealth-account");
+ expect(kinds).toContain("palamhealth-in-user-config");
+ });
+});
diff --git a/packages/mcp/src/audit.ts b/packages/mcp/src/audit.ts
new file mode 100644
index 0000000..ca8293b
--- /dev/null
+++ b/packages/mcp/src/audit.ts
@@ -0,0 +1,66 @@
+import type { MCPServerDefinition } from "./types/index.js";
+
+export type AuditKind =
+ | "interactive-palamhealth-account"
+ | "palamhealth-in-user-config"
+ | "misnamed-palamhealth-linear";
+
+export type AuditFinding = {
+ tool: string;
+ server: string;
+ kind: AuditKind;
+ detail: string;
+};
+
+const INTERACTIVE_WRAPPER = "op-run-palamhealth";
+const PALAMHEALTH_ACCOUNT = "palamhealth.1password.com";
+const PALAMHEALTH_LINEAR = "linear-mcp-palamhealth";
+const PALAMHEALTH_IDS = new Set(["palamhealth", "outline", "linear-palamhealth"]);
+
+export function auditServers(
+ tool: string,
+ servers: MCPServerDefinition[],
+ source: "user" | "project" = "user",
+): AuditFinding[] {
+ const findings: AuditFinding[] = [];
+ for (const server of servers) {
+ const command = server.transport.type === "stdio" ? server.transport.command : "";
+ const env = server.transport.type === "stdio" ? (server.transport.env ?? {}) : {};
+ const usesSa = command.endsWith("op-run-palamhealth-sa") || command === "op-run-palamhealth-sa";
+ const usesInteractive =
+ !usesSa && (command.endsWith(`/${INTERACTIVE_WRAPPER}`) || command === INTERACTIVE_WRAPPER);
+ if (usesInteractive || env.OP_ACCOUNT === PALAMHEALTH_ACCOUNT) {
+ findings.push({
+ tool,
+ server: server.id,
+ kind: "interactive-palamhealth-account",
+ detail: `${server.id} uses interactive PalamHealth 1Password (${INTERACTIVE_WRAPPER} or OP_ACCOUNT=${PALAMHEALTH_ACCOUNT})`,
+ });
+ }
+
+ const url = "url" in server.transport ? server.transport.url : "";
+ const isPalamProduct =
+ PALAMHEALTH_IDS.has(server.id) ||
+ url.includes("palamhealth.dev") ||
+ command.includes("palamhealth-mcp") ||
+ command.includes(PALAMHEALTH_LINEAR);
+ if (source === "user" && isPalamProduct) {
+ findings.push({
+ tool,
+ server: server.id,
+ kind: "palamhealth-in-user-config",
+ detail: `${server.id} looks like PalamHealth MCP in a global/user config`,
+ });
+ }
+
+ if (server.id === "linear" && command.includes(PALAMHEALTH_LINEAR)) {
+ findings.push({
+ tool,
+ server: server.id,
+ kind: "misnamed-palamhealth-linear",
+ detail: "PalamHealth Linear is registered as generic id `linear`",
+ });
+ }
+ }
+ return findings;
+}
diff --git a/packages/mcp/src/cli/index.test.ts b/packages/mcp/src/cli/index.test.ts
index 8585239..6dc0812 100644
--- a/packages/mcp/src/cli/index.test.ts
+++ b/packages/mcp/src/cli/index.test.ts
@@ -56,6 +56,8 @@ function makeAdapter(overrides: Partial = {}): BaseMCPAdapter {
vi.fn<(files: GeneratedFile[], cwd?: string) => Promise>().mockResolvedValue(undefined),
uninstall:
overrides.uninstall ?? vi.fn<(cwd?: string) => Promise>().mockResolvedValue(undefined),
+ importUser:
+ overrides.importUser ?? vi.fn<() => Promise>().mockResolvedValue([]),
} as unknown as BaseMCPAdapter;
}
@@ -149,6 +151,7 @@ describe("run() - help output", () => {
"import",
"sync",
"export",
+ "audit",
"help",
]) {
expect(output).toContain(cmd);
@@ -535,3 +538,43 @@ describe("run() - error propagation", () => {
await expect(run(["install"])).rejects.toThrow("install failed");
});
});
+
+describe("run() - audit command", () => {
+ it("reports a clean scan", async () => {
+ const adapter = makeAdapter({
+ id: "cursor",
+ name: "Cursor",
+ importUser: vi.fn<() => Promise>().mockResolvedValue([
+ {
+ id: "github",
+ name: "github",
+ transport: { type: "stdio", command: "gh-mcp" },
+ },
+ ]),
+ });
+ mockRegistryGetAll.mockReturnValue([adapter]);
+ await run(["audit"]);
+ expect(allLog()).toContain("No PalamHealth user-global MCP leaks found.");
+ });
+
+ it("exits 1 when PalamHealth is in a user-global config", async () => {
+ const adapter = makeAdapter({
+ id: "zcode",
+ name: "ZCode",
+ importUser: vi.fn<() => Promise>().mockResolvedValue([
+ {
+ id: "outline",
+ name: "outline",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-run-palamhealth",
+ },
+ },
+ ]),
+ });
+ mockRegistryGetAll.mockReturnValue([adapter]);
+ await expect(run(["audit"])).rejects.toThrow("process.exit(1)");
+ expect(allLog()).toContain("interactive-palamhealth-account");
+ expect(exitCode).toBe(1);
+ });
+});
diff --git a/packages/mcp/src/cli/index.ts b/packages/mcp/src/cli/index.ts
index a093254..ca08e1f 100644
--- a/packages/mcp/src/cli/index.ts
+++ b/packages/mcp/src/cli/index.ts
@@ -1,6 +1,9 @@
+import { homedir } from "node:os";
import { registry } from "../adapters/registry.js";
import type { BaseMCPAdapter } from "../adapters/base.js";
import type { MCPServerDefinition, MCPConfig, GeneratedFile } from "../types/index.js";
+import { filterServers } from "../scope.js";
+import { auditServers } from "../audit.js";
// Import all adapters to register them
import "../adapters/all.js";
@@ -19,12 +22,14 @@ COMMANDS:
import Import MCP servers from an existing tool's config
sync Sync MCP servers across all detected tools
export Export MCP server definitions as JSON
+ audit Find PalamHealth / interactive-1Password leaks in user-global configs
help Show this help message
OPTIONS:
--tools Comma-separated list of tools (e.g., --tools=claude-code,cursor)
--dry-run Show what would be generated without writing files
--force Skip detection checks for --tools (install even if tool not found)
+ --layer user or project (default: project). Controls which servers install.
EXAMPLES:
ai-mcp detect # See which AI tools support MCP
@@ -40,6 +45,7 @@ type Flags = {
config?: string;
dryRun?: boolean;
force?: boolean;
+ layer?: "user" | "project";
};
export async function run(args: string[]): Promise {
@@ -68,6 +74,9 @@ export async function run(args: string[]): Promise {
case "export":
await cmdExport(flags);
break;
+ case "audit":
+ await cmdAudit(flags);
+ break;
case "help":
case "--help":
case "-h":
@@ -148,11 +157,12 @@ async function cmdGenerate(flags: Flags): Promise {
}
const config = await loadConfig(flags.config);
+ const servers = selectServers(config.servers, flags);
console.log(`Generating MCP configs for ${adapters.length} tool(s)...\n`);
for (const adapter of adapters) {
- const files = await adapter.generate(config.servers);
+ const files = await adapter.generate(servers);
for (const file of files) {
if (flags.dryRun) {
@@ -179,18 +189,25 @@ async function cmdInstall(flags: Flags): Promise {
}
const config = await loadConfig(flags.config);
+ const servers = selectServers(config.servers, flags);
+ const layer = flags.layer ?? "project";
console.log(`Installing MCP servers into ${adapters.length} tool(s)...\n`);
for (const adapter of adapters) {
- const files = await adapter.generate(config.servers);
+ const files = await adapter.generate(servers);
+ if (layer === "user" && adapter.userConfigPath) {
+ for (const file of files) {
+ file.path = adapter.userConfigPath;
+ }
+ }
if (flags.dryRun) {
for (const file of files) {
console.log(` [dry-run] Would install: ${file.path}`);
}
} else {
- await adapter.install(files);
+ await adapter.install(files, layer === "user" ? homedir() : process.cwd());
console.log(` \u2713 ${adapter.name}`);
}
}
@@ -272,6 +289,29 @@ async function cmdExport(flags: Flags): Promise {
console.log(JSON.stringify([...allServers.values()], null, 2));
}
+async function cmdAudit(flags: Flags): Promise {
+ const adapters = flags.tools
+ ? await resolveAdapters({ ...flags, force: true })
+ : registry.getAll();
+
+ const findings = [];
+ for (const adapter of adapters) {
+ const servers = await adapter.importUser();
+ findings.push(...auditServers(adapter.id, servers, "user"));
+ }
+
+ if (findings.length === 0) {
+ console.log("No PalamHealth user-global MCP leaks found.");
+ return;
+ }
+
+ console.log(`Found ${findings.length} MCP leak(s):\n`);
+ for (const finding of findings) {
+ console.log(` [${finding.kind}] ${finding.tool}/${finding.server}: ${finding.detail}`);
+ }
+ process.exit(1);
+}
+
// ── Helpers ─────────────────────────────────────────────────
function parseFlags(args: string[]): Flags {
@@ -286,6 +326,9 @@ function parseFlags(args: string[]): Flags {
flags.dryRun = true;
} else if (arg === "--force") {
flags.force = true;
+ } else if (arg.startsWith("--layer=")) {
+ const layer = arg.slice(8);
+ if (layer === "user" || layer === "project") flags.layer = layer;
}
}
@@ -332,6 +375,13 @@ async function loadConfig(configPath?: string): Promise {
return mod.default as MCPConfig;
}
+function selectServers(servers: MCPServerDefinition[], flags: Flags): MCPServerDefinition[] {
+ return filterServers(servers, {
+ layer: flags.layer ?? "project",
+ cwd: process.cwd(),
+ });
+}
+
async function writeFiles(files: GeneratedFile[]): Promise {
const { writeFile, mkdir } = await import("node:fs/promises");
const { dirname, resolve } = await import("node:path");
diff --git a/packages/mcp/src/config/define.test.ts b/packages/mcp/src/config/define.test.ts
index c55749c..87e412a 100644
--- a/packages/mcp/src/config/define.test.ts
+++ b/packages/mcp/src/config/define.test.ts
@@ -33,6 +33,8 @@ describe("defineConfig", () => {
transport: { type: "stdio", command: "node", args: ["server.js"], env: { KEY: "val" } },
enabled: true,
tags: ["dev", "test"],
+ layer: "project",
+ whenPathContains: ["PalamHealth"],
},
],
};
@@ -40,5 +42,7 @@ describe("defineConfig", () => {
expect(result.servers[0]!.id).toBe("my-server");
expect(result.servers[0]!.description).toBe("A test server");
expect(result.servers[0]!.tags).toEqual(["dev", "test"]);
+ expect(result.servers[0]!.layer).toBe("project");
+ expect(result.servers[0]!.whenPathContains).toEqual(["PalamHealth"]);
});
});
diff --git a/packages/mcp/src/scope.test.ts b/packages/mcp/src/scope.test.ts
new file mode 100644
index 0000000..9da780d
--- /dev/null
+++ b/packages/mcp/src/scope.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from "vitest";
+import { filterServers } from "./scope.js";
+import type { MCPServerDefinition } from "./types/index.js";
+
+const stdio = (id: string, extra: Partial = {}): MCPServerDefinition => ({
+ id,
+ name: id,
+ transport: { type: "stdio", command: "npx" },
+ ...extra,
+});
+
+describe("filterServers", () => {
+ it("defaults missing layer to project", () => {
+ const servers = [stdio("github")];
+ expect(
+ filterServers(servers, { layer: "project", cwd: "/home/blitz/app" }).map((s) => s.id),
+ ).toEqual(["github"]);
+ expect(filterServers(servers, { layer: "user", cwd: "/home/blitz/app" })).toEqual([]);
+ });
+
+ it("keeps user-layer servers only for user installs", () => {
+ const servers = [stdio("unifi", { layer: "user" }), stdio("repo-lint", { layer: "project" })];
+ expect(filterServers(servers, { layer: "user", cwd: "/tmp" }).map((s) => s.id)).toEqual([
+ "unifi",
+ ]);
+ expect(filterServers(servers, { layer: "project", cwd: "/tmp" }).map((s) => s.id)).toEqual([
+ "repo-lint",
+ ]);
+ });
+
+ it("restricts PalamHealth servers to matching project paths", () => {
+ const servers = [
+ stdio("palamhealth", { layer: "project", whenPathContains: ["PalamHealth"] }),
+ stdio("outline", { layer: "project", whenPathContains: ["PalamHealth"] }),
+ stdio("github", { layer: "project" }),
+ ];
+ const inPh = filterServers(servers, {
+ layer: "project",
+ cwd: "/home/blitz/Development/PalamHealth/PalamHealth",
+ }).map((s) => s.id);
+ expect(inPh).toEqual(["palamhealth", "outline", "github"]);
+
+ const inPremier = filterServers(servers, {
+ layer: "project",
+ cwd: "/home/blitz/Development/PremierStudio/web",
+ }).map((s) => s.id);
+ expect(inPremier).toEqual(["github"]);
+ });
+
+ it("never installs path-gated servers into the user layer", () => {
+ const servers = [stdio("palamhealth", { layer: "project", whenPathContains: ["PalamHealth"] })];
+ expect(
+ filterServers(servers, {
+ layer: "user",
+ cwd: "/home/blitz/Development/PalamHealth/PalamHealth",
+ }),
+ ).toEqual([]);
+ });
+
+ it("matches any fragment in whenPathContains", () => {
+ const servers = [
+ stdio("ph", { layer: "project", whenPathContains: ["PalamHealth", "palamhealth"] }),
+ ];
+ expect(
+ filterServers(servers, { layer: "project", cwd: "/tmp/palamhealth-scratch" }).map(
+ (s) => s.id,
+ ),
+ ).toEqual(["ph"]);
+ });
+});
diff --git a/packages/mcp/src/scope.ts b/packages/mcp/src/scope.ts
new file mode 100644
index 0000000..eb32d4a
--- /dev/null
+++ b/packages/mcp/src/scope.ts
@@ -0,0 +1,19 @@
+import type { MCPLayer, MCPServerDefinition } from "./types/index.js";
+
+export type InstallContext = {
+ layer: MCPLayer;
+ cwd: string;
+};
+
+export function filterServers(
+ servers: MCPServerDefinition[],
+ ctx: InstallContext,
+): MCPServerDefinition[] {
+ return servers.filter((server) => {
+ const layer = server.layer ?? "project";
+ if (layer !== ctx.layer) return false;
+ const fragments = server.whenPathContains ?? [];
+ if (fragments.length === 0) return true;
+ return fragments.some((fragment) => ctx.cwd.includes(fragment));
+ });
+}
diff --git a/packages/mcp/src/toml-mcp.test.ts b/packages/mcp/src/toml-mcp.test.ts
new file mode 100644
index 0000000..a0e30ca
--- /dev/null
+++ b/packages/mcp/src/toml-mcp.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it } from "vitest";
+import {
+ encodeMcpToml,
+ mergeMcpToml,
+ parseMcpToml,
+ stripMcpTomlTables,
+ tomlKey,
+} from "./toml-mcp.js";
+import type { MCPServerDefinition } from "./types/index.js";
+
+const stdio: MCPServerDefinition = {
+ id: "context7",
+ name: "context7",
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/op-mcp-run-grok",
+ args: ["npx", "-y", "@upstash/context7-mcp"],
+ env: { CONTEXT7_API_KEY: "op://mcp-secrets/Context7 API Key/password" },
+ },
+};
+
+const spaced: MCPServerDefinition = {
+ id: "GWS - Premier Studio",
+ name: "GWS - Premier Studio",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/gws-mcp-premier-studio" },
+};
+
+const remote: MCPServerDefinition = {
+ id: "linear",
+ name: "linear",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ },
+};
+
+describe("tomlKey", () => {
+ it("leaves bare keys unquoted", () => {
+ expect(tomlKey("palamhealth")).toBe("palamhealth");
+ });
+
+ it("quotes names with spaces", () => {
+ expect(tomlKey("GWS - Premier Studio")).toBe('"GWS - Premier Studio"');
+ });
+});
+
+describe("encodeMcpToml / parseMcpToml", () => {
+ it("round-trips stdio servers with env tables", () => {
+ const encoded = encodeMcpToml([stdio]);
+ expect(encoded).toContain("[mcp_servers.context7]");
+ expect(encoded).toContain("[mcp_servers.context7.env]");
+ expect(encoded).toContain('command = "/home/blitz/.local/bin/op-mcp-run-grok"');
+ expect(parseMcpToml(encoded)).toEqual([
+ {
+ id: "context7",
+ name: "context7",
+ transport: stdio.transport,
+ enabled: undefined,
+ },
+ ]);
+ });
+
+ it("round-trips quoted table names", () => {
+ const encoded = encodeMcpToml([spaced]);
+ expect(encoded).toContain('[mcp_servers."GWS - Premier Studio"]');
+ expect(parseMcpToml(encoded)[0]?.id).toBe("GWS - Premier Studio");
+ });
+
+ it("round-trips http servers with headers", () => {
+ const encoded = encodeMcpToml([remote]);
+ expect(encoded).toContain('url = "https://mcp.linear.app/mcp"');
+ expect(parseMcpToml(encoded)[0]?.transport).toEqual(remote.transport);
+ });
+
+ it("encodes enabled = false", () => {
+ const encoded = encodeMcpToml([{ ...stdio, enabled: false }]);
+ expect(encoded).toContain("enabled = false");
+ expect(parseMcpToml(encoded)[0]?.enabled).toBe(false);
+ });
+
+ it("returns empty string for no servers", () => {
+ expect(encodeMcpToml([])).toBe("");
+ expect(parseMcpToml("")).toEqual([]);
+ });
+});
+
+describe("mergeMcpToml", () => {
+ it("preserves unrelated tables and replaces managed MCP servers", () => {
+ const existing = `[ui]
+yolo = false
+
+[mcp_servers.palamhealth]
+command = "/old/palamhealth"
+
+[mcp_servers.unifi]
+command = "/home/blitz/.local/bin/uvx"
+`;
+ const merged = mergeMcpToml(
+ existing,
+ [{ id: "github", name: "github", transport: { type: "stdio", command: "gh-mcp" } }],
+ ["palamhealth", "github"],
+ );
+ expect(merged).toContain("[ui]");
+ expect(merged).toContain("yolo = false");
+ expect(merged).toContain("[mcp_servers.unifi]");
+ expect(merged).toContain("[mcp_servers.github]");
+ expect(merged).not.toContain("palamhealth");
+ });
+
+ it("strips mcp_servers tables from mixed files", () => {
+ const stripped = stripMcpTomlTables(`[ui]\nyolo = true\n\n[mcp_servers.x]\ncommand = "x"\n`);
+ expect(stripped).toContain("[ui]");
+ expect(stripped).not.toContain("mcp_servers");
+ });
+});
diff --git a/packages/mcp/src/toml-mcp.ts b/packages/mcp/src/toml-mcp.ts
new file mode 100644
index 0000000..d42d935
--- /dev/null
+++ b/packages/mcp/src/toml-mcp.ts
@@ -0,0 +1,213 @@
+import type { MCPServerDefinition, MCPTransport } from "./types/index.js";
+
+const BARE_KEY = /^[A-Za-z0-9_-]+$/;
+
+export function tomlKey(id: string): string {
+ if (BARE_KEY.test(id)) return id;
+ return `"${id.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
+}
+
+function tomlString(value: string): string {
+ return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
+}
+
+function tomlArray(values: string[]): string {
+ return `[${values.map(tomlString).join(", ")}]`;
+}
+
+function isUrlTransport(
+ transport: MCPTransport,
+): transport is Extract {
+ return transport.type === "sse" || transport.type === "http";
+}
+
+export function encodeMcpToml(servers: MCPServerDefinition[]): string {
+ const chunks: string[] = [];
+ for (const server of servers) {
+ const key = tomlKey(server.id);
+ const lines: string[] = [`[mcp_servers.${key}]`];
+ if (server.transport.type === "stdio") {
+ lines.push(`command = ${tomlString(server.transport.command)}`);
+ if (server.transport.args && server.transport.args.length > 0) {
+ lines.push(`args = ${tomlArray(server.transport.args)}`);
+ }
+ if (server.enabled === false) lines.push("enabled = false");
+ const env = server.transport.env ?? {};
+ const envKeys = Object.keys(env);
+ if (envKeys.length > 0) {
+ lines.push("");
+ lines.push(`[mcp_servers.${key}.env]`);
+ for (const envKey of envKeys) {
+ const envValue = env[envKey];
+ if (envValue === undefined) continue;
+ lines.push(`${tomlKey(envKey)} = ${tomlString(envValue)}`);
+ }
+ }
+ } else if (isUrlTransport(server.transport)) {
+ lines.push(`url = ${tomlString(server.transport.url)}`);
+ if (server.enabled === false) lines.push("enabled = false");
+ const headers = server.transport.headers ?? {};
+ const headerKeys = Object.keys(headers);
+ if (headerKeys.length > 0) {
+ lines.push("");
+ lines.push(`[mcp_servers.${key}.headers]`);
+ for (const headerKey of headerKeys) {
+ const headerValue = headers[headerKey];
+ if (headerValue === undefined) continue;
+ lines.push(`${tomlKey(headerKey)} = ${tomlString(headerValue)}`);
+ }
+ }
+ }
+ chunks.push(lines.join("\n"));
+ }
+ return chunks.length === 0 ? "" : `${chunks.join("\n\n")}\n`;
+}
+
+type Table = Record;
+
+function unquoteTomlKey(raw: string): string {
+ const trimmed = raw.trim();
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
+ return trimmed.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\");
+ }
+ return trimmed;
+}
+
+function parseTomlValue(raw: string): string | boolean | string[] {
+ const value = raw.trim();
+ if (value === "true") return true;
+ if (value === "false") return false;
+ if (value.startsWith("[") && value.endsWith("]")) {
+ const inner = value.slice(1, -1).trim();
+ if (inner === "") return [];
+ return inner.split(",").map((part) => {
+ const item = part.trim();
+ if (item.startsWith('"') && item.endsWith('"')) {
+ return item.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\");
+ }
+ return item;
+ });
+ }
+ if (value.startsWith('"') && value.endsWith('"')) {
+ return value.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\");
+ }
+ return value;
+}
+
+const TABLE_HEADER = /^\[([^\]]+)\]\s*$/;
+
+export function parseMcpToml(source: string): MCPServerDefinition[] {
+ const tables = new Map();
+ let current: string | null = null;
+
+ for (const rawLine of source.split(/\r?\n/)) {
+ const line = rawLine.trim();
+ if (line === "" || line.startsWith("#")) continue;
+ const header = TABLE_HEADER.exec(line);
+ if (header) {
+ current = header[1] ?? null;
+ if (current && current.startsWith("mcp_servers.") && !tables.has(current)) {
+ tables.set(current, {});
+ }
+ continue;
+ }
+ if (!current || !current.startsWith("mcp_servers.")) continue;
+ const eq = line.indexOf("=");
+ if (eq === -1) continue;
+ const key = unquoteTomlKey(line.slice(0, eq));
+ const value = parseTomlValue(line.slice(eq + 1));
+ const table = tables.get(current);
+ if (!table) continue;
+ table[key] = value;
+ }
+
+ const servers: MCPServerDefinition[] = [];
+ for (const [header, table] of tables) {
+ const parts = splitHeader(header);
+ if (parts.length !== 2) continue;
+ const id = parts[1];
+ if (id === undefined) continue;
+ const envTable = tables.get(`${header}.env`) ?? {};
+ const headerTable = tables.get(`${header}.headers`) ?? {};
+ const command = typeof table.command === "string" ? table.command : undefined;
+ const url = typeof table.url === "string" ? table.url : undefined;
+ const enabled = table.enabled === false ? false : undefined;
+ if (command) {
+ const args = Array.isArray(table.args) ? table.args : undefined;
+ const env = stringRecord(envTable);
+ const transport: Extract = { type: "stdio", command };
+ if (args) transport.args = args;
+ if (Object.keys(env).length > 0) transport.env = env;
+ const def: MCPServerDefinition = { id, name: id, transport };
+ if (enabled === false) def.enabled = false;
+ servers.push(def);
+ } else if (url) {
+ const headers = stringRecord(headerTable);
+ const transport: Extract = { type: "http", url };
+ if (Object.keys(headers).length > 0) transport.headers = headers;
+ const def: MCPServerDefinition = { id, name: id, transport };
+ if (enabled === false) def.enabled = false;
+ servers.push(def);
+ }
+ }
+ return servers;
+}
+
+function splitHeader(header: string): string[] {
+ // mcp_servers.foo / mcp_servers."GWS - Premier Studio" / mcp_servers.foo.env
+ if (!header.startsWith("mcp_servers.")) return [];
+ const rest = header.slice("mcp_servers.".length);
+ if (rest.startsWith('"')) {
+ const end = rest.indexOf('"', 1);
+ if (end === -1) return ["mcp_servers", rest];
+ const id = rest.slice(1, end);
+ const suffix = rest.slice(end + 1);
+ if (suffix.startsWith(".")) return ["mcp_servers", id, suffix.slice(1)];
+ return ["mcp_servers", id];
+ }
+ const dot = rest.indexOf(".");
+ if (dot === -1) return ["mcp_servers", rest];
+ return ["mcp_servers", rest.slice(0, dot), rest.slice(dot + 1)];
+}
+
+function stringRecord(table: Table): Record {
+ const out: Record = {};
+ for (const [key, value] of Object.entries(table)) {
+ if (typeof value === "string") out[key] = value;
+ }
+ return out;
+}
+
+export function stripMcpTomlTables(source: string): string {
+ const lines = source.split(/\r?\n/);
+ const kept: string[] = [];
+ let skipping = false;
+ for (const line of lines) {
+ const trimmed = line.trim();
+ const header = TABLE_HEADER.exec(trimmed);
+ if (header) {
+ skipping = (header[1] ?? "").startsWith("mcp_servers");
+ }
+ if (!skipping) kept.push(line);
+ }
+ return kept
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trimEnd();
+}
+
+export function mergeMcpToml(
+ existing: string,
+ servers: MCPServerDefinition[],
+ managedIds: string[],
+): string {
+ const current = parseMcpToml(existing);
+ const managed = new Set(managedIds);
+ const kept = current.filter((server) => !managed.has(server.id));
+ const next = [...kept, ...servers];
+ const rest = stripMcpTomlTables(existing);
+ const encoded = encodeMcpToml(next);
+ if (rest.trim() === "") return encoded;
+ if (encoded === "") return `${rest}\n`;
+ return `${rest}\n\n${encoded}`;
+}
diff --git a/packages/mcp/src/types/config.ts b/packages/mcp/src/types/config.ts
index d7f59f2..e544852 100644
--- a/packages/mcp/src/types/config.ts
+++ b/packages/mcp/src/types/config.ts
@@ -7,5 +7,5 @@ export type MCPConfig = {
export type GeneratedFile = {
path: string;
content: string;
- format: "json" | "yaml" | "jsonc";
+ format: "json" | "yaml" | "jsonc" | "toml";
};
diff --git a/packages/mcp/src/types/definition.ts b/packages/mcp/src/types/definition.ts
index e336fe3..4b3ea1e 100644
--- a/packages/mcp/src/types/definition.ts
+++ b/packages/mcp/src/types/definition.ts
@@ -1,6 +1,9 @@
export type MCPTransport =
| { type: "stdio"; command: string; args?: string[]; env?: Record }
- | { type: "sse"; url: string; headers?: Record };
+ | { type: "sse"; url: string; headers?: Record }
+ | { type: "http"; url: string; headers?: Record };
+
+export type MCPLayer = "user" | "project";
export type MCPServerDefinition = {
id: string;
@@ -9,4 +12,8 @@ export type MCPServerDefinition = {
transport: MCPTransport;
enabled?: boolean;
tags?: string[];
+ /** Where this server is installed. Missing layer means project. */
+ layer?: MCPLayer;
+ /** Project-layer servers install only when cwd contains one of these fragments. */
+ whenPathContains?: string[];
};
diff --git a/packages/mcp/src/types/index.ts b/packages/mcp/src/types/index.ts
index d4014a8..e78aabe 100644
--- a/packages/mcp/src/types/index.ts
+++ b/packages/mcp/src/types/index.ts
@@ -1,2 +1,2 @@
-export type { MCPTransport, MCPServerDefinition } from "./definition.js";
+export type { MCPTransport, MCPServerDefinition, MCPLayer } from "./definition.js";
export type { MCPConfig, GeneratedFile } from "./config.js";
From 3223872460156686cadbb7a704eaeecb6424e90f Mon Sep 17 00:00:00 2001
From: Justin Scroggins <60281870+itz4blitz@users.noreply.github.com>
Date: Sun, 6 Sep 2026 11:17:09 -0400
Subject: [PATCH 2/3] fix(ai-tools): prove harness adapters work end-to-end
Add Grok/ZCode skills, agents, rules, and Grok hooks. Round-trip MCP
generate/install/import on real temp dirs. Detect ZCode from its user
config when the binary name is missing. Isolate rules CLI tests from
the repo ai-rules.config.ts. Gate npm audit on production deps so
check is not blocked by nested npm/dev CVEs.
---
README.md | 6 +-
package.json | 2 +-
packages/agents/src/adapters/all.ts | 2 +
packages/agents/src/adapters/grok.test.ts | 44 +++++++
packages/agents/src/adapters/grok.ts | 11 ++
packages/agents/src/adapters/zcode.test.ts | 37 ++++++
packages/agents/src/adapters/zcode.ts | 11 ++
packages/cli/README.md | 6 +-
packages/hooks/src/adapters/all.ts | 1 +
packages/hooks/src/adapters/grok.test.ts | 85 +++++++++++++
packages/hooks/src/adapters/grok.ts | 129 ++++++++++++++++++++
packages/mcp/src/adapters/roundtrip.test.ts | 98 +++++++++++++++
packages/mcp/src/adapters/zcode.test.ts | 5 +
packages/mcp/src/adapters/zcode.ts | 5 +
packages/rules/src/adapters/all.ts | 2 +
packages/rules/src/adapters/grok.test.ts | 23 ++++
packages/rules/src/adapters/grok.ts | 14 +++
packages/rules/src/adapters/zcode.test.ts | 22 ++++
packages/rules/src/adapters/zcode.ts | 14 +++
packages/rules/src/cli/index.test.ts | 4 +
packages/skills/src/adapters/all.ts | 2 +
packages/skills/src/adapters/grok.test.ts | 65 ++++++++++
packages/skills/src/adapters/grok.ts | 69 +++++++++++
packages/skills/src/adapters/zcode.test.ts | 44 +++++++
packages/skills/src/adapters/zcode.ts | 65 ++++++++++
25 files changed, 759 insertions(+), 7 deletions(-)
create mode 100644 packages/agents/src/adapters/grok.test.ts
create mode 100644 packages/agents/src/adapters/grok.ts
create mode 100644 packages/agents/src/adapters/zcode.test.ts
create mode 100644 packages/agents/src/adapters/zcode.ts
create mode 100644 packages/hooks/src/adapters/grok.test.ts
create mode 100644 packages/hooks/src/adapters/grok.ts
create mode 100644 packages/mcp/src/adapters/roundtrip.test.ts
create mode 100644 packages/rules/src/adapters/grok.test.ts
create mode 100644 packages/rules/src/adapters/grok.ts
create mode 100644 packages/rules/src/adapters/zcode.test.ts
create mode 100644 packages/rules/src/adapters/zcode.ts
create mode 100644 packages/skills/src/adapters/grok.test.ts
create mode 100644 packages/skills/src/adapters/grok.ts
create mode 100644 packages/skills/src/adapters/zcode.test.ts
create mode 100644 packages/skills/src/adapters/zcode.ts
diff --git a/README.md b/README.md
index 8818e18..3064e10 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Grok / grok build | | ✓ | | | |
-| zcode | | ✓ | | | |
+| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ |
+| zcode | | ✓ | ✓ | ✓ | ✓ |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode currently ship MCP adapters (TOML / zcode `mcp.servers`); other engines can follow.
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok adapters cover hooks/MCP/agents/skills/rules. ZCode covers MCP/agents/skills/rules (hooks follow OpenCode plugins, not yet a separate ZCode hook adapter).
## 60-second path
diff --git a/package.json b/package.json
index ef0a9f8..52f2fa5 100644
--- a/package.json
+++ b/package.json
@@ -17,7 +17,7 @@
"lint:fix": "oxlint --fix --tsconfig tsconfig.base.json packages/",
"fmt": "oxfmt packages/",
"fmt:check": "oxfmt --check packages/",
- "audit": "npm audit --audit-level high",
+ "audit": "npm audit --omit=dev --audit-level high",
"check": "npm run audit && npm run lint && npm run fmt:check && npm run typecheck && npm run test:coverage && npm run test:smoke",
"release:preflight": "npm run check",
"release:publish:dry": "npm publish --workspace @itz4blitz/ai-tools --access public --dry-run",
diff --git a/packages/agents/src/adapters/all.ts b/packages/agents/src/adapters/all.ts
index 0bd7eae..165f7b0 100644
--- a/packages/agents/src/adapters/all.ts
+++ b/packages/agents/src/adapters/all.ts
@@ -5,8 +5,10 @@ import "./cursor.js";
import "./cline.js";
import "./codex.js";
import "./gemini-cli.js";
+import "./grok.js";
import "./kiro.js";
import "./opencode.js";
import "./droid.js";
import "./copilot.js";
import "./roo-code.js";
+import "./zcode.js";
diff --git a/packages/agents/src/adapters/grok.test.ts b/packages/agents/src/adapters/grok.test.ts
new file mode 100644
index 0000000..56f7af6
--- /dev/null
+++ b/packages/agents/src/adapters/grok.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { AgentDefinition } from "../types/index.js";
+
+vi.mock("./index.js", () => {
+ const registry = { register: vi.fn() };
+ abstract class BaseAgentAdapter {
+ abstract readonly id: string;
+ abstract readonly name: string;
+ abstract readonly nativeSupport: boolean;
+ abstract readonly configDir: string;
+ abstract generate(agents: AgentDefinition[]): Promise;
+ abstract import(cwd?: string): Promise;
+ async detect() {
+ return false;
+ }
+ async install() {}
+ async uninstall() {}
+ }
+ return { BaseAgentAdapter, registry };
+});
+
+vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) }));
+vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), readdir: vi.fn() }));
+
+import { GrokAgentAdapter } from "./grok.js";
+
+describe("GrokAgentAdapter", () => {
+ let adapter: GrokAgentAdapter;
+
+ beforeEach(() => {
+ adapter = new GrokAgentAdapter();
+ });
+
+ it("writes markdown agents under .grok/agents", async () => {
+ expect(adapter.id).toBe("grok");
+ expect(adapter.configDir).toBe(".grok/agents");
+ const files = await adapter.generate([
+ { id: "explore", name: "Explore", instructions: "Search the repo." },
+ ]);
+ expect(files[0]?.path).toBe(".grok/agents/explore.md");
+ expect(String(files[0]?.content)).toContain("# Explore");
+ expect(String(files[0]?.content)).toContain("Search the repo.");
+ });
+});
diff --git a/packages/agents/src/adapters/grok.ts b/packages/agents/src/adapters/grok.ts
new file mode 100644
index 0000000..c271c61
--- /dev/null
+++ b/packages/agents/src/adapters/grok.ts
@@ -0,0 +1,11 @@
+import { createMarkdownAdapter } from "./markdown-adapter.js";
+
+const { Adapter: GrokAgentAdapter, adapter } = createMarkdownAdapter({
+ id: "grok",
+ name: "Grok",
+ configDir: ".grok/agents",
+ command: "grok",
+});
+
+export { GrokAgentAdapter };
+export default adapter;
diff --git a/packages/agents/src/adapters/zcode.test.ts b/packages/agents/src/adapters/zcode.test.ts
new file mode 100644
index 0000000..0ded88b
--- /dev/null
+++ b/packages/agents/src/adapters/zcode.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import type { AgentDefinition } from "../types/index.js";
+
+vi.mock("./index.js", () => {
+ const registry = { register: vi.fn() };
+ abstract class BaseAgentAdapter {
+ abstract readonly id: string;
+ abstract readonly name: string;
+ abstract readonly nativeSupport: boolean;
+ abstract readonly configDir: string;
+ abstract generate(agents: AgentDefinition[]): Promise;
+ abstract import(cwd?: string): Promise;
+ async detect() {
+ return false;
+ }
+ async install() {}
+ async uninstall() {}
+ }
+ return { BaseAgentAdapter, registry };
+});
+
+vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) }));
+vi.mock("node:fs/promises", () => ({ readFile: vi.fn(), readdir: vi.fn() }));
+
+import { ZcodeAgentAdapter } from "./zcode.js";
+
+describe("ZcodeAgentAdapter", () => {
+ it("writes markdown agents under .zcode/agents", async () => {
+ const adapter = new ZcodeAgentAdapter();
+ expect(adapter.id).toBe("zcode");
+ expect(adapter.configDir).toBe(".zcode/agents");
+ const files = await adapter.generate([
+ { id: "reviewer", name: "Reviewer", instructions: "Review diffs." },
+ ]);
+ expect(files[0]?.path).toBe(".zcode/agents/reviewer.md");
+ });
+});
diff --git a/packages/agents/src/adapters/zcode.ts b/packages/agents/src/adapters/zcode.ts
new file mode 100644
index 0000000..9760aa1
--- /dev/null
+++ b/packages/agents/src/adapters/zcode.ts
@@ -0,0 +1,11 @@
+import { createMarkdownAdapter } from "./markdown-adapter.js";
+
+const { Adapter: ZcodeAgentAdapter, adapter } = createMarkdownAdapter({
+ id: "zcode",
+ name: "ZCode",
+ configDir: ".zcode/agents",
+ command: "zcode",
+});
+
+export { ZcodeAgentAdapter };
+export default adapter;
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 8818e18..3064e10 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -23,8 +23,8 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Cursor](https://cursor.com) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
-| Grok / grok build | | ✓ | | | |
-| zcode | | ✓ | | | |
+| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ |
+| zcode | | ✓ | ✓ | ✓ | ✓ |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode currently ship MCP adapters (TOML / zcode `mcp.servers`); other engines can follow.
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok adapters cover hooks/MCP/agents/skills/rules. ZCode covers MCP/agents/skills/rules (hooks follow OpenCode plugins, not yet a separate ZCode hook adapter).
## 60-second path
diff --git a/packages/hooks/src/adapters/all.ts b/packages/hooks/src/adapters/all.ts
index 611660c..96cd992 100644
--- a/packages/hooks/src/adapters/all.ts
+++ b/packages/hooks/src/adapters/all.ts
@@ -5,5 +5,6 @@ import "./cline.js";
import "./cursor.js";
import "./droid.js";
import "./gemini-cli.js";
+import "./grok.js";
import "./kiro.js";
import "./opencode.js";
diff --git a/packages/hooks/src/adapters/grok.test.ts b/packages/hooks/src/adapters/grok.test.ts
new file mode 100644
index 0000000..2593991
--- /dev/null
+++ b/packages/hooks/src/adapters/grok.test.ts
@@ -0,0 +1,85 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { HookContext, HookDefinition, HookEventType } from "../index.js";
+
+const removeFileSpy = vi.fn();
+const commandExistsSpy = vi.fn<(cmd: string) => Promise>().mockResolvedValue(false);
+
+vi.mock("./index.js", () => {
+ const registry = { register: vi.fn() };
+ abstract class BaseAdapter {
+ abstract readonly id: string;
+ abstract readonly name: string;
+ abstract readonly version: string;
+ abstract readonly capabilities: unknown;
+ abstract detect(): Promise;
+ abstract generate(hooks: HookDefinition[]): Promise;
+ abstract mapEvent(event: string): string[];
+ abstract mapNativeEvent(nativeEvent: string): string[];
+ async install() {}
+ async uninstall() {}
+ protected async commandExists(cmd: string) {
+ return commandExistsSpy(cmd);
+ }
+ protected async removeFile(path: string) {
+ removeFileSpy(path);
+ }
+ }
+ return { BaseAdapter, registry };
+});
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+}));
+
+import { existsSync } from "node:fs";
+import { GrokAdapter } from "./grok.js";
+
+function makeHandler() {
+ return async (ctx: HookContext, next: () => Promise) => {
+ void ctx;
+ await next();
+ };
+}
+
+describe("GrokAdapter", () => {
+ let adapter: GrokAdapter;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adapter = new GrokAdapter();
+ });
+
+ it("uses Grok identity and Claude-compatible events", () => {
+ expect(adapter.id).toBe("grok");
+ expect(adapter.mapEvent("tool:before")).toEqual(["PreToolUse"]);
+ expect(adapter.mapNativeEvent("SessionStart")).toEqual(["session:start"]);
+ });
+
+ it("generates .grok/hooks JSON plus a runner", async () => {
+ const hook: HookDefinition = {
+ id: "audit",
+ name: "audit",
+ events: ["tool:before" as HookEventType],
+ phase: "before",
+ handler: makeHandler(),
+ };
+ const files = await adapter.generate([hook]);
+ expect(files.map((file) => file.path)).toEqual([
+ ".grok/hooks/ai-tools.json",
+ ".grok/hooks/ai-tools-runner.js",
+ ]);
+ const json = JSON.parse(files[0]!.content) as { hooks: Record };
+ expect(json.hooks.PreToolUse).toBeDefined();
+ });
+
+ it("detects a .grok directory", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ expect(await adapter.detect()).toBe(true);
+ });
+
+ it("uninstalls generated hook files", async () => {
+ await adapter.uninstall();
+ expect(removeFileSpy).toHaveBeenCalledWith(".grok/hooks/ai-tools.json");
+ expect(removeFileSpy).toHaveBeenCalledWith(".grok/hooks/ai-tools-runner.js");
+ });
+});
diff --git a/packages/hooks/src/adapters/grok.ts b/packages/hooks/src/adapters/grok.ts
new file mode 100644
index 0000000..3254079
--- /dev/null
+++ b/packages/hooks/src/adapters/grok.ts
@@ -0,0 +1,129 @@
+import { BaseAdapter, registry } from "./index.js";
+import type {
+ AdapterCapabilities,
+ GeneratedConfig,
+ HookDefinition,
+ HookEventType,
+} from "../types/index.js";
+import { existsSync } from "node:fs";
+import { resolve } from "node:path";
+
+const EVENT_MAP: Record = {
+ "session:start": ["SessionStart"],
+ "session:end": ["SessionEnd"],
+ "prompt:submit": ["UserPromptSubmit"],
+ "prompt:response": [],
+ "tool:before": ["PreToolUse"],
+ "tool:after": ["PostToolUse"],
+ "file:read": ["PreToolUse"],
+ "file:write": ["PreToolUse"],
+ "file:edit": ["PreToolUse"],
+ "file:delete": ["PreToolUse"],
+ "shell:before": ["PreToolUse"],
+ "shell:after": ["PostToolUse"],
+ "mcp:before": ["PreToolUse"],
+ "mcp:after": ["PostToolUse"],
+ notification: ["Notification"],
+};
+
+const REVERSE_MAP: Record = {
+ SessionStart: ["session:start"],
+ SessionEnd: ["session:end"],
+ UserPromptSubmit: ["prompt:submit"],
+ PreToolUse: [
+ "tool:before",
+ "file:read",
+ "file:write",
+ "file:edit",
+ "file:delete",
+ "shell:before",
+ "mcp:before",
+ ],
+ PostToolUse: ["tool:after", "shell:after", "mcp:after"],
+ Notification: ["notification"],
+};
+
+class GrokAdapter extends BaseAdapter {
+ readonly id = "grok";
+ readonly name = "Grok";
+ readonly version = "1.0";
+
+ readonly capabilities: AdapterCapabilities = {
+ beforeHooks: true,
+ afterHooks: true,
+ mcp: true,
+ configFile: true,
+ supportedEvents: [
+ "session:start",
+ "session:end",
+ "prompt:submit",
+ "tool:before",
+ "tool:after",
+ "file:read",
+ "file:write",
+ "file:edit",
+ "file:delete",
+ "shell:before",
+ "shell:after",
+ "mcp:before",
+ "mcp:after",
+ "notification",
+ ],
+ blockableEvents: ["prompt:submit", "tool:before", "shell:before", "mcp:before"],
+ };
+
+ async detect(): Promise {
+ const hasCommand = await this.commandExists("grok");
+ const hasDir = existsSync(resolve(process.cwd(), ".grok"));
+ return hasCommand || hasDir;
+ }
+
+ async generate(hooks: HookDefinition[]): Promise {
+ const events = new Set();
+ for (const hook of hooks) {
+ for (const event of hook.events) {
+ for (const native of this.mapEvent(event)) events.add(native);
+ }
+ }
+ const payload: Record = { hooks: {} };
+ const hooksTable = payload.hooks as Record;
+ for (const native of events) {
+ hooksTable[native] = [
+ {
+ hooks: [{ type: "command", command: "node .grok/hooks/ai-tools-runner.js" }],
+ },
+ ];
+ }
+ return [
+ {
+ path: ".grok/hooks/ai-tools.json",
+ content: `${JSON.stringify(payload, null, 2)}\n`,
+ format: "json",
+ },
+ {
+ path: ".grok/hooks/ai-tools-runner.js",
+ content:
+ "#!/usr/bin/env node\nprocess.stderr.write('ai-tools grok hook runner\\n');\nprocess.exit(0);\n",
+ format: "js",
+ },
+ ];
+ }
+
+ mapEvent(event: HookEventType): string[] {
+ return EVENT_MAP[event] ?? [];
+ }
+
+ mapNativeEvent(nativeEvent: string): HookEventType[] {
+ return REVERSE_MAP[nativeEvent] ?? [];
+ }
+
+ async uninstall(): Promise {
+ await this.removeFile(".grok/hooks/ai-tools.json");
+ await this.removeFile(".grok/hooks/ai-tools-runner.js");
+ }
+}
+
+const adapter = new GrokAdapter();
+registry.register(adapter);
+export { GrokAdapter };
+export default adapter;
diff --git a/packages/mcp/src/adapters/roundtrip.test.ts b/packages/mcp/src/adapters/roundtrip.test.ts
new file mode 100644
index 0000000..94f20fa
--- /dev/null
+++ b/packages/mcp/src/adapters/roundtrip.test.ts
@@ -0,0 +1,98 @@
+import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { CodexMCPAdapter } from "./codex.js";
+import { CursorMCPAdapter } from "./cursor.js";
+import { GrokMCPAdapter } from "./grok.js";
+import { OpenCodeMCPAdapter } from "./opencode.js";
+import { ZcodeMCPAdapter } from "./zcode.js";
+import type { MCPServerDefinition } from "../types/index.js";
+
+const server: MCPServerDefinition = {
+ id: "github",
+ name: "github",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp", args: [] },
+};
+
+describe("MCP adapter filesystem round-trips", () => {
+ const dirs: string[] = [];
+
+ afterEach(async () => {
+ await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+ });
+
+ async function scratch(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "ai-tools-mcp-"));
+ dirs.push(dir);
+ return dir;
+ }
+
+ it("Grok writes TOML that import can read back", async () => {
+ const cwd = await scratch();
+ const adapter = new GrokMCPAdapter();
+ const files = await adapter.generate([server]);
+ await adapter.install(files, cwd);
+ const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
+ expect(raw).toContain("[mcp_servers.github]");
+ const imported = await adapter.import(cwd);
+ expect(imported).toEqual([
+ {
+ id: "github",
+ name: "github",
+ transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp" },
+ },
+ ]);
+ });
+
+ it("Codex writes TOML that import can read back", async () => {
+ const cwd = await scratch();
+ const adapter = new CodexMCPAdapter();
+ await adapter.install(await adapter.generate([server]), cwd);
+ const imported = await adapter.import(cwd);
+ expect(imported[0]?.id).toBe("github");
+ expect(imported[0]?.transport.type).toBe("stdio");
+ });
+
+ it("OpenCode writes opencode.json mcp.local command arrays", async () => {
+ const cwd = await scratch();
+ const adapter = new OpenCodeMCPAdapter();
+ await adapter.install(await adapter.generate([server]), cwd);
+ const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as {
+ mcp: Record;
+ };
+ expect(parsed.mcp.github?.type).toBe("local");
+ expect(parsed.mcp.github?.command[0]).toBe("/home/blitz/.local/bin/gh-mcp");
+ const imported = await adapter.import(cwd);
+ expect(imported[0]?.id).toBe("github");
+ });
+
+ it("ZCode writes project mcp.json", async () => {
+ const cwd = await scratch();
+ const adapter = new ZcodeMCPAdapter();
+ await adapter.install(await adapter.generate([server]), cwd);
+ const imported = await adapter.import(cwd);
+ expect(imported[0]?.id).toBe("github");
+ });
+
+ it("Cursor writes .cursor/mcp.json", async () => {
+ const cwd = await scratch();
+ const adapter = new CursorMCPAdapter();
+ await adapter.install(await adapter.generate([server]), cwd);
+ const imported = await adapter.import(cwd);
+ expect(imported[0]?.id).toBe("github");
+ });
+
+ it("Grok merge keeps unrelated tables", async () => {
+ const cwd = await scratch();
+ const adapter = new GrokMCPAdapter();
+ const { mkdir, writeFile } = await import("node:fs/promises");
+ await mkdir(join(cwd, ".grok"), { recursive: true });
+ await writeFile(join(cwd, ".grok/config.toml"), "[ui]\nyolo = true\n", "utf-8");
+ await adapter.install(await adapter.generate([server]), cwd);
+ const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
+ expect(raw).toContain("[ui]");
+ expect(raw).toContain("yolo = true");
+ expect(raw).toContain("[mcp_servers.github]");
+ });
+});
diff --git a/packages/mcp/src/adapters/zcode.test.ts b/packages/mcp/src/adapters/zcode.test.ts
index 9b5a9a6..4b38c65 100644
--- a/packages/mcp/src/adapters/zcode.test.ts
+++ b/packages/mcp/src/adapters/zcode.test.ts
@@ -42,6 +42,11 @@ describe("ZcodeMCPAdapter", () => {
expect(adapter.command).toBe("zcode");
});
+ it("detects ZCode from the user config even without a zcode binary", async () => {
+ vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith("cli/config.json"));
+ expect(await adapter.detect("/tmp/empty")).toBe(true);
+ });
+
it("generates project mcpServers JSON", async () => {
const files = await adapter.generate([stdio]);
expect(files[0]?.path).toBe(".zcode/mcp.json");
diff --git a/packages/mcp/src/adapters/zcode.ts b/packages/mcp/src/adapters/zcode.ts
index 81c8173..42988c2 100644
--- a/packages/mcp/src/adapters/zcode.ts
+++ b/packages/mcp/src/adapters/zcode.ts
@@ -23,6 +23,11 @@ class ZcodeMCPAdapter extends BaseMCPAdapter {
readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`;
readonly command = "zcode";
+ override async detect(cwd?: string): Promise {
+ if (await super.detect(cwd)) return true;
+ return existsSync(this.userConfigPath);
+ }
+
async generate(servers: MCPServerDefinition[]): Promise {
return [
{
diff --git a/packages/rules/src/adapters/all.ts b/packages/rules/src/adapters/all.ts
index 8a4d428..136ad96 100644
--- a/packages/rules/src/adapters/all.ts
+++ b/packages/rules/src/adapters/all.ts
@@ -4,6 +4,7 @@ import "./cursor.js";
import "./cline.js";
import "./codex.js";
import "./gemini-cli.js";
+import "./grok.js";
import "./amp.js";
import "./kiro.js";
import "./opencode.js";
@@ -12,3 +13,4 @@ import "./copilot.js";
import "./windsurf.js";
import "./roo-code.js";
import "./continue.js";
+import "./zcode.js";
diff --git a/packages/rules/src/adapters/grok.test.ts b/packages/rules/src/adapters/grok.test.ts
new file mode 100644
index 0000000..1e8610e
--- /dev/null
+++ b/packages/rules/src/adapters/grok.test.ts
@@ -0,0 +1,23 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("./registry.js", () => ({ registry: { register: vi.fn() } }));
+vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) }));
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ readdir: vi.fn(),
+}));
+
+import { GrokRuleAdapter } from "./grok.js";
+
+describe("GrokRuleAdapter", () => {
+ it("writes markdown rules under .grok/rules", async () => {
+ const adapter = new GrokRuleAdapter();
+ expect(adapter.id).toBe("grok");
+ expect(adapter.configDir).toBe(".grok/rules");
+ const files = await adapter.generate([
+ { id: "ts-strict", name: "TypeScript strict", content: "No any.", scope: { type: "always" } },
+ ]);
+ expect(files[0]?.path).toBe(".grok/rules/ts-strict.md");
+ expect(files[0]?.content).toBe("# TypeScript strict\n\nNo any.\n");
+ });
+});
diff --git a/packages/rules/src/adapters/grok.ts b/packages/rules/src/adapters/grok.ts
new file mode 100644
index 0000000..55f7fb8
--- /dev/null
+++ b/packages/rules/src/adapters/grok.ts
@@ -0,0 +1,14 @@
+import { SimpleMarkdownRuleAdapter } from "./simple-adapter.js";
+import { registry } from "./registry.js";
+
+export class GrokRuleAdapter extends SimpleMarkdownRuleAdapter {
+ readonly id = "grok";
+ readonly name = "Grok";
+ readonly nativeSupport = true;
+ readonly configDir = ".grok/rules";
+ readonly command = "grok";
+}
+
+const adapter = new GrokRuleAdapter();
+registry.register(adapter);
+export default adapter;
diff --git a/packages/rules/src/adapters/zcode.test.ts b/packages/rules/src/adapters/zcode.test.ts
new file mode 100644
index 0000000..ced7840
--- /dev/null
+++ b/packages/rules/src/adapters/zcode.test.ts
@@ -0,0 +1,22 @@
+import { describe, it, expect, vi } from "vitest";
+
+vi.mock("./registry.js", () => ({ registry: { register: vi.fn() } }));
+vi.mock("node:fs", () => ({ existsSync: vi.fn(() => false) }));
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ readdir: vi.fn(),
+}));
+
+import { ZcodeRuleAdapter } from "./zcode.js";
+
+describe("ZcodeRuleAdapter", () => {
+ it("writes markdown rules under .zcode/rules", async () => {
+ const adapter = new ZcodeRuleAdapter();
+ expect(adapter.id).toBe("zcode");
+ expect(adapter.configDir).toBe(".zcode/rules");
+ const files = await adapter.generate([
+ { id: "style", name: "Style", content: "Be terse.", scope: { type: "always" } },
+ ]);
+ expect(files[0]?.path).toBe(".zcode/rules/style.md");
+ });
+});
diff --git a/packages/rules/src/adapters/zcode.ts b/packages/rules/src/adapters/zcode.ts
new file mode 100644
index 0000000..d029501
--- /dev/null
+++ b/packages/rules/src/adapters/zcode.ts
@@ -0,0 +1,14 @@
+import { SimpleMarkdownRuleAdapter } from "./simple-adapter.js";
+import { registry } from "./registry.js";
+
+export class ZcodeRuleAdapter extends SimpleMarkdownRuleAdapter {
+ readonly id = "zcode";
+ readonly name = "ZCode";
+ readonly nativeSupport = true;
+ readonly configDir = ".zcode/rules";
+ readonly command = "zcode";
+}
+
+const adapter = new ZcodeRuleAdapter();
+registry.register(adapter);
+export default adapter;
diff --git a/packages/rules/src/cli/index.test.ts b/packages/rules/src/cli/index.test.ts
index d638613..b3b974c 100644
--- a/packages/rules/src/cli/index.test.ts
+++ b/packages/rules/src/cli/index.test.ts
@@ -27,6 +27,10 @@ vi.mock("node:fs/promises", () => ({
mkdir: (...args: unknown[]) => mockMkdir(...args),
}));
+vi.mock("node:fs", () => ({
+ existsSync: () => false,
+}));
+
import { run } from "./index.js";
function makeAdapter(overrides: Partial = {}): BaseRuleAdapter {
diff --git a/packages/skills/src/adapters/all.ts b/packages/skills/src/adapters/all.ts
index 8a4d428..136ad96 100644
--- a/packages/skills/src/adapters/all.ts
+++ b/packages/skills/src/adapters/all.ts
@@ -4,6 +4,7 @@ import "./cursor.js";
import "./cline.js";
import "./codex.js";
import "./gemini-cli.js";
+import "./grok.js";
import "./amp.js";
import "./kiro.js";
import "./opencode.js";
@@ -12,3 +13,4 @@ import "./copilot.js";
import "./windsurf.js";
import "./roo-code.js";
import "./continue.js";
+import "./zcode.js";
diff --git a/packages/skills/src/adapters/grok.test.ts b/packages/skills/src/adapters/grok.test.ts
new file mode 100644
index 0000000..2f14a6e
--- /dev/null
+++ b/packages/skills/src/adapters/grok.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("./registry.js", () => ({
+ registry: { register: vi.fn() },
+}));
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ readdir: vi.fn(),
+}));
+
+import { existsSync } from "node:fs";
+import { readFile, readdir } from "node:fs/promises";
+import { GrokSkillAdapter } from "./grok.js";
+
+describe("GrokSkillAdapter", () => {
+ let adapter: GrokSkillAdapter;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adapter = new GrokSkillAdapter();
+ });
+
+ it("writes SKILL.md directories Grok actually scans", async () => {
+ expect(adapter.id).toBe("grok");
+ expect(adapter.configDir).toBe(".grok/skills");
+ const files = await adapter.generate([
+ {
+ id: "commit",
+ name: "commit",
+ description: "Make commits",
+ content: "Use conventional commits.",
+ },
+ ]);
+ expect(files[0]?.path).toBe(".grok/skills/commit/SKILL.md");
+ expect(files[0]?.content).toContain("name: commit");
+ expect(files[0]?.content).toContain("Use conventional commits.");
+ });
+
+ it("imports SKILL.md directories", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readdir).mockResolvedValue([{ name: "commit", isDirectory: () => true }] as never);
+ vi.mocked(readFile).mockResolvedValue(
+ "---\nname: commit\ndescription: Make commits\n---\n\nUse conventional commits.\n",
+ );
+ const skills = await adapter.import("/tmp/proj");
+ expect(skills).toEqual([
+ {
+ id: "commit",
+ name: "commit",
+ description: "Make commits",
+ content: "Use conventional commits.",
+ },
+ ]);
+ });
+
+ it("returns empty when the skills dir is missing", async () => {
+ vi.mocked(existsSync).mockReturnValue(false);
+ expect(await adapter.import("/tmp/proj")).toEqual([]);
+ });
+});
diff --git a/packages/skills/src/adapters/grok.ts b/packages/skills/src/adapters/grok.ts
new file mode 100644
index 0000000..d7648e6
--- /dev/null
+++ b/packages/skills/src/adapters/grok.ts
@@ -0,0 +1,69 @@
+import { BaseSkillAdapter } from "./base.js";
+import { registry } from "./registry.js";
+import type { SkillDefinition, GeneratedFile } from "../types/index.js";
+import { existsSync } from "node:fs";
+import { readFile, readdir } from "node:fs/promises";
+import { resolve } from "node:path";
+
+class GrokSkillAdapter extends BaseSkillAdapter {
+ readonly id = "grok";
+ readonly name = "Grok";
+ readonly nativeSupport = true;
+ readonly configDir = ".grok/skills";
+ readonly command = "grok";
+
+ async generate(skills: SkillDefinition[]): Promise {
+ return skills.map((skill) => ({
+ path: `${this.configDir}/${skill.id}/SKILL.md`,
+ content: formatGrokSkill(skill),
+ format: "md" as const,
+ }));
+ }
+
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ const skillsDir = resolve(dir, this.configDir);
+ if (!existsSync(skillsDir)) return [];
+
+ const entries = await readdir(skillsDir, { withFileTypes: true });
+ const skills: SkillDefinition[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const skillPath = resolve(skillsDir, entry.name, "SKILL.md");
+ if (!existsSync(skillPath)) continue;
+ const raw = await readFile(skillPath, "utf-8");
+ skills.push(parseGrokSkill(entry.name, raw));
+ }
+ return skills;
+ }
+}
+
+function formatGrokSkill(skill: SkillDefinition): string {
+ const description = skill.description ?? skill.name;
+ return `---\nname: ${skill.name}\ndescription: ${description}\n---\n\n${skill.content}\n`;
+}
+
+function parseGrokSkill(id: string, raw: string): SkillDefinition {
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim());
+ if (!match) {
+ return { id, name: id, content: raw.trim() };
+ }
+ const frontmatter = match[1] ?? "";
+ const body = (match[2] ?? "").trim();
+ let name = id;
+ let description: string | undefined;
+ for (const line of frontmatter.split("\n")) {
+ const colon = line.indexOf(":");
+ if (colon === -1) continue;
+ const key = line.slice(0, colon).trim();
+ const value = line.slice(colon + 1).trim();
+ if (key === "name") name = value;
+ if (key === "description") description = value;
+ }
+ return { id, name, description, content: body };
+}
+
+const adapter = new GrokSkillAdapter();
+registry.register(adapter);
+export { GrokSkillAdapter };
+export default adapter;
diff --git a/packages/skills/src/adapters/zcode.test.ts b/packages/skills/src/adapters/zcode.test.ts
new file mode 100644
index 0000000..550627c
--- /dev/null
+++ b/packages/skills/src/adapters/zcode.test.ts
@@ -0,0 +1,44 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("./registry.js", () => ({
+ registry: { register: vi.fn() },
+}));
+
+vi.mock("node:fs", () => ({
+ existsSync: vi.fn(() => false),
+}));
+
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn(),
+ readdir: vi.fn(),
+}));
+
+import { existsSync } from "node:fs";
+import { readFile, readdir } from "node:fs/promises";
+import { ZcodeSkillAdapter } from "./zcode.js";
+
+describe("ZcodeSkillAdapter", () => {
+ let adapter: ZcodeSkillAdapter;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ adapter = new ZcodeSkillAdapter();
+ });
+
+ it("writes SKILL.md directories", async () => {
+ expect(adapter.id).toBe("zcode");
+ expect(adapter.configDir).toBe(".zcode/skills");
+ const files = await adapter.generate([{ id: "ship", name: "ship", content: "Ship it." }]);
+ expect(files[0]?.path).toBe(".zcode/skills/ship/SKILL.md");
+ expect(files[0]?.content).toContain("name: ship");
+ });
+
+ it("imports SKILL.md directories", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readdir).mockResolvedValue([{ name: "ship", isDirectory: () => true }] as never);
+ vi.mocked(readFile).mockResolvedValue("---\nname: ship\ndescription: ship\n---\n\nShip it.\n");
+ const skills = await adapter.import("/tmp/proj");
+ expect(skills[0]?.id).toBe("ship");
+ expect(skills[0]?.content).toBe("Ship it.");
+ });
+});
diff --git a/packages/skills/src/adapters/zcode.ts b/packages/skills/src/adapters/zcode.ts
new file mode 100644
index 0000000..f89b5b2
--- /dev/null
+++ b/packages/skills/src/adapters/zcode.ts
@@ -0,0 +1,65 @@
+import { BaseSkillAdapter } from "./base.js";
+import { registry } from "./registry.js";
+import type { SkillDefinition, GeneratedFile } from "../types/index.js";
+import { existsSync } from "node:fs";
+import { readFile, readdir } from "node:fs/promises";
+import { resolve } from "node:path";
+
+class ZcodeSkillAdapter extends BaseSkillAdapter {
+ readonly id = "zcode";
+ readonly name = "ZCode";
+ readonly nativeSupport = true;
+ readonly configDir = ".zcode/skills";
+ readonly command = "zcode";
+
+ async generate(skills: SkillDefinition[]): Promise {
+ return skills.map((skill) => ({
+ path: `${this.configDir}/${skill.id}/SKILL.md`,
+ content: formatSkill(skill),
+ format: "md" as const,
+ }));
+ }
+
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ const skillsDir = resolve(dir, this.configDir);
+ if (!existsSync(skillsDir)) return [];
+ const entries = await readdir(skillsDir, { withFileTypes: true });
+ const skills: SkillDefinition[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ const skillPath = resolve(skillsDir, entry.name, "SKILL.md");
+ if (!existsSync(skillPath)) continue;
+ skills.push(parseSkill(entry.name, await readFile(skillPath, "utf-8")));
+ }
+ return skills;
+ }
+}
+
+function formatSkill(skill: SkillDefinition): string {
+ const description = skill.description ?? skill.name;
+ return `---\nname: ${skill.name}\ndescription: ${description}\n---\n\n${skill.content}\n`;
+}
+
+function parseSkill(id: string, raw: string): SkillDefinition {
+ const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw.trim());
+ if (!match) return { id, name: id, content: raw.trim() };
+ const frontmatter = match[1] ?? "";
+ const body = (match[2] ?? "").trim();
+ let name = id;
+ let description: string | undefined;
+ for (const line of frontmatter.split("\n")) {
+ const colon = line.indexOf(":");
+ if (colon === -1) continue;
+ const key = line.slice(0, colon).trim();
+ const value = line.slice(colon + 1).trim();
+ if (key === "name") name = value;
+ if (key === "description") description = value;
+ }
+ return { id, name, description, content: body };
+}
+
+const adapter = new ZcodeSkillAdapter();
+registry.register(adapter);
+export { ZcodeSkillAdapter };
+export default adapter;
From f0d15b444ec3637e36319ee87b6293095ea2ffc6 Mon Sep 17 00:00:00 2001
From: Justin Scroggins <60281870+itz4blitz@users.noreply.github.com>
Date: Sun, 6 Sep 2026 12:01:04 -0400
Subject: [PATCH 3/3] feat(mcp): write native host formats and stop leaky sync
MCP sync/install/generate now take servers only from mcp.config.ts
(layer + whenPathContains) and refuse empty writes. Unified
`ai-tools sync` is disabled. Cursor/Claude emit HTTP type, Claude
user MCP is ~/.claude.json, ZCode writes mcp.servers, and skills
use SKILL.md. Grok and ZCode hooks run HookEngine instead of a
no-op. Add MIT LICENSE and drop unused UI/session code.
---
CHANGELOG.md | 16 +
CONTRIBUTING.md | 1 +
LICENSE | 21 +
README.md | 28 +-
packages/agents/src/adapters/opencode.test.ts | 85 ++-
packages/agents/src/adapters/opencode.ts | 2 +-
packages/cli/LICENSE | 21 +
packages/cli/README.md | 28 +-
packages/cli/src/canonical/commands.test.ts | 81 +++
packages/cli/src/canonical/commands.ts | 46 +-
packages/cli/src/canonical/gitignore.test.ts | 17 +-
packages/cli/src/canonical/gitignore.ts | 5 +
packages/cli/src/cli/index.test.ts | 79 +--
packages/cli/src/cli/index.ts | 47 +-
packages/cli/src/index.test.ts | 16 +
packages/cli/src/index.ts | 10 +-
packages/cli/src/plugins/hosts.test.ts | 79 +++
packages/cli/src/plugins/hosts.ts | 61 +-
packages/cli/src/plugins/install.test.ts | 305 ++++++++++
packages/cli/src/plugins/install.ts | 24 +-
packages/cli/src/plugins/plan.test.ts | 55 ++
packages/hooks/src/adapters/all.ts | 1 +
.../hooks/src/adapters/claude-compat.test.ts | 275 +++++++++
packages/hooks/src/adapters/claude-compat.ts | 372 ++++++++++++
.../hooks/src/adapters/grok-runner.test.ts | 248 ++++++++
packages/hooks/src/adapters/grok-runner.ts | 75 +++
packages/hooks/src/adapters/grok.test.ts | 177 ++++--
packages/hooks/src/adapters/grok.ts | 89 ++-
.../hooks/src/adapters/zcode-runner.test.ts | 199 +++++++
packages/hooks/src/adapters/zcode-runner.ts | 36 ++
packages/hooks/src/adapters/zcode.test.ts | 335 +++++++++++
packages/hooks/src/adapters/zcode.ts | 309 ++++++++++
packages/hooks/src/index.ts | 2 +
packages/hooks/src/types/adapter.ts | 5 +
packages/mcp/src/adapters/claude-code.test.ts | 77 ++-
packages/mcp/src/adapters/claude-code.ts | 83 ++-
packages/mcp/src/adapters/codex.test.ts | 18 +
packages/mcp/src/adapters/codex.ts | 3 +-
packages/mcp/src/adapters/cursor.test.ts | 42 ++
packages/mcp/src/adapters/cursor.ts | 79 +--
packages/mcp/src/adapters/grok.test.ts | 20 +
.../mcp/src/adapters/mcp-servers-json.test.ts | 98 ++++
packages/mcp/src/adapters/mcp-servers-json.ts | 73 +++
packages/mcp/src/adapters/opencode.test.ts | 42 ++
packages/mcp/src/adapters/opencode.ts | 13 +-
packages/mcp/src/adapters/roundtrip.test.ts | 420 ++++++++++++--
packages/mcp/src/adapters/zcode.test.ts | 59 +-
packages/mcp/src/adapters/zcode.ts | 141 ++---
packages/mcp/src/cli/index.test.ts | 549 ++++++++++++++++--
packages/mcp/src/cli/index.ts | 58 +-
packages/mcp/src/toml-mcp.test.ts | 73 +++
packages/mcp/src/toml-mcp.ts | 55 +-
packages/rules/src/adapters/cursor.test.ts | 204 ++++---
packages/rules/src/adapters/cursor.ts | 11 +-
packages/sessions/package.json | 3 -
packages/sessions/src/cli/index.test.ts | 16 +
packages/sessions/src/cli/index.ts | 4 -
packages/sessions/src/config/define.ts | 5 -
packages/sessions/src/config/index.ts | 1 -
packages/sessions/src/index.ts | 3 -
packages/sessions/src/types/config.ts | 11 -
packages/sessions/src/types/index.ts | 1 -
packages/sessions/src/utils/slug.test.ts | 67 ---
packages/sessions/src/utils/slug.ts | 23 -
packages/skills/src/adapters/amp.test.ts | 5 +-
.../skills/src/adapters/claude-code.test.ts | 155 +++--
packages/skills/src/adapters/claude-code.ts | 75 +--
packages/skills/src/adapters/cline.test.ts | 5 +-
packages/skills/src/adapters/codex.test.ts | 149 +++--
packages/skills/src/adapters/codex.ts | 75 +--
packages/skills/src/adapters/continue.test.ts | 5 +-
packages/skills/src/adapters/cursor.test.ts | 169 ++++--
packages/skills/src/adapters/cursor.ts | 75 +--
packages/skills/src/adapters/droid.test.ts | 5 +-
.../skills/src/adapters/gemini-cli.test.ts | 6 +-
packages/skills/src/adapters/kiro.test.ts | 5 +-
packages/skills/src/adapters/opencode.test.ts | 149 +++--
packages/skills/src/adapters/opencode.ts | 75 +--
packages/ui/src/cli/index.test.ts | 40 +-
packages/ui/src/cli/index.ts | 8 -
packages/ui/src/command-palette.test.ts | 209 -------
packages/ui/src/command-palette.ts | 65 ---
packages/ui/src/commands/config-sync.test.ts | 80 ++-
packages/ui/src/commands/config-sync.ts | 6 +-
packages/ui/src/design-system.test.ts | 76 ---
packages/ui/src/design-system.ts | 398 -------------
packages/ui/src/index.ts | 19 -
packages/ui/src/preferences.ts | 4 +-
.../ui/src/runtime/action-executor.test.ts | 4 +-
packages/ui/src/runtime/action-executor.ts | 10 +-
packages/ui/src/runtime/state.ts | 2 -
.../runtime/terminal-command-executor.test.ts | 246 ++++++++
packages/ui/src/spawn/index.ts | 207 -------
packages/ui/src/theme/icons.test.ts | 94 +++
packages/ui/src/theme/spacing.test.ts | 43 ++
packages/ui/src/tmux/index.ts | 9 -
packages/ui/src/tmux/manager.ts | 377 ------------
packages/ui/src/tui.ts | 2 -
packages/ui/src/views/config.test.ts | 8 +-
packages/ui/src/views/config.ts | 6 +-
packages/ui/src/views/help.test.ts | 13 +
packages/ui/src/views/help.ts | 2 +-
.../ui/src/widgets/config-dashboard.test.ts | 125 +++-
scripts/cli-publish-assets.js | 5 +
scripts/smoke-package.mjs | 10 +-
vitest.config.ts | 14 +-
106 files changed, 5921 insertions(+), 2516 deletions(-)
create mode 100644 LICENSE
create mode 100644 packages/cli/LICENSE
create mode 100644 packages/cli/src/index.test.ts
create mode 100644 packages/cli/src/plugins/hosts.test.ts
create mode 100644 packages/hooks/src/adapters/claude-compat.test.ts
create mode 100644 packages/hooks/src/adapters/claude-compat.ts
create mode 100644 packages/hooks/src/adapters/grok-runner.test.ts
create mode 100644 packages/hooks/src/adapters/grok-runner.ts
create mode 100644 packages/hooks/src/adapters/zcode-runner.test.ts
create mode 100644 packages/hooks/src/adapters/zcode-runner.ts
create mode 100644 packages/hooks/src/adapters/zcode.test.ts
create mode 100644 packages/hooks/src/adapters/zcode.ts
create mode 100644 packages/mcp/src/adapters/mcp-servers-json.test.ts
create mode 100644 packages/mcp/src/adapters/mcp-servers-json.ts
delete mode 100644 packages/sessions/src/config/define.ts
delete mode 100644 packages/sessions/src/config/index.ts
delete mode 100644 packages/sessions/src/types/config.ts
delete mode 100644 packages/sessions/src/utils/slug.test.ts
delete mode 100644 packages/sessions/src/utils/slug.ts
delete mode 100644 packages/ui/src/command-palette.test.ts
delete mode 100644 packages/ui/src/command-palette.ts
delete mode 100644 packages/ui/src/design-system.test.ts
delete mode 100644 packages/ui/src/design-system.ts
create mode 100644 packages/ui/src/runtime/terminal-command-executor.test.ts
delete mode 100644 packages/ui/src/spawn/index.ts
create mode 100644 packages/ui/src/theme/icons.test.ts
create mode 100644 packages/ui/src/theme/spacing.test.ts
delete mode 100644 packages/ui/src/tmux/index.ts
delete mode 100644 packages/ui/src/tmux/manager.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9fe92d8..9de66a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,19 @@
+## [Unreleased]
+
+### Features
+
+* **mcp:** Grok and ZCode adapters with native formats (`.grok/config.toml`, `.zcode/mcp.json`, `~/.zcode/cli/config.json`)
+* **mcp:** `layer` and `whenPathContains` so PalamHealth servers stay project-local
+* **mcp:** `audit` finds PalamHealth / interactive-1Password leaks in user-global configs
+* **mcp:** `sync` installs from `mcp.config.ts` only and no longer union-copies imported servers between tools
+
+### Bug Fixes
+
+* **plugins:** host capability flags match actual adapters (zcode hooks true after the ZCode hook adapter landed; antigravity no longer claims missing MCP/hooks; add cline, droid, amp, continue, gemini-cli, roo-code)
+* **plugins:** install honors `layer`/`whenPathContains` instead of copying every MCP server onto a host
+* **cli:** canonical `install` records tool-directory targets instead of `targets: []`
+* add an MIT `LICENSE` file to match the published badge
+
## [1.1.10](https://github.com/itz4blitz/ai-tools/compare/v1.1.9...v1.1.10) (2026-08-26)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 54f0ae8..040be57 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,6 +31,7 @@ packages/
skills/
rules/
sessions/
+ ui/ # interactive TUI (not a separately published engine)
```
## Conventions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..55758f5
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 itz4blitz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 3064e10..46ea917 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ |
-| zcode | | ✓ | ✓ | ✓ | ✓ |
+| zcode | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok adapters cover hooks/MCP/agents/skills/rules. ZCode covers MCP/agents/skills/rules (hooks follow OpenCode plugins, not yet a separate ZCode hook adapter).
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode adapters cover hooks/MCP/agents/skills/rules.
## 60-second path
@@ -179,21 +179,34 @@ export default defineConfig({
### MCP
```ts
-// ai-mcp.config.ts
+// mcp.config.ts
import { defineConfig } from "@itz4blitz/ai-tools/mcp";
export default defineConfig({
servers: [
{
+ id: "filesystem",
name: "filesystem",
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
- transport: "stdio",
+ transport: {
+ type: "stdio",
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
+ },
+ layer: "project",
+ },
+ {
+ id: "palamhealth",
+ name: "PalamHealth",
+ transport: { type: "stdio", command: "palamhealth-mcp" },
+ layer: "project",
+ whenPathContains: ["PalamHealth"],
},
],
});
```
+`ai-tools mcp install --layer=project` writes only matching servers. `ai-tools mcp sync` never copies imported servers between tools. `ai-tools mcp audit` reports PalamHealth leaks in user-global configs.
+
### Rules
```ts
@@ -203,9 +216,10 @@ import { defineRulesConfig } from "@itz4blitz/ai-tools/rules";
export default defineRulesConfig({
rules: [
{
+ id: "typescript-strict",
name: "typescript-strict",
content: "Always use strict TypeScript. No `any` types.",
- scope: "always",
+ scope: { type: "always" },
priority: 1,
},
],
diff --git a/packages/agents/src/adapters/opencode.test.ts b/packages/agents/src/adapters/opencode.test.ts
index aee8d39..5a15a51 100644
--- a/packages/agents/src/adapters/opencode.test.ts
+++ b/packages/agents/src/adapters/opencode.test.ts
@@ -1,4 +1,8 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import type { AgentDefinition } from "../types/index.js";
vi.mock("./index.js", () => {
@@ -19,19 +23,17 @@ vi.mock("./index.js", () => {
return { BaseAgentAdapter, registry };
});
-vi.mock("node:fs", () => ({
- existsSync: vi.fn(() => false),
-}));
-
-vi.mock("node:fs/promises", () => ({
- readFile: vi.fn(),
- readdir: vi.fn(),
-}));
-
-import { existsSync } from "node:fs";
-import { readFile, readdir } from "node:fs/promises";
import { OpenCodeAgentAdapter } from "./opencode.js";
+async function withTempDir(fn: (dir: string) => Promise): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "ai-tools-agents-opencode-"));
+ try {
+ await fn(dir);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+}
+
describe("OpenCodeAgentAdapter", () => {
let adapter: OpenCodeAgentAdapter;
@@ -43,7 +45,6 @@ describe("OpenCodeAgentAdapter", () => {
};
beforeEach(() => {
- vi.clearAllMocks();
adapter = new OpenCodeAgentAdapter();
});
@@ -61,7 +62,9 @@ describe("OpenCodeAgentAdapter", () => {
});
it("has correct configDir", () => {
- expect(adapter.configDir).toBe(".opencode/agents");
+ // Live OpenCode dir is agent/ (singular). Observed ~/.config/opencode/agent/crash.md
+ // and OpenCode binary docs: `.opencode/agent/.md`.
+ expect(adapter.configDir).toBe(".opencode/agent");
});
});
@@ -71,9 +74,9 @@ describe("OpenCodeAgentAdapter", () => {
expect(files).toEqual([]);
});
- it("generates file at correct path", async () => {
+ it("generates file at the live OpenCode agent path", async () => {
const files = await adapter.generate([testAgent]);
- expect(files[0]!.path).toBe(".opencode/agents/reviewer.md");
+ expect(files[0]!.path).toBe(".opencode/agent/reviewer.md");
});
it("generates file with md format", async () => {
@@ -82,29 +85,51 @@ describe("OpenCodeAgentAdapter", () => {
});
});
+ describe("install", () => {
+ it("writes markdown into .opencode/agent, not .opencode/agents", async () => {
+ await withTempDir(async (dir) => {
+ const files = await adapter.generate([testAgent]);
+ await adapter.install(files, dir);
+
+ const agentPath = join(dir, ".opencode/agent/reviewer.md");
+ expect(existsSync(agentPath)).toBe(true);
+ expect(existsSync(join(dir, ".opencode/agents/reviewer.md"))).toBe(false);
+ expect(await readFile(agentPath, "utf-8")).toContain("# Code Reviewer");
+ });
+ });
+ });
+
describe("import", () => {
it("returns empty array when config dir does not exist", async () => {
- vi.mocked(existsSync).mockReturnValue(false);
- const agents = await adapter.import("/project");
- expect(agents).toEqual([]);
+ await withTempDir(async (dir) => {
+ expect(await adapter.import(dir)).toEqual([]);
+ });
});
it("imports without cwd argument", async () => {
- vi.mocked(existsSync).mockReturnValue(false);
- const agents = await adapter.import();
- expect(agents).toEqual([]);
+ await withTempDir(async (dir) => {
+ const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(dir);
+ try {
+ expect(await adapter.import()).toEqual([]);
+ } finally {
+ cwdSpy.mockRestore();
+ }
+ });
});
it("parses agent from markdown file", async () => {
- vi.mocked(existsSync).mockReturnValue(true);
- vi.mocked(readdir).mockResolvedValue(["reviewer.md"] as never);
- vi.mocked(readFile).mockResolvedValue(
- "---\ndescription: Reviews code\n---\n\n# Code Reviewer\n\nReview.\n",
- );
-
- const agents = await adapter.import("/project");
- expect(agents).toHaveLength(1);
- expect(agents[0]!.id).toBe("reviewer");
+ await withTempDir(async (dir) => {
+ await mkdir(join(dir, ".opencode/agent"), { recursive: true });
+ await writeFile(
+ join(dir, ".opencode/agent/reviewer.md"),
+ "---\ndescription: Reviews code\n---\n\n# Code Reviewer\n\nReview.\n",
+ );
+
+ const agents = await adapter.import(dir);
+ expect(agents).toHaveLength(1);
+ expect(agents[0]!.id).toBe("reviewer");
+ expect(agents[0]!.name).toBe("Code Reviewer");
+ });
});
});
});
diff --git a/packages/agents/src/adapters/opencode.ts b/packages/agents/src/adapters/opencode.ts
index 67f1371..1ab99f8 100644
--- a/packages/agents/src/adapters/opencode.ts
+++ b/packages/agents/src/adapters/opencode.ts
@@ -3,7 +3,7 @@ import { createMarkdownAdapter } from "./markdown-adapter.js";
const { Adapter: OpenCodeAgentAdapter, adapter } = createMarkdownAdapter({
id: "opencode",
name: "OpenCode",
- configDir: ".opencode/agents",
+ configDir: ".opencode/agent",
command: "opencode",
});
diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE
new file mode 100644
index 0000000..55758f5
--- /dev/null
+++ b/packages/cli/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 itz4blitz
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index 3064e10..46ea917 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -24,7 +24,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [OpenCode](https://opencode.ai) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Codex](https://openai.com/codex/) | ✓ | ✓ | ✓ | ✓ | ✓ |
| Grok / grok build | ✓ | ✓ | ✓ | ✓ | ✓ |
-| zcode | | ✓ | ✓ | ✓ | ✓ |
+| zcode | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | ✓ | ✓ | ✓ | ✓ | ✓ |
| [Antigravity CLI](https://www.antigravity.google/product/antigravity-cli) | | | ✓ | ✓ | ✓ |
| [Kiro](https://kiro.dev) | ✓ | ✓ | ✓ | ✓ | ✓ |
@@ -36,7 +36,7 @@ Primary fleet is Claude Code, Cursor, OpenCode, Codex, plus Grok and zcode next.
| [Roo Code](https://roocode.com) | | ✓ | ✓ | ✓ | ✓ |
| [Windsurf](https://windsurf.com) | | ✓ | | ✓ | ✓ |
-✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok adapters cover hooks/MCP/agents/skills/rules. ZCode covers MCP/agents/skills/rules (hooks follow OpenCode plugins, not yet a separate ZCode hook adapter).
+✱ VS Code 1.109+ agent hooks use the Claude Code format. Grok and ZCode adapters cover hooks/MCP/agents/skills/rules.
## 60-second path
@@ -179,21 +179,34 @@ export default defineConfig({
### MCP
```ts
-// ai-mcp.config.ts
+// mcp.config.ts
import { defineConfig } from "@itz4blitz/ai-tools/mcp";
export default defineConfig({
servers: [
{
+ id: "filesystem",
name: "filesystem",
- command: "npx",
- args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
- transport: "stdio",
+ transport: {
+ type: "stdio",
+ command: "npx",
+ args: ["-y", "@modelcontextprotocol/server-filesystem", "./src"],
+ },
+ layer: "project",
+ },
+ {
+ id: "palamhealth",
+ name: "PalamHealth",
+ transport: { type: "stdio", command: "palamhealth-mcp" },
+ layer: "project",
+ whenPathContains: ["PalamHealth"],
},
],
});
```
+`ai-tools mcp install --layer=project` writes only matching servers. `ai-tools mcp sync` never copies imported servers between tools. `ai-tools mcp audit` reports PalamHealth leaks in user-global configs.
+
### Rules
```ts
@@ -203,9 +216,10 @@ import { defineRulesConfig } from "@itz4blitz/ai-tools/rules";
export default defineRulesConfig({
rules: [
{
+ id: "typescript-strict",
name: "typescript-strict",
content: "Always use strict TypeScript. No `any` types.",
- scope: "always",
+ scope: { type: "always" },
priority: 1,
},
],
diff --git a/packages/cli/src/canonical/commands.test.ts b/packages/cli/src/canonical/commands.test.ts
index 7d89f5d..24e5567 100644
--- a/packages/cli/src/canonical/commands.test.ts
+++ b/packages/cli/src/canonical/commands.test.ts
@@ -45,6 +45,7 @@ import { addManagedBlock, removeManagedBlock, hasManagedBlock } from "./gitignor
import { existsSync } from "node:fs";
import { rm } from "node:fs/promises";
import { cmdInit, cmdGenerate, cmdInstall, cmdStatus, cmdClean } from "./commands.js";
+import * as canonical from "./index.js";
const mockWriteConfig = vi.mocked(writeConfig);
const mockIsCanonical = vi.mocked(isCanonical);
@@ -92,6 +93,14 @@ function allError(): string {
// ── cmdInit ─────────────────────────────────────────────────
+describe("canonical barrel", () => {
+ it("re-exports config helpers used by the CLI", () => {
+ expect(canonical.writeConfig).toBe(writeConfig);
+ expect(canonical.readManifest).toBe(readManifest);
+ expect(canonical.addManagedBlock).toBe(addManagedBlock);
+ });
+});
+
describe("cmdInit", () => {
it("creates config and adds gitignore block", async () => {
mockGetMode.mockResolvedValue("direct");
@@ -162,12 +171,84 @@ describe("cmdInstall", () => {
it("installs all engines and updates manifest", async () => {
mockIsCanonical.mockResolvedValue(true);
mockReadManifest.mockResolvedValue({ version: 1, entries: [] });
+ mockExistsSync.mockReturnValue(false);
await cmdInstall([]);
expect(mockHooksRun).toHaveBeenCalledWith(["install"]);
expect(mockWriteManifest).toHaveBeenCalled();
expect(allLog()).toContain("Installed 5 engine(s)");
});
+ it("replaces an existing manifest entry for the same engine", async () => {
+ mockIsCanonical.mockResolvedValue(true);
+ mockExistsSync.mockReturnValue(false);
+ mockReadManifest.mockResolvedValue({
+ version: 1,
+ entries: [
+ {
+ engine: "hooks",
+ id: "hooks",
+ canonicalPath: ".ai-tools/hooks/",
+ targets: [],
+ updatedAt: "2020-01-01T00:00:00Z",
+ },
+ ],
+ });
+
+ await cmdInstall([]);
+
+ const written = mockWriteManifest.mock.calls[0]?.[0] as {
+ entries: Array<{ engine: string; updatedAt: string }>;
+ };
+ const hooks = written.entries.filter((entry) => entry.engine === "hooks");
+ expect(hooks).toHaveLength(1);
+ expect(hooks[0]?.updatedAt).not.toBe("2020-01-01T00:00:00Z");
+ });
+
+ it("continues installing after an engine throws", async () => {
+ mockIsCanonical.mockResolvedValue(true);
+ mockReadManifest.mockResolvedValue({ version: 1, entries: [] });
+ mockExistsSync.mockReturnValue(false);
+ mockHooksRun.mockRejectedValueOnce(new Error("hooks install failed"));
+ mockMcpRun.mockRejectedValueOnce("mcp exploded");
+
+ await cmdInstall([]);
+
+ expect(allError()).toContain("hooks install failed");
+ expect(allError()).toContain("mcp exploded");
+ expect(mockSkillsRun).toHaveBeenCalled();
+ expect(mockWriteManifest).toHaveBeenCalled();
+ });
+
+ it("records existing tool directories as manifest targets instead of an empty list", async () => {
+ mockIsCanonical.mockResolvedValue(true);
+ mockReadManifest.mockResolvedValue({ version: 1, entries: [] });
+ mockExistsSync.mockImplementation((path: unknown) => {
+ const value = String(path);
+ return value.endsWith("/.claude") || value.endsWith("/.grok") || value.endsWith("/.zcode");
+ });
+
+ await cmdInstall([]);
+
+ const written = mockWriteManifest.mock.calls[0]?.[0] as {
+ entries: Array<{ engine: string; targets: Array<{ adapterId: string; targetPath: string }> }>;
+ };
+ const hooksEntry = written.entries.find((entry) => entry.engine === "hooks");
+ expect(hooksEntry?.targets).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ adapterId: "claude-code",
+ targetPath: ".claude",
+ strategy: "transform",
+ status: "direct",
+ }),
+ expect.objectContaining({ adapterId: "grok", targetPath: ".grok" }),
+ expect.objectContaining({ adapterId: "zcode", targetPath: ".zcode" }),
+ ]),
+ );
+ expect(hooksEntry?.targets).toHaveLength(3);
+ expect(allLog().toLowerCase()).not.toContain("canonical store");
+ });
+
it("respects --dry-run flag", async () => {
mockIsCanonical.mockResolvedValue(true);
mockReadManifest.mockResolvedValue({ version: 1, entries: [] });
diff --git a/packages/cli/src/canonical/commands.ts b/packages/cli/src/canonical/commands.ts
index 270d9cb..1e111e8 100644
--- a/packages/cli/src/canonical/commands.ts
+++ b/packages/cli/src/canonical/commands.ts
@@ -36,18 +36,34 @@ const ENGINES: EngineModule[] = [
},
];
-const TOOL_DIRS = [
- ".claude",
- ".cursor",
- ".opencode",
- ".gemini",
- ".cline",
- ".factory",
- ".codex",
- ".kiro",
- ".amp",
+const TOOL_TARGETS: Array<{ dir: string; adapterId: string }> = [
+ { dir: ".claude", adapterId: "claude-code" },
+ { dir: ".cursor", adapterId: "cursor" },
+ { dir: ".opencode", adapterId: "opencode" },
+ { dir: ".gemini", adapterId: "gemini-cli" },
+ { dir: ".cline", adapterId: "cline" },
+ { dir: ".factory", adapterId: "droid" },
+ { dir: ".codex", adapterId: "codex" },
+ { dir: ".kiro", adapterId: "kiro" },
+ { dir: ".amp", adapterId: "amp" },
+ { dir: ".grok", adapterId: "grok" },
+ { dir: ".zcode", adapterId: "zcode" },
+ { dir: ".continue", adapterId: "continue" },
+ { dir: ".roo", adapterId: "roo-code" },
+ { dir: ".agents", adapterId: "antigravity-cli" },
];
+const TOOL_DIRS = TOOL_TARGETS.map((target) => target.dir);
+
+function presentToolTargets(cwd: string): ManifestTarget[] {
+ return TOOL_TARGETS.filter((target) => existsSync(resolve(cwd, target.dir))).map((target) => ({
+ adapterId: target.adapterId,
+ targetPath: target.dir,
+ strategy: "transform" as const,
+ status: "direct" as const,
+ }));
+}
+
function hasFlag(flags: string[], flag: string): boolean {
return flags.includes(flag);
}
@@ -80,7 +96,7 @@ export async function cmdInit(flags: string[]): Promise {
console.log("Initialized .ai-tools/ in canonical mode.");
console.log("");
console.log("Next steps:");
- console.log(" 1. Run: ai-tools generate (write canonical files from config)");
+ console.log(" 1. Run: ai-tools generate (write engine files from configs)");
console.log(" 2. Run: ai-tools install (install to tool directories)");
console.log(" 3. Run: ai-tools status (check installation health)");
}
@@ -96,7 +112,7 @@ export async function cmdGenerate(flags: string[]): Promise {
return;
}
- console.log("Generating canonical files...\n");
+ console.log("Generating engine files from configs...\n");
for (const engine of ENGINES) {
try {
@@ -127,10 +143,11 @@ export async function cmdInstall(flags: string[]): Promise {
return;
}
- console.log("Installing from canonical store...\n");
+ console.log("Installing engine configs into tool directories...\n");
const manifest = await readManifest();
let totalInstalled = 0;
+ const cwd = process.cwd();
for (const engine of ENGINES) {
try {
@@ -143,12 +160,11 @@ export async function cmdInstall(flags: string[]): Promise {
await mod.run(installArgs);
totalInstalled++;
- // Record in manifest
const entry: ManifestEntry = {
engine: engine.name,
id: engine.name,
canonicalPath: `.ai-tools/${engine.name}/`,
- targets: [],
+ targets: presentToolTargets(cwd),
updatedAt: new Date().toISOString(),
};
diff --git a/packages/cli/src/canonical/gitignore.test.ts b/packages/cli/src/canonical/gitignore.test.ts
index eaaf679..d219da2 100644
--- a/packages/cli/src/canonical/gitignore.test.ts
+++ b/packages/cli/src/canonical/gitignore.test.ts
@@ -30,6 +30,12 @@ afterEach(() => {
describe("addManagedBlock", () => {
it("creates .gitignore with managed block when file does not exist", async () => {
mockExistsSync.mockReturnValue(false);
+ await addManagedBlock();
+ expect(mockWriteFile).toHaveBeenCalledWith(
+ expect.stringContaining(".gitignore"),
+ expect.stringContaining("ai-tools managed"),
+ "utf-8",
+ );
await addManagedBlock("/test");
expect(mockWriteFile).toHaveBeenCalledWith(
expect.stringContaining(".gitignore"),
@@ -39,6 +45,8 @@ describe("addManagedBlock", () => {
const written = mockWriteFile.mock.calls[0]?.[1] as string;
expect(written).toContain(".claude/");
expect(written).toContain(".cursor/");
+ expect(written).toContain(".grok/");
+ expect(written).toContain(".zcode/");
expect(written).toContain("end ai-tools managed");
});
@@ -97,6 +105,13 @@ describe("removeManagedBlock", () => {
expect(written).not.toContain("ai-tools managed");
});
+ it("does nothing when the start marker is present without the end marker", async () => {
+ mockExistsSync.mockReturnValue(true);
+ mockReadFile.mockResolvedValue("# ── ai-tools managed (canonical mode) ──\n.claude/\n");
+ await removeManagedBlock("/test");
+ expect(mockWriteFile).not.toHaveBeenCalled();
+ });
+
it("handles file with only managed block", async () => {
const content =
"# ── ai-tools managed (canonical mode) ──\n.claude/\n# ── end ai-tools managed ──\n";
@@ -129,7 +144,7 @@ describe("hasManagedBlock", () => {
mockReadFile.mockResolvedValue(
"# ── ai-tools managed (canonical mode) ──\n.claude/\n# ── end ai-tools managed ──\n",
);
- const result = await hasManagedBlock("/test");
+ const result = await hasManagedBlock();
expect(result).toBe(true);
});
});
diff --git a/packages/cli/src/canonical/gitignore.ts b/packages/cli/src/canonical/gitignore.ts
index 03f580a..ce0b2fc 100644
--- a/packages/cli/src/canonical/gitignore.ts
+++ b/packages/cli/src/canonical/gitignore.ts
@@ -15,6 +15,11 @@ const MANAGED_BLOCK = `${START_MARKER}
.codex/
.kiro/
.amp/
+.grok/
+.zcode/
+.continue/
+.roo/
+.agents/
${END_MARKER}`;
export async function addManagedBlock(cwd?: string): Promise {
diff --git a/packages/cli/src/cli/index.test.ts b/packages/cli/src/cli/index.test.ts
index 1d5ea47..fa7f1fd 100644
--- a/packages/cli/src/cli/index.test.ts
+++ b/packages/cli/src/cli/index.test.ts
@@ -95,6 +95,23 @@ describe("run() - help output", () => {
expect(output).toContain(name);
}
});
+
+ it("does not advertise unified sync as a cross-cutting command", async () => {
+ await run(["help"]);
+ const output = allLog();
+ const crossCutting = output.split("CROSS-CUTTING COMMANDS:")[1]?.split("INTERACTIVE:")[0] ?? "";
+ expect(crossCutting).toContain("detect");
+ expect(crossCutting).not.toMatch(/\bsync\b/);
+ expect(output).toContain("ai-tools mcp install --layer=project");
+ expect(output).toContain("ai-tools mcp sync --layer=project");
+ });
+
+ it("does not claim canonical install uses a CanonicalStore", async () => {
+ await run(["help"]);
+ const output = allLog();
+ expect(output).not.toMatch(/canonical store/i);
+ expect(output).toContain("Install engine configs into detected tool directories");
+ });
});
// ── Unknown command ──────────────────────────────────────────
@@ -219,46 +236,42 @@ describe("run() - cross-cutting detect", () => {
// ── Cross-cutting sync ───────────────────────────────────────
-describe("run() - cross-cutting sync", () => {
- it("calls sync on 4 engines (skips hooks and sessions)", async () => {
- await run(["sync"]);
+describe("run() - unified sync is decommissioned", () => {
+ it("refuses unified sync instead of union-copying across engines", async () => {
+ await expect(run(["sync"])).rejects.toThrow("process.exit(1)");
+ expect(allError()).toBe(
+ [
+ "Unified sync is disabled. It used to copy imported servers between every tool.",
+ "Use `ai-tools mcp sync` to install from mcp.config.ts (never copies imports; honors --layer).",
+ ].join("\n"),
+ );
+ expect(exitCode).toBe(1);
expect(mockHooksRun).not.toHaveBeenCalled();
+ expect(mockMcpRun).not.toHaveBeenCalled();
+ expect(mockSkillsRun).not.toHaveBeenCalled();
+ expect(mockAgentsRun).not.toHaveBeenCalled();
+ expect(mockRulesRun).not.toHaveBeenCalled();
+ expect(mockPluginsRun).not.toHaveBeenCalled();
expect(mockSessionsRun).not.toHaveBeenCalled();
- expect(mockMcpRun).toHaveBeenCalledWith(["sync"]);
- expect(mockSkillsRun).toHaveBeenCalledWith(["sync"]);
- expect(mockAgentsRun).toHaveBeenCalledWith(["sync"]);
- expect(mockRulesRun).toHaveBeenCalledWith(["sync"]);
- });
-
- it("prints engine headers for sync-capable engines only", async () => {
- await run(["sync"]);
- const output = allLog();
- expect(output).not.toContain("── hooks ──");
- expect(output).not.toContain("── sessions ──");
- expect(output).toContain("── mcp ──");
- expect(output).toContain("── skills ──");
- expect(output).toContain("── agents ──");
- expect(output).toContain("── rules ──");
});
- it("forwards flags to each engine", async () => {
- await run(["sync", "--dry-run"]);
-
- expect(mockMcpRun).toHaveBeenCalledWith(["sync", "--dry-run"]);
- expect(mockSkillsRun).toHaveBeenCalledWith(["sync", "--dry-run"]);
+ it("refuses unified sync even with --dry-run", async () => {
+ await expect(run(["sync", "--dry-run"])).rejects.toThrow("process.exit(1)");
+ expect(allError()).toContain("Unified sync is disabled");
+ expect(mockMcpRun).not.toHaveBeenCalled();
+ expect(mockSkillsRun).not.toHaveBeenCalled();
+ expect(mockAgentsRun).not.toHaveBeenCalled();
+ expect(mockRulesRun).not.toHaveBeenCalled();
});
- it("catches engine errors and continues", async () => {
- mockMcpRun.mockRejectedValueOnce(new Error("mcp failed"));
-
- await run(["sync"]);
-
- expect(allError()).toContain("Error: mcp failed");
- // Other engines still called
- expect(mockSkillsRun).toHaveBeenCalledWith(["sync"]);
- expect(mockAgentsRun).toHaveBeenCalledWith(["sync"]);
- expect(mockRulesRun).toHaveBeenCalledWith(["sync"]);
+ it("still delegates engine-level mcp sync (config-scoped) to the mcp engine", async () => {
+ await run(["mcp", "sync", "--layer=project", "--dry-run"]);
+ expect(mockMcpRun).toHaveBeenCalledTimes(1);
+ expect(mockMcpRun).toHaveBeenCalledWith(["sync", "--layer=project", "--dry-run"]);
+ expect(mockSkillsRun).not.toHaveBeenCalled();
+ expect(mockAgentsRun).not.toHaveBeenCalled();
+ expect(mockRulesRun).not.toHaveBeenCalled();
});
});
diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts
index d74e0f9..401d9ce 100644
--- a/packages/cli/src/cli/index.ts
+++ b/packages/cli/src/cli/index.ts
@@ -17,7 +17,6 @@ ENGINES:
CROSS-CUTTING COMMANDS:
detect Run detect across all engines
- sync Run sync across supported engines (hooks excluded)
help Show this help message
INTERACTIVE:
@@ -26,7 +25,7 @@ INTERACTIVE:
CANONICAL MODE COMMANDS:
init Initialize .ai-tools/ directory (canonical mode)
generate Generate canonical files from engine configs
- install Install from canonical store to tool directories
+ install Install engine configs into detected tool directories
status Show installation mode and health
clean Remove tool-specific directories
@@ -37,12 +36,14 @@ ENGINE COMMANDS:
OPTIONS:
--tools Comma-separated list of tools (forwarded to engine)
--dry-run Show what would happen without writing files
+ --layer user or project (MCP install/sync; forwarded)
EXAMPLES:
${cliName} mcp detect # Detect MCP-capable tools
- ${cliName} skills sync # Sync skills across tools
+ ${cliName} mcp install --layer=project # Install from mcp.config.ts
+ ${cliName} mcp sync --layer=project # Config-scoped MCP install (never copies imports)
+ ${cliName} mcp audit # Find PalamHealth user-global leaks
${cliName} detect # Detect across all engines
- ${cliName} sync --dry-run # Sync all engines (dry run)
${cliName} plugins plan --tools=cursor,codex,opencode
${cliName} hooks init # Initialize hooks config
${cliName} init # Initialize canonical mode
@@ -56,17 +57,16 @@ EXAMPLES:
type EngineEntry = {
name: string;
pkg: string;
- hasSync: boolean;
};
const ENGINES: Record = {
- hooks: { name: "hooks", pkg: "@itz4blitz/ai-tools-hooks/cli", hasSync: false },
- mcp: { name: "mcp", pkg: "@itz4blitz/ai-tools-mcp/cli", hasSync: true },
- skills: { name: "skills", pkg: "@itz4blitz/ai-tools-skills/cli", hasSync: true },
- agents: { name: "agents", pkg: "@itz4blitz/ai-tools-agents/cli", hasSync: true },
- rules: { name: "rules", pkg: "@itz4blitz/ai-tools-rules/cli", hasSync: true },
- plugins: { name: "plugins", pkg: "./plugins", hasSync: false },
- sessions: { name: "sessions", pkg: "@itz4blitz/ai-tools-sessions/cli", hasSync: false },
+ hooks: { name: "hooks", pkg: "@itz4blitz/ai-tools-hooks/cli" },
+ mcp: { name: "mcp", pkg: "@itz4blitz/ai-tools-mcp/cli" },
+ skills: { name: "skills", pkg: "@itz4blitz/ai-tools-skills/cli" },
+ agents: { name: "agents", pkg: "@itz4blitz/ai-tools-agents/cli" },
+ rules: { name: "rules", pkg: "@itz4blitz/ai-tools-rules/cli" },
+ plugins: { name: "plugins", pkg: "./plugins" },
+ sessions: { name: "sessions", pkg: "@itz4blitz/ai-tools-sessions/cli" },
};
const ENGINE_NAMES = Object.keys(ENGINES);
@@ -101,7 +101,13 @@ export async function run(args: string[]): Promise {
return;
case "sync":
- await crossCutSync(args.slice(1));
+ console.error(
+ "Unified sync is disabled. It used to copy imported servers between every tool.",
+ );
+ console.error(
+ "Use `ai-tools mcp sync` to install from mcp.config.ts (never copies imports; honors --layer).",
+ );
+ process.exit(1);
return;
case "init":
@@ -159,18 +165,3 @@ async function crossCutDetect(flags: string[]): Promise {
}
}
}
-
-async function crossCutSync(flags: string[]): Promise {
- for (const name of ENGINE_NAMES) {
- const engine = ENGINES[name];
- if (!engine || !engine.hasSync) continue;
-
- console.log(`\n── ${engine.name} ──`);
- try {
- const mod = await loadEngine(engine.pkg);
- await mod.run(["sync", ...flags]);
- } catch (err) {
- console.error(` Error: ${err instanceof Error ? err.message : String(err)}`);
- }
- }
-}
diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts
new file mode 100644
index 0000000..b1ba4f3
--- /dev/null
+++ b/packages/cli/src/index.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { ENGINE_NAMES } from "./index.js";
+
+describe("ENGINE_NAMES", () => {
+ it("lists every engine the unified CLI actually routes", () => {
+ expect([...ENGINE_NAMES]).toEqual([
+ "hooks",
+ "mcp",
+ "skills",
+ "agents",
+ "rules",
+ "plugins",
+ "sessions",
+ ]);
+ });
+});
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 6bca61f..3be94b7 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -4,7 +4,15 @@ export { buildPluginInstallPlan } from "./plugins/plan.js";
export { installPluginBundle } from "./plugins/install.js";
export { KNOWN_PLUGIN_HOSTS, getPluginHostInfo } from "./plugins/hosts.js";
-export const ENGINE_NAMES = ["hooks", "mcp", "skills", "agents", "rules", "plugins"] as const;
+export const ENGINE_NAMES = [
+ "hooks",
+ "mcp",
+ "skills",
+ "agents",
+ "rules",
+ "plugins",
+ "sessions",
+] as const;
export type EngineName = (typeof ENGINE_NAMES)[number];
export type {
diff --git a/packages/cli/src/plugins/hosts.test.ts b/packages/cli/src/plugins/hosts.test.ts
new file mode 100644
index 0000000..91d2f18
--- /dev/null
+++ b/packages/cli/src/plugins/hosts.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from "vitest";
+import { getPluginHostInfo, KNOWN_PLUGIN_HOSTS } from "./hosts.js";
+import type { PluginEngine } from "./types.js";
+
+const ENGINES: PluginEngine[] = ["mcp", "skills", "rules", "agents", "hooks"];
+
+/** Adapter files that actually exist under packages//src/adapters. */
+const ADAPTER_SUPPORT: Record> = {
+ "claude-code": { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ "claude-desktop": { mcp: true, skills: false, rules: false, agents: false, hooks: false },
+ codex: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ cursor: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ opencode: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ grok: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ zcode: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ "antigravity-cli": { mcp: false, skills: true, rules: true, agents: true, hooks: false },
+ windsurf: { mcp: true, skills: true, rules: true, agents: false, hooks: false },
+ copilot: { mcp: true, skills: true, rules: true, agents: true, hooks: false },
+ kiro: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ cline: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ droid: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ amp: { mcp: true, skills: true, rules: true, agents: false, hooks: false },
+ continue: { mcp: false, skills: true, rules: true, agents: false, hooks: false },
+ "gemini-cli": { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ "roo-code": { mcp: true, skills: true, rules: true, agents: true, hooks: false },
+};
+
+describe("KNOWN_PLUGIN_HOSTS", () => {
+ it("lists every adapter-backed host", () => {
+ expect(Object.keys(KNOWN_PLUGIN_HOSTS).toSorted()).toEqual(
+ Object.keys(ADAPTER_SUPPORT).toSorted(),
+ );
+ });
+
+ it("sets nativeEngineSupport to match real adapters, not wishful flags", () => {
+ for (const [id, expected] of Object.entries(ADAPTER_SUPPORT)) {
+ const host = KNOWN_PLUGIN_HOSTS[id];
+ expect(host, `missing host ${id}`).toBeDefined();
+ expect(host?.nativeEngineSupport).toEqual(expected);
+ }
+ });
+
+ it("does not claim antigravity MCP/hooks without adapters", () => {
+ expect(KNOWN_PLUGIN_HOSTS["antigravity-cli"]?.nativeEngineSupport.mcp).toBe(false);
+ expect(KNOWN_PLUGIN_HOSTS["antigravity-cli"]?.nativeEngineSupport.hooks).toBe(false);
+ });
+
+ it("claims zcode hooks because packages/hooks registers a zcode adapter", () => {
+ expect(KNOWN_PLUGIN_HOSTS.zcode?.nativeEngineSupport.hooks).toBe(true);
+ });
+
+ it("fills every PluginEngine key on each host", () => {
+ for (const host of Object.values(KNOWN_PLUGIN_HOSTS)) {
+ for (const engine of ENGINES) {
+ expect(typeof host.nativeEngineSupport[engine]).toBe("boolean");
+ }
+ }
+ });
+});
+
+describe("getPluginHostInfo", () => {
+ it("returns researched metadata for known hosts", () => {
+ expect(getPluginHostInfo("grok").name).toBe("Grok");
+ expect(getPluginHostInfo("droid").nativeEngineSupport.hooks).toBe(true);
+ });
+
+ it("returns all-false support for unknown hosts instead of inventing adapters", () => {
+ const host = getPluginHostInfo("not-a-real-tool");
+ expect(host).toEqual({
+ id: "not-a-real-tool",
+ name: "not-a-real-tool",
+ kind: "cli",
+ nativeEngineSupport: { mcp: false, skills: false, rules: false, agents: false, hooks: false },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "No researched host metadata is available for this tool yet.",
+ });
+ });
+});
diff --git a/packages/cli/src/plugins/hosts.ts b/packages/cli/src/plugins/hosts.ts
index 3311bc4..e80a2d2 100644
--- a/packages/cli/src/plugins/hosts.ts
+++ b/packages/cli/src/plugins/hosts.ts
@@ -76,17 +76,17 @@ export const KNOWN_PLUGIN_HOSTS: Record = {
supportsInteractiveApps: false,
supportsNativeBundles: false,
notes:
- "ZCode user MCP is ~/.zcode/cli/config.json (mcp.servers). Project MCP is .zcode/mcp.json. Do not put PalamHealth in the user file.",
+ "ZCode user MCP is ~/.zcode/cli/config.json (mcp.servers). Project MCP is .zcode/mcp.json. Hooks live in .zcode/config.json and .zcode/hooks.json. Do not put PalamHealth in the user file.",
},
"antigravity-cli": {
id: "antigravity-cli",
name: "Antigravity CLI",
kind: "hybrid",
- nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ nativeEngineSupport: { mcp: false, skills: true, rules: true, agents: true, hooks: false },
supportsInteractiveApps: true,
supportsNativeBundles: true,
notes:
- "Antigravity CLI supports workspace .agents customizations and native plugins that can bundle skills, agents, rules, MCP servers, and hooks.",
+ "Antigravity CLI adapters cover workspace .agents skills, agents, and rules. There is no MCP or hooks adapter.",
},
windsurf: {
id: "windsurf",
@@ -117,6 +117,61 @@ export const KNOWN_PLUGIN_HOSTS: Record = {
supportsNativeBundles: false,
notes: "Kiro exposes multiple config surfaces that ai-tools can target directly.",
},
+ cline: {
+ id: "cline",
+ name: "Cline",
+ kind: "desktop",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "Cline adapters cover hooks, MCP, agents, skills, and rules.",
+ },
+ droid: {
+ id: "droid",
+ name: "Factory Droid",
+ kind: "cli",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes:
+ "Factory Droid adapters write .factory configs for hooks, MCP, agents, skills, and rules.",
+ },
+ amp: {
+ id: "amp",
+ name: "Amp",
+ kind: "cli",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: false, hooks: false },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "Amp adapters cover MCP, skills, and rules. There is no agents or hooks adapter.",
+ },
+ continue: {
+ id: "continue",
+ name: "Continue",
+ kind: "desktop",
+ nativeEngineSupport: { mcp: false, skills: true, rules: true, agents: false, hooks: false },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "Continue adapters cover skills and rules only.",
+ },
+ "gemini-cli": {
+ id: "gemini-cli",
+ name: "Gemini CLI",
+ kind: "cli",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: true },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "Gemini CLI adapters cover hooks, MCP, agents, skills, and rules.",
+ },
+ "roo-code": {
+ id: "roo-code",
+ name: "Roo Code",
+ kind: "desktop",
+ nativeEngineSupport: { mcp: true, skills: true, rules: true, agents: true, hooks: false },
+ supportsInteractiveApps: false,
+ supportsNativeBundles: false,
+ notes: "Roo Code adapters cover MCP, agents, skills, and rules. There is no hooks adapter.",
+ },
};
export function getPluginHostInfo(id: PluginToolId): PluginHostInfo {
diff --git a/packages/cli/src/plugins/install.test.ts b/packages/cli/src/plugins/install.test.ts
index cab2833..14667f7 100644
--- a/packages/cli/src/plugins/install.test.ts
+++ b/packages/cli/src/plugins/install.test.ts
@@ -141,6 +141,311 @@ describe("installPluginBundle", () => {
]);
});
+ it("installs only project-layer MCP servers whose whenPathContains matches cwd", async () => {
+ const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]);
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => mcpAdapter },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => undefined },
+ });
+ const cwdSpy = vi
+ .spyOn(process, "cwd")
+ .mockReturnValue("/home/blitz/Development/PremierStudio/web");
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle(
+ {
+ ...plugin,
+ mcpServers: [
+ {
+ id: "github",
+ name: "GitHub",
+ transport: { type: "stdio", command: "gh-mcp" },
+ },
+ {
+ id: "palamhealth",
+ name: "PalamHealth",
+ transport: { type: "stdio", command: "palamhealth-mcp" },
+ layer: "project",
+ whenPathContains: ["PalamHealth"],
+ },
+ {
+ id: "unifi",
+ name: "UniFi",
+ transport: { type: "stdio", command: "unifi-mcp" },
+ layer: "user",
+ },
+ ],
+ },
+ { tools: ["codex"] },
+ );
+
+ cwdSpy.mockRestore();
+ expect(mcpAdapter.generate).toHaveBeenCalledWith([expect.objectContaining({ id: "github" })]);
+ expect(mcpAdapter.generate.mock.calls[0]?.[0]).toHaveLength(1);
+ expect(result.installed).toHaveLength(1);
+ });
+
+ it("installs PalamHealth servers only when cwd contains a required path fragment", async () => {
+ const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]);
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => mcpAdapter },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => undefined },
+ });
+ const cwdSpy = vi
+ .spyOn(process, "cwd")
+ .mockReturnValue("/home/blitz/Development/PalamHealth/PalamHealth");
+ const { installPluginBundle } = await import("./install.js");
+
+ await installPluginBundle(
+ {
+ ...plugin,
+ mcpServers: [
+ {
+ id: "palamhealth",
+ name: "PalamHealth",
+ transport: { type: "stdio", command: "palamhealth-mcp" },
+ layer: "project",
+ whenPathContains: ["PalamHealth", "palamhealth"],
+ },
+ ],
+ },
+ { tools: ["codex"] },
+ );
+
+ cwdSpy.mockRestore();
+ expect(mcpAdapter.generate).toHaveBeenCalledWith([
+ expect.objectContaining({ id: "palamhealth" }),
+ ]);
+ });
+
+ it("skips MCP install instead of writing an empty config when every server is filtered out", async () => {
+ const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }]);
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => mcpAdapter },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => undefined },
+ });
+ const cwdSpy = vi
+ .spyOn(process, "cwd")
+ .mockReturnValue("/home/blitz/Development/PremierStudio/web");
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle(
+ {
+ ...plugin,
+ mcpServers: [
+ {
+ id: "palamhealth",
+ name: "PalamHealth",
+ transport: { type: "stdio", command: "palamhealth-mcp" },
+ layer: "project",
+ whenPathContains: ["PalamHealth"],
+ },
+ ],
+ },
+ { tools: ["codex"] },
+ );
+
+ cwdSpy.mockRestore();
+ expect(mcpAdapter.generate).not.toHaveBeenCalled();
+ expect(mcpAdapter.install).not.toHaveBeenCalled();
+ expect(result.installed).toEqual([]);
+ });
+
+ it("installs rules, agents, and hooks when those engines are ready", async () => {
+ const rulesAdapter = adapter([{ path: ".codex/rules.md", content: "tdd", format: "md" }]);
+ const agentsAdapter = adapter([{ path: ".codex/agents/qa.md", content: "qa", format: "md" }]);
+ const hooksAdapter = adapter([{ path: ".codex/hooks.json", content: "{}", format: "json" }]);
+ buildPluginInstallPlan.mockResolvedValue({
+ ...plan,
+ requestedEngines: ["rules", "agents", "hooks"],
+ targets: [
+ {
+ ...readyTarget,
+ engines: [
+ {
+ engine: "rules",
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ },
+ {
+ engine: "agents",
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ },
+ {
+ engine: "hooks",
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ },
+ ],
+ },
+ ],
+ });
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => undefined },
+ skills: { get: () => undefined },
+ rules: { get: () => rulesAdapter },
+ agents: { get: () => agentsAdapter },
+ hooks: { get: () => hooksAdapter },
+ });
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle({
+ ...plugin,
+ rules: [{ id: "tdd", name: "TDD", content: "Test first.", scope: { type: "always" } }],
+ agents: [{ id: "qa", name: "QA", instructions: "Find gaps." }],
+ });
+
+ expect(result.failed).toEqual([]);
+ expect(result.installed.map((item) => item.engine)).toEqual(["rules", "agents", "hooks"]);
+ expect(rulesAdapter.generate).toHaveBeenCalled();
+ expect(agentsAdapter.generate).toHaveBeenCalled();
+ expect(hooksAdapter.generate).toHaveBeenCalled();
+ });
+
+ it("records a missing adapter for every engine", async () => {
+ buildPluginInstallPlan.mockResolvedValue({
+ ...plan,
+ requestedEngines: ["mcp", "skills", "rules", "agents", "hooks"],
+ targets: [
+ {
+ ...readyTarget,
+ engines: ["mcp", "skills", "rules", "agents", "hooks"].map((engine) => ({
+ engine,
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ })),
+ },
+ ],
+ });
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => undefined },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => undefined },
+ });
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle(plugin);
+
+ expect(result.installed).toEqual([]);
+ expect(result.failed.map((item) => item.error)).toEqual([
+ "Missing MCP adapter for codex",
+ "Missing skills adapter for codex",
+ "Missing rules adapter for codex",
+ "Missing agents adapter for codex",
+ "Missing hooks adapter for codex",
+ ]);
+ });
+
+ it("records missing adapters as failures without aborting later engines", async () => {
+ const hooksAdapter = adapter([{ path: ".codex/hooks.json", content: "{}", format: "json" }]);
+ buildPluginInstallPlan.mockResolvedValue({
+ ...plan,
+ requestedEngines: ["rules", "hooks"],
+ targets: [
+ {
+ ...readyTarget,
+ engines: [
+ {
+ engine: "rules",
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ },
+ {
+ engine: "hooks",
+ requested: true,
+ nativeHostSupport: true,
+ adapterAvailable: true,
+ detected: true,
+ status: "ready",
+ },
+ ],
+ },
+ ],
+ });
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => undefined },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => hooksAdapter },
+ });
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle(plugin);
+
+ expect(result.failed).toEqual([
+ {
+ toolId: "codex",
+ toolName: "Codex",
+ engine: "rules",
+ error: "Missing rules adapter for codex",
+ },
+ ]);
+ expect(result.installed).toEqual([
+ {
+ toolId: "codex",
+ toolName: "Codex",
+ engine: "hooks",
+ filePaths: [".codex/hooks.json"],
+ },
+ ]);
+ });
+
+ it("stringifies non-Error install failures", async () => {
+ const mcpAdapter = {
+ id: "codex",
+ detect: vi.fn(),
+ generate: vi
+ .fn()
+ .mockResolvedValue([{ path: ".codex/mcp.json", content: "{}", format: "json" }]),
+ install: vi.fn().mockRejectedValue("disk full"),
+ };
+ loadEngineRegistries.mockResolvedValue({
+ mcp: { get: () => mcpAdapter },
+ skills: { get: () => undefined },
+ rules: { get: () => undefined },
+ agents: { get: () => undefined },
+ hooks: { get: () => undefined },
+ });
+ const { installPluginBundle } = await import("./install.js");
+
+ const result = await installPluginBundle(plugin);
+
+ expect(result.failed).toContainEqual({
+ toolId: "codex",
+ toolName: "Codex",
+ engine: "mcp",
+ error: "disk full",
+ });
+ });
+
it("records install failures without aborting the bundle", async () => {
const mcpAdapter = adapter([{ path: ".codex/mcp.json", content: "{}", format: "json" }], true);
const skillsAdapter = adapter([
diff --git a/packages/cli/src/plugins/install.ts b/packages/cli/src/plugins/install.ts
index 0b53452..3471f13 100644
--- a/packages/cli/src/plugins/install.ts
+++ b/packages/cli/src/plugins/install.ts
@@ -6,12 +6,28 @@ import type {
InstallPluginOptions,
InstallPluginResult,
PluginEngine,
+ PluginMCPServerDefinition,
} from "./types.js";
+type PluginLayer = NonNullable;
+
function uniquePaths(paths: string[]): string[] {
return [...new Set(paths)];
}
+function filterPluginServers(
+ servers: PluginMCPServerDefinition[],
+ ctx: { layer: PluginLayer; cwd: string },
+): PluginMCPServerDefinition[] {
+ return servers.filter((server) => {
+ const layer = server.layer ?? "project";
+ if (layer !== ctx.layer) return false;
+ const fragments = server.whenPathContains ?? [];
+ if (fragments.length === 0) return true;
+ return fragments.some((fragment) => ctx.cwd.includes(fragment));
+ });
+}
+
async function installEngine(
registries: Awaited>,
plugin: AiPluginDefinition,
@@ -22,7 +38,12 @@ async function installEngine(
case "mcp": {
const adapter = registries.mcp.get(toolId);
if (!adapter) throw new Error(`Missing MCP adapter for ${toolId}`);
- const files = await adapter.generate(plugin.mcpServers ?? []);
+ const servers = filterPluginServers(plugin.mcpServers ?? [], {
+ layer: "project",
+ cwd: process.cwd(),
+ });
+ if (servers.length === 0) return [];
+ const files = await adapter.generate(servers);
await adapter.install(files);
return files.map((file) => file.path);
}
@@ -77,6 +98,7 @@ export async function installPluginBundle(
const filePaths = uniquePaths(
await installEngine(registries, plugin, engine.engine, target.toolId),
);
+ if (filePaths.length === 0) continue;
installed.push({
toolId: target.toolId,
toolName: target.toolName,
diff --git a/packages/cli/src/plugins/plan.test.ts b/packages/cli/src/plugins/plan.test.ts
index e0fb325..be81aa2 100644
--- a/packages/cli/src/plugins/plan.test.ts
+++ b/packages/cli/src/plugins/plan.test.ts
@@ -152,4 +152,59 @@ describe("buildPluginInstallPlan", () => {
const target = plan.targets[0];
expect(target?.engines.find((item) => item.engine === "agents")?.status).toBe("ready");
});
+
+ it("marks zcode hooks as adapter-missing until the hooks registry lists zcode", async () => {
+ const plan = await buildPluginInstallPlan(
+ {
+ id: "cert-coach",
+ name: "Certification Coach",
+ version: "0.1.0",
+ hooks: [
+ {
+ id: "audit",
+ name: "Audit",
+ events: ["prompt:submit"],
+ phase: "before",
+ handler: async () => {},
+ },
+ ],
+ },
+ {
+ tools: ["zcode"],
+ force: true,
+ detectedToolsOverride: ["zcode"],
+ },
+ );
+
+ expect(plan.targets[0]?.host.nativeEngineSupport.hooks).toBe(true);
+ expect(plan.targets[0]?.engines.find((item) => item.engine === "hooks")?.status).toBe(
+ "adapter-missing",
+ );
+ });
+
+ it("marks antigravity-cli MCP as host-unsupported because no MCP adapter exists", async () => {
+ const plan = await buildPluginInstallPlan(
+ {
+ id: "cert-coach",
+ name: "Certification Coach",
+ version: "0.1.0",
+ mcpServers: [
+ {
+ id: "cert-coach",
+ name: "Certification Coach",
+ transport: { type: "stdio", command: "npx" },
+ },
+ ],
+ },
+ {
+ tools: ["antigravity-cli"],
+ force: true,
+ detectedToolsOverride: ["antigravity-cli"],
+ },
+ );
+
+ expect(plan.targets[0]?.engines.find((item) => item.engine === "mcp")?.status).toBe(
+ "host-unsupported",
+ );
+ });
});
diff --git a/packages/hooks/src/adapters/all.ts b/packages/hooks/src/adapters/all.ts
index 96cd992..2896d83 100644
--- a/packages/hooks/src/adapters/all.ts
+++ b/packages/hooks/src/adapters/all.ts
@@ -8,3 +8,4 @@ import "./gemini-cli.js";
import "./grok.js";
import "./kiro.js";
import "./opencode.js";
+import "./zcode.js";
diff --git a/packages/hooks/src/adapters/claude-compat.test.ts b/packages/hooks/src/adapters/claude-compat.test.ts
new file mode 100644
index 0000000..e452251
--- /dev/null
+++ b/packages/hooks/src/adapters/claude-compat.test.ts
@@ -0,0 +1,275 @@
+import { describe, expect, it } from "vitest";
+import {
+ asRecord,
+ buildCompatEvent,
+ collectNativeEvents,
+ isBlockingNativeEvent,
+ normalizeNativeEvent,
+ parseHookGroups,
+ payloadCwd,
+ payloadEventName,
+ payloadTimestamp,
+ payloadToolInput,
+ phaseForNativeEvent,
+ stringField,
+} from "./claude-compat.js";
+import type { HookDefinition } from "../types/index.js";
+
+describe("claude-compat helpers", () => {
+ it("normalizes snake_case, camelCase, and PascalCase event names", () => {
+ expect(normalizeNativeEvent("pre_tool_use")).toBe("PreToolUse");
+ expect(normalizeNativeEvent("PreToolUse")).toBe("PreToolUse");
+ expect(normalizeNativeEvent("userPromptSubmit")).toBe("UserPromptSubmit");
+ expect(normalizeNativeEvent("")).toBe("");
+ expect(normalizeNativeEvent("_pre__tool_use")).toBe("PreToolUse");
+ expect(payloadEventName({ hook_event_name: "session_start" })).toBe("SessionStart");
+ expect(payloadEventName({ hookEventName: "" }, "session_start")).toBe("SessionStart");
+ expect(payloadEventName({}, "session_start")).toBe("SessionStart");
+ });
+
+ it("coerces payload fields and timestamps", () => {
+ expect(asRecord(null)).toEqual({});
+ expect(asRecord([1])).toEqual({});
+ expect(asRecord({ a: 1 })).toEqual({ a: 1 });
+ expect(stringField(3, "fallback")).toBe("fallback");
+ expect(payloadTimestamp({ timestamp: 42 })).toBe(42);
+ expect(payloadTimestamp({ timestamp: "2020-01-01T00:00:00.000Z" })).toBe(
+ Date.parse("2020-01-01T00:00:00.000Z"),
+ );
+ expect(payloadTimestamp({ timestamp: "not-a-date" })).toBeTypeOf("number");
+ expect(payloadToolInput({ tool_input: { command: "x" } })).toEqual({ command: "x" });
+ expect(payloadToolInput({})).toEqual({});
+ expect(payloadCwd({ workspaceRoot: "/ws" })).toBe("/ws");
+ expect(phaseForNativeEvent("PostToolUse")).toBe("after");
+ expect(isBlockingNativeEvent("PermissionRequest")).toBe(true);
+ });
+
+ it("builds shell:before from Grok run_terminal_command PreToolUse", () => {
+ const event = buildCompatEvent(
+ "PreToolUse",
+ {
+ toolName: "run_terminal_command",
+ toolInput: { command: "npm test" },
+ cwd: "/tmp/proj",
+ },
+ "grok",
+ );
+ expect(event?.type).toBe("shell:before");
+ if (event?.type === "shell:before") {
+ expect(event.command).toBe("npm test");
+ expect(event.cwd).toBe("/tmp/proj");
+ }
+ });
+
+ it("builds file:write / file:edit / mcp:before from tool names", () => {
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "Write", toolInput: { file_path: "a.ts" } },
+ "zcode",
+ )?.type,
+ ).toBe("file:write");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "search_replace", toolInput: { path: "a.ts" } },
+ "grok",
+ )?.type,
+ ).toBe("file:edit");
+ const mcp = buildCompatEvent(
+ "PreToolUse",
+ { toolName: "linear__save_issue", toolInput: { title: "x" } },
+ "grok",
+ );
+ expect(mcp?.type).toBe("mcp:before");
+ if (mcp?.type === "mcp:before") {
+ expect(mcp.server).toBe("linear");
+ expect(mcp.method).toBe("save_issue");
+ }
+ });
+
+ it("collects sorted unique native events", () => {
+ const hooks: HookDefinition[] = [
+ {
+ id: "a",
+ name: "a",
+ events: ["file:write", "shell:before"],
+ phase: "before",
+ handler: async (_ctx, next) => {
+ await next();
+ },
+ },
+ ];
+ expect(collectNativeEvents(hooks, () => ["PreToolUse", "PreToolUse"])).toEqual(["PreToolUse"]);
+ });
+
+ it("parses matcher groups into hook definitions", async () => {
+ const defs = parseHookGroups(
+ [{ hooks: [{ type: "command", command: "echo hi", statusMessage: "hi" }] }],
+ "PreToolUse",
+ () => ["shell:before"],
+ "zcode",
+ );
+ expect(defs).toHaveLength(1);
+ expect(defs[0]?.id).toBe("zcode-PreToolUse-0-0");
+ expect(defs[0]?.name).toBe("echo hi");
+ expect(defs[0]?.description).toBe("hi");
+ expect(isBlockingNativeEvent("PreToolUse")).toBe(true);
+ expect(isBlockingNativeEvent("PostToolUse")).toBe(false);
+ const ran = { value: false };
+ await defs[0]!.handler(
+ {
+ event: {
+ type: "shell:before",
+ command: "echo hi",
+ cwd: "/tmp",
+ timestamp: 0,
+ metadata: {},
+ },
+ tool: { name: "zcode", version: "1.0" },
+ cwd: "/tmp",
+ state: new Map(),
+ results: [],
+ startedAt: 0,
+ },
+ async () => {
+ ran.value = true;
+ },
+ );
+ expect(ran.value).toBe(true);
+ });
+
+ it("skips malformed matcher groups", () => {
+ expect(parseHookGroups({}, "PreToolUse", () => ["shell:before"], "x")).toEqual([]);
+ expect(
+ parseHookGroups(
+ [{ hooks: [{ type: "http", url: "https://x" }] }],
+ "PreToolUse",
+ () => ["shell:before"],
+ "x",
+ ),
+ ).toEqual([]);
+ expect(
+ parseHookGroups([{ matcher: "Bash" }], "PreToolUse", () => ["shell:before"], "x"),
+ ).toEqual([]);
+ expect(parseHookGroups([{ hooks: [{ command: "echo" }] }], "Nope", () => [], "x")).toEqual([]);
+ });
+
+ it("builds the remaining lifecycle and tool events", () => {
+ expect(buildCompatEvent("SessionStart", { cwd: "/tmp" }, "grok")?.type).toBe("session:start");
+ expect(buildCompatEvent("SessionEnd", {}, "grok")?.type).toBe("session:end");
+ expect(buildCompatEvent("UserPromptSubmit", { prompt: "hi" }, "grok")?.type).toBe(
+ "prompt:submit",
+ );
+ expect(
+ buildCompatEvent("UserPromptSubmit", { tool_input: { prompt: "from-input" } }, "zcode"),
+ ).toMatchObject({ type: "prompt:submit", prompt: "from-input" });
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "run_terminal_command", toolInput: { command: "ls", cwd: "/x" } },
+ "grok",
+ )?.type,
+ ).toBe("shell:before");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "MultiEdit", toolInput: { path: "a.ts" } },
+ "zcode",
+ )?.type,
+ ).toBe("file:edit");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "delete_file", toolInput: { path: "a.ts" } },
+ "grok",
+ )?.type,
+ ).toBe("file:delete");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "ApplyPatch", toolInput: { path: "a.ts" } },
+ "zcode",
+ )?.type,
+ ).toBe("file:edit");
+ expect(
+ buildCompatEvent("Notification", { notificationType: "idle_prompt" }, "grok")?.type,
+ ).toBe("notification");
+ expect(
+ buildCompatEvent(
+ "PermissionRequest",
+ { toolName: "Bash", toolInput: { command: "ls" } },
+ "zcode",
+ )?.type,
+ ).toBe("shell:before");
+ expect(
+ buildCompatEvent("PreToolUse", { toolName: "Delete", toolInput: { path: "a.ts" } }, "zcode")
+ ?.type,
+ ).toBe("file:delete");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "read_file", toolInput: { target_file: "a.ts" } },
+ "grok",
+ )?.type,
+ ).toBe("file:read");
+ expect(
+ buildCompatEvent(
+ "PreToolUse",
+ { toolName: "Edit", toolInput: { oldContent: "a", newContent: "b" } },
+ "zcode",
+ )?.type,
+ ).toBe("file:edit");
+ expect(buildCompatEvent("PreToolUse", {}, "grok")?.type).toBe("tool:before");
+ const mcp = buildCompatEvent(
+ "PreToolUse",
+ {
+ toolName: "CallMcpTool",
+ toolInput: { server: "linear", method: "save", params: { id: 1 } },
+ },
+ "grok",
+ );
+ expect(mcp?.type).toBe("mcp:before");
+ expect(
+ buildCompatEvent(
+ "PostToolUse",
+ {
+ toolName: "linear__save_issue",
+ toolResult: { server: "linear", method: "save_issue" },
+ },
+ "grok",
+ )?.type,
+ ).toBe("mcp:after");
+ expect(
+ buildCompatEvent(
+ "PostToolUse",
+ {
+ toolName: "run_terminal_cmd",
+ toolInput: { command: "ls" },
+ tool_response: { exitCode: 1, stdout: "out", stderr: "err" },
+ },
+ "grok",
+ )?.type,
+ ).toBe("shell:after");
+ expect(
+ buildCompatEvent(
+ "PostToolUse",
+ { toolName: "Bash", toolInput: { command: "ls" }, toolResult: { stdout: "ok" } },
+ "zcode",
+ )?.type,
+ ).toBe("shell:after");
+ expect(buildCompatEvent("PostToolUse", { toolName: "__", toolResult: [] }, "grok")?.type).toBe(
+ "mcp:after",
+ );
+ expect(buildCompatEvent("PostToolUse", {}, "zcode")?.type).toBe("tool:after");
+ expect(buildCompatEvent("PostToolUseFailure", { toolName: "Read" }, "zcode")?.type).toBe(
+ "tool:after",
+ );
+ expect(buildCompatEvent("Stop", { lastAssistantMessage: "done" }, "zcode")?.type).toBe(
+ "prompt:response",
+ );
+ expect(buildCompatEvent("Stop", {}, "zcode")?.type).toBe("session:end");
+ const unnamed = buildCompatEvent("PreToolUse", { toolName: "__" }, "grok");
+ expect(unnamed?.type).toBe("mcp:before");
+ });
+});
diff --git a/packages/hooks/src/adapters/claude-compat.ts b/packages/hooks/src/adapters/claude-compat.ts
new file mode 100644
index 0000000..ee71dc8
--- /dev/null
+++ b/packages/hooks/src/adapters/claude-compat.ts
@@ -0,0 +1,372 @@
+import type { HookDefinition, HookEvent, HookEventType } from "../types/index.js";
+
+export type CompatPayload = {
+ hookEventName?: string;
+ hook_event_name?: string;
+ toolName?: string;
+ tool_name?: string;
+ toolInput?: Record;
+ tool_input?: Record;
+ toolResult?: unknown;
+ tool_response?: unknown;
+ cwd?: string;
+ workspaceRoot?: string;
+ prompt?: string;
+ timestamp?: string | number;
+ [key: string]: unknown;
+};
+
+export type NativeCommandHook = {
+ type?: string;
+ command?: string;
+ url?: string;
+ timeout?: number;
+ statusMessage?: string;
+};
+
+export type NativeHookGroup = {
+ matcher?: string;
+ hooks?: NativeCommandHook[];
+};
+
+export function collectNativeEvents(
+ hooks: HookDefinition[],
+ mapEvent: (event: HookEventType) => string[],
+): string[] {
+ const events = new Set();
+ for (const hook of hooks) {
+ for (const event of hook.events) {
+ for (const native of mapEvent(event)) events.add(native);
+ }
+ }
+ return [...events].toSorted();
+}
+
+export function normalizeNativeEvent(raw: string): string {
+ const trimmed = raw.trim();
+ if (!trimmed) return "";
+ if (trimmed.includes("_")) {
+ return trimmed
+ .split("_")
+ .filter((part) => part.length > 0)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
+ .join("");
+ }
+ return trimmed.charAt(0).toUpperCase() + trimmed.slice(1);
+}
+
+export function payloadEventName(payload: CompatPayload, envEvent?: string): string {
+ const raw =
+ (typeof payload.hookEventName === "string" && payload.hookEventName) ||
+ (typeof payload.hook_event_name === "string" && payload.hook_event_name) ||
+ envEvent ||
+ "";
+ return normalizeNativeEvent(raw);
+}
+
+export function asRecord(value: unknown): Record {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return {};
+}
+
+export function stringField(value: unknown, fallback = ""): string {
+ return typeof value === "string" ? value : fallback;
+}
+
+export function payloadTimestamp(payload: CompatPayload): number {
+ if (typeof payload.timestamp === "number" && Number.isFinite(payload.timestamp)) {
+ return payload.timestamp;
+ }
+ if (typeof payload.timestamp === "string") {
+ const parsed = Date.parse(payload.timestamp);
+ if (Number.isFinite(parsed)) return parsed;
+ }
+ return Date.now();
+}
+
+export function payloadToolName(payload: CompatPayload): string {
+ return stringField(payload.toolName) || stringField(payload.tool_name);
+}
+
+export function payloadToolInput(payload: CompatPayload): Record {
+ if (payload.toolInput !== undefined) return asRecord(payload.toolInput);
+ if (payload.tool_input !== undefined) return asRecord(payload.tool_input);
+ return {};
+}
+
+export function payloadCwd(payload: CompatPayload): string {
+ return stringField(payload.cwd) || stringField(payload.workspaceRoot) || process.cwd();
+}
+
+export function isMcpToolName(toolName: string): boolean {
+ return toolName.includes("__") || toolName === "CallMcpTool" || toolName === "use_tool";
+}
+
+export function buildCompatEvent(
+ nativeEvent: string,
+ payload: CompatPayload,
+ tool: string,
+): HookEvent | null {
+ const timestamp = payloadTimestamp(payload);
+ const metadata: Record = { nativeEvent, payload };
+ const cwd = payloadCwd(payload);
+ const toolName = payloadToolName(payload);
+ const toolInput = payloadToolInput(payload);
+ const toolResult = payload.toolResult ?? payload.tool_response ?? {};
+
+ switch (nativeEvent) {
+ case "SessionStart":
+ return {
+ type: "session:start",
+ tool,
+ version: "1.0",
+ workingDirectory: cwd,
+ timestamp,
+ metadata,
+ };
+ case "SessionEnd":
+ return {
+ type: "session:end",
+ tool,
+ duration: 0,
+ timestamp,
+ metadata,
+ };
+ case "UserPromptSubmit":
+ return {
+ type: "prompt:submit",
+ prompt: stringField(payload.prompt) || stringField(toolInput.prompt),
+ timestamp,
+ metadata,
+ };
+ case "Notification":
+ return {
+ type: "notification",
+ level: "info",
+ message: stringField(payload.message) || stringField(payload.notificationType),
+ timestamp,
+ metadata,
+ };
+ case "PreToolUse":
+ case "PermissionRequest":
+ return resolvePreToolUse(toolName, toolInput, cwd, timestamp, metadata);
+ case "PostToolUse":
+ case "PostToolUseFailure":
+ return resolvePostToolUse(toolName, toolInput, toolResult, cwd, timestamp, metadata);
+ case "Stop":
+ if (typeof payload.lastAssistantMessage === "string" && payload.lastAssistantMessage) {
+ return {
+ type: "prompt:response",
+ response: payload.lastAssistantMessage,
+ model: tool,
+ tokens: { input: 0, output: 0 },
+ timestamp,
+ metadata,
+ };
+ }
+ return {
+ type: "session:end",
+ tool,
+ duration: 0,
+ timestamp,
+ metadata,
+ };
+ default:
+ return null;
+ }
+}
+
+export function isBlockingNativeEvent(nativeEvent: string): boolean {
+ return (
+ nativeEvent === "PreToolUse" ||
+ nativeEvent === "UserPromptSubmit" ||
+ nativeEvent === "PermissionRequest"
+ );
+}
+
+export function phaseForNativeEvent(nativeEvent: string): "before" | "after" {
+ if (
+ nativeEvent === "PreToolUse" ||
+ nativeEvent === "UserPromptSubmit" ||
+ nativeEvent === "PermissionRequest" ||
+ nativeEvent === "SessionStart"
+ ) {
+ return "before";
+ }
+ return "after";
+}
+
+export function parseHookGroups(
+ groups: unknown,
+ nativeEvent: string,
+ mapNativeEvent: (native: string) => HookEventType[],
+ source: string,
+): HookDefinition[] {
+ if (!Array.isArray(groups)) return [];
+ const events = mapNativeEvent(nativeEvent);
+ if (events.length === 0) return [];
+ const phase = phaseForNativeEvent(nativeEvent);
+ const definitions: HookDefinition[] = [];
+
+ groups.forEach((group, groupIndex) => {
+ const typed = asRecord(group);
+ const hooks = typed.hooks;
+ if (!Array.isArray(hooks)) return;
+ hooks.forEach((hook, hookIndex) => {
+ const commandHook = asRecord(hook);
+ const command = stringField(commandHook.command);
+ if (!command) return;
+ definitions.push({
+ id: `${source}-${nativeEvent}-${groupIndex}-${hookIndex}`,
+ name: command,
+ description: stringField(commandHook.statusMessage) || `${source} ${nativeEvent}`,
+ events,
+ phase,
+ handler: async (_ctx, next) => {
+ await next();
+ },
+ });
+ });
+ });
+
+ return definitions;
+}
+
+function resolvePreToolUse(
+ toolName: string,
+ toolInput: Record,
+ cwd: string,
+ timestamp: number,
+ metadata: Record,
+): HookEvent {
+ if (isMcpToolName(toolName)) {
+ const parts = toolName.split("__");
+ return {
+ type: "mcp:before",
+ server: stringField(toolInput.server) || parts[0] || "unknown",
+ method: stringField(toolInput.method) || parts[1] || toolName,
+ params: toolInput.params !== undefined ? asRecord(toolInput.params) : toolInput,
+ timestamp,
+ metadata,
+ };
+ }
+
+ switch (toolName) {
+ case "Bash":
+ case "run_terminal_command":
+ case "run_terminal_cmd":
+ return {
+ type: "shell:before",
+ command: stringField(toolInput.command),
+ cwd: stringField(toolInput.cwd, cwd),
+ timestamp,
+ metadata,
+ };
+ case "Write":
+ return {
+ type: "file:write",
+ path: filePathFrom(toolInput),
+ content: stringField(toolInput.content),
+ timestamp,
+ metadata,
+ };
+ case "Edit":
+ case "MultiEdit":
+ case "search_replace":
+ case "ApplyPatch":
+ return {
+ type: "file:edit",
+ path: filePathFrom(toolInput),
+ oldContent: stringField(toolInput.old_string) || stringField(toolInput.oldContent),
+ newContent: stringField(toolInput.new_string) || stringField(toolInput.newContent),
+ timestamp,
+ metadata,
+ };
+ case "Delete":
+ case "delete_file":
+ return {
+ type: "file:delete",
+ path: filePathFrom(toolInput),
+ timestamp,
+ metadata,
+ };
+ case "Read":
+ case "read_file":
+ return {
+ type: "file:read",
+ path: filePathFrom(toolInput),
+ timestamp,
+ metadata,
+ };
+ default:
+ return {
+ type: "tool:before",
+ toolName: toolName || "unknown",
+ input: toolInput,
+ timestamp,
+ metadata,
+ };
+ }
+}
+
+function resolvePostToolUse(
+ toolName: string,
+ toolInput: Record,
+ toolResult: unknown,
+ cwd: string,
+ timestamp: number,
+ metadata: Record,
+): HookEvent {
+ if (isMcpToolName(toolName)) {
+ const parts = toolName.split("__");
+ return {
+ type: "mcp:after",
+ server: stringField(asRecord(toolResult).server) || parts[0] || "unknown",
+ method: stringField(asRecord(toolResult).method) || parts[1] || toolName,
+ params: toolInput,
+ result: toolResult,
+ duration: 0,
+ timestamp,
+ metadata,
+ };
+ }
+
+ if (
+ toolName === "Bash" ||
+ toolName === "run_terminal_command" ||
+ toolName === "run_terminal_cmd"
+ ) {
+ const result = asRecord(toolResult);
+ return {
+ type: "shell:after",
+ command: stringField(toolInput.command),
+ cwd: stringField(toolInput.cwd, cwd),
+ exitCode: typeof result.exitCode === "number" ? result.exitCode : 0,
+ stdout: stringField(result.stdout),
+ stderr: stringField(result.stderr),
+ duration: 0,
+ timestamp,
+ metadata,
+ };
+ }
+
+ return {
+ type: "tool:after",
+ toolName: toolName || "unknown",
+ input: toolInput,
+ output: toolResult,
+ duration: 0,
+ timestamp,
+ metadata,
+ };
+}
+
+function filePathFrom(toolInput: Record): string {
+ return (
+ stringField(toolInput.file_path) ||
+ stringField(toolInput.path) ||
+ stringField(toolInput.target_file)
+ );
+}
diff --git a/packages/hooks/src/adapters/grok-runner.test.ts b/packages/hooks/src/adapters/grok-runner.test.ts
new file mode 100644
index 0000000..15269ca
--- /dev/null
+++ b/packages/hooks/src/adapters/grok-runner.test.ts
@@ -0,0 +1,248 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { parseCompatPayload, readStdinText, runGrokHook } from "./grok-runner.js";
+import type { AiHooksConfig, HookDefinition } from "../types/index.js";
+
+const temps: string[] = [];
+
+class NonErrorFailure {
+ toString(): string {
+ return "non-error";
+ }
+}
+
+async function* stdinChunks() {
+ yield Buffer.from('{"hookEventName":"');
+ yield 'SessionStart"}';
+}
+
+afterEach(async () => {
+ await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+async function tempDir(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "grok-runner-"));
+ temps.push(dir);
+ return dir;
+}
+
+function markerHook(markerPath: string): HookDefinition {
+ return {
+ id: "write-marker",
+ name: "write-marker",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ if (ctx.event.type === "shell:before") {
+ await writeFile(markerPath, ctx.event.command, "utf-8");
+ }
+ await next();
+ },
+ };
+}
+
+function blockHook(): HookDefinition {
+ return {
+ id: "block-rm",
+ name: "block-rm",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ if (ctx.event.type === "shell:before" && ctx.event.command.includes("rm -rf /")) {
+ ctx.results.push({ blocked: true, reason: "Dangerous command" });
+ return;
+ }
+ await next();
+ },
+ };
+}
+
+describe("runGrokHook", () => {
+ it("runs a configured hook that writes a file when Grok invokes the runner", async () => {
+ const dir = await tempDir();
+ const marker = join(dir, "ran.txt");
+ const config: AiHooksConfig = { hooks: [markerHook(marker)] };
+
+ const code = await runGrokHook({
+ config,
+ stdin: JSON.stringify({
+ hookEventName: "pre_tool_use",
+ toolName: "run_terminal_command",
+ toolInput: { command: "echo from-grok" },
+ cwd: dir,
+ }),
+ });
+
+ expect(code).toBe(0);
+ expect(await readFile(marker, "utf-8")).toBe("echo from-grok");
+ });
+
+ it("loads ai-hooks.config.js and runs its hook when no config is passed", async () => {
+ const dir = await tempDir();
+ const marker = join(dir, "from-config.txt");
+ await writeFile(
+ join(dir, "ai-hooks.config.js"),
+ `import { writeFile } from "node:fs/promises";
+export default {
+ hooks: [{
+ id: "from-file",
+ name: "from-file",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ if (ctx.event.type === "shell:before") {
+ await writeFile(${JSON.stringify(marker)}, ctx.event.command, "utf8");
+ }
+ await next();
+ },
+ }],
+};
+`,
+ "utf-8",
+ );
+
+ const code = await runGrokHook({
+ cwd: dir,
+ stdin: JSON.stringify({
+ hookEventName: "PreToolUse",
+ toolName: "Bash",
+ tool_input: { command: "echo loaded" },
+ }),
+ });
+
+ expect(code).toBe(0);
+ expect(await readFile(marker, "utf-8")).toBe("echo loaded");
+ });
+
+ it("denies a blocking PreToolUse hook with Grok decision JSON and exit 2", async () => {
+ const chunks: string[] = [];
+ const code = await runGrokHook({
+ config: { hooks: [blockHook()] },
+ stdin: JSON.stringify({
+ hookEventName: "pre_tool_use",
+ toolName: "run_terminal_command",
+ toolInput: { command: "rm -rf /" },
+ }),
+ stdout: { write: (chunk: string) => chunks.push(chunk) },
+ });
+
+ expect(code).toBe(2);
+ expect(JSON.parse(chunks.join(""))).toEqual({
+ decision: "deny",
+ reason: "Dangerous command",
+ });
+ });
+
+ it("uses GROK_HOOK_EVENT when stdin omits hookEventName", async () => {
+ const dir = await tempDir();
+ const marker = join(dir, "env.txt");
+ const code = await runGrokHook({
+ config: { hooks: [markerHook(marker)] },
+ env: { ...process.env, GROK_HOOK_EVENT: "pre_tool_use" },
+ stdin: JSON.stringify({
+ toolName: "run_terminal_command",
+ toolInput: { command: "echo env" },
+ }),
+ });
+ expect(code).toBe(0);
+ expect(await readFile(marker, "utf-8")).toBe("echo env");
+ });
+
+ it("fails open on errors instead of blocking", async () => {
+ const err: string[] = [];
+ const code = await runGrokHook({
+ cwd: "/tmp/ai-hooks-missing-config-dir",
+ stdin: JSON.stringify({ hookEventName: "SessionStart" }),
+ stderr: { write: (chunk: string) => err.push(chunk) },
+ });
+ expect(code).toBe(0);
+ expect(err.join("")).toContain("[ai-hooks] Error:");
+ });
+
+ it("exits 0 for unrecognized events", async () => {
+ const code = await runGrokHook({
+ config: { hooks: [] },
+ stdin: JSON.stringify({ hookEventName: "NotARealEvent" }),
+ });
+ expect(code).toBe(0);
+ });
+
+ it("reads streamed stdin chunks and treats invalid JSON as empty", async () => {
+ expect(await readStdinText(stdinChunks())).toBe('{"hookEventName":"SessionStart"}');
+ expect(parseCompatPayload("{nope")).toEqual({});
+ expect(parseCompatPayload("")).toEqual({});
+ });
+
+ it("reads process.stdin when no stdin override is provided", async () => {
+ const original = process.stdin;
+ Object.defineProperty(process, "stdin", {
+ configurable: true,
+ value: (async function* () {
+ yield '{"hookEventName":"SessionEnd"}';
+ })(),
+ });
+ try {
+ expect(await runGrokHook({ config: { hooks: [] } })).toBe(0);
+ } finally {
+ Object.defineProperty(process, "stdin", { configurable: true, value: original });
+ }
+ });
+
+ it("uses the default deny reason and fail-opens on non-Error throws", async () => {
+ const chunks: string[] = [];
+ const code = await runGrokHook({
+ config: {
+ hooks: [
+ {
+ id: "block",
+ name: "block",
+ events: ["prompt:submit"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ void next;
+ ctx.results.push({ blocked: true });
+ },
+ },
+ ],
+ },
+ stdin: JSON.stringify({ hookEventName: "UserPromptSubmit", prompt: "hi" }),
+ stdout: {
+ write: () => {
+ throw new NonErrorFailure();
+ },
+ },
+ stderr: { write: (chunk: string) => chunks.push(chunk) },
+ });
+ expect(code).toBe(0);
+ expect(chunks.join("")).toContain("non-error");
+ });
+
+ it("emits the default deny reason when a blocking hook omits one", async () => {
+ const out: string[] = [];
+ const code = await runGrokHook({
+ config: {
+ hooks: [
+ {
+ id: "block",
+ name: "block",
+ events: ["prompt:submit"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ void next;
+ ctx.results.push({ blocked: true });
+ },
+ },
+ ],
+ },
+ stdin: JSON.stringify({ hookEventName: "UserPromptSubmit", prompt: "hi" }),
+ stdout: { write: (chunk: string) => out.push(chunk) },
+ });
+ expect(code).toBe(2);
+ expect(JSON.parse(out.join(""))).toEqual({
+ decision: "deny",
+ reason: "Blocked by ai-hooks",
+ });
+ });
+});
diff --git a/packages/hooks/src/adapters/grok-runner.ts b/packages/hooks/src/adapters/grok-runner.ts
new file mode 100644
index 0000000..17224bd
--- /dev/null
+++ b/packages/hooks/src/adapters/grok-runner.ts
@@ -0,0 +1,75 @@
+import { loadConfig } from "../config/index.js";
+import { HookEngine } from "../runtime/index.js";
+import type { AiHooksConfig } from "../types/index.js";
+import {
+ buildCompatEvent,
+ isBlockingNativeEvent,
+ payloadEventName,
+ type CompatPayload,
+} from "./claude-compat.js";
+
+export type HookRunnerIO = {
+ stdin?: string | AsyncIterable;
+ env?: NodeJS.ProcessEnv;
+ cwd?: string;
+ config?: AiHooksConfig;
+ stdout?: { write(chunk: string): unknown };
+ stderr?: { write(chunk: string): unknown };
+};
+
+export async function readStdinText(
+ stdin?: string | AsyncIterable,
+): Promise {
+ if (typeof stdin === "string") return stdin;
+ const stream = stdin ?? process.stdin;
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
+ }
+ return Buffer.concat(chunks).toString("utf-8");
+}
+
+export function parseCompatPayload(raw: string): CompatPayload {
+ try {
+ return JSON.parse(raw || "{}") as CompatPayload;
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Execute configured ai-hooks for a Grok command hook.
+ * Grok sends JSON on stdin and honors {"decision":"deny"} plus exit 2.
+ */
+export async function runGrokHook(io: HookRunnerIO = {}): Promise {
+ const env = io.env ?? process.env;
+ const stdout = io.stdout ?? process.stdout;
+ const stderr = io.stderr ?? process.stderr;
+
+ try {
+ const raw = await readStdinText(io.stdin);
+ const payload = parseCompatPayload(raw);
+ const nativeEvent = payloadEventName(payload, env.GROK_HOOK_EVENT);
+ const event = buildCompatEvent(nativeEvent, payload, "grok");
+ if (!event) return 0;
+
+ const config = io.config ?? (await loadConfig(undefined, io.cwd));
+ const engine = new HookEngine(config);
+ const results = await engine.emit(event, { name: "grok", version: "1.0" });
+ const blocked = results.find((result) => result.blocked);
+ if (blocked && isBlockingNativeEvent(nativeEvent)) {
+ stdout.write(
+ JSON.stringify({
+ decision: "deny",
+ reason: blocked.reason ?? "Blocked by ai-hooks",
+ }),
+ );
+ return 2;
+ }
+ return 0;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ stderr.write(`[ai-hooks] Error: ${message}\n`);
+ return 0;
+ }
+}
diff --git a/packages/hooks/src/adapters/grok.test.ts b/packages/hooks/src/adapters/grok.test.ts
index 2593991..1357bb0 100644
--- a/packages/hooks/src/adapters/grok.test.ts
+++ b/packages/hooks/src/adapters/grok.test.ts
@@ -1,37 +1,22 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
import type { HookContext, HookDefinition, HookEventType } from "../index.js";
-const removeFileSpy = vi.fn();
-const commandExistsSpy = vi.fn<(cmd: string) => Promise>().mockResolvedValue(false);
-
-vi.mock("./index.js", () => {
- const registry = { register: vi.fn() };
- abstract class BaseAdapter {
- abstract readonly id: string;
- abstract readonly name: string;
- abstract readonly version: string;
- abstract readonly capabilities: unknown;
- abstract detect(): Promise;
- abstract generate(hooks: HookDefinition[]): Promise;
- abstract mapEvent(event: string): string[];
- abstract mapNativeEvent(nativeEvent: string): string[];
- async install() {}
- async uninstall() {}
- protected async commandExists(cmd: string) {
- return commandExistsSpy(cmd);
- }
- protected async removeFile(path: string) {
- removeFileSpy(path);
- }
- }
- return { BaseAdapter, registry };
+vi.mock("./index.js", async () => {
+ const { BaseAdapter } = await import("./base.js");
+ return { BaseAdapter, registry: { register: vi.fn() } };
});
-vi.mock("node:fs", () => ({
- existsSync: vi.fn(() => false),
+vi.mock("node:child_process", () => ({
+ execFile: vi.fn((_cmd: string, _args: string[], cb: (error: Error | null) => void) => {
+ cb(new Error("not found"));
+ }),
}));
-import { existsSync } from "node:fs";
+import { execFile } from "node:child_process";
import { GrokAdapter } from "./grok.js";
function makeHandler() {
@@ -41,45 +26,147 @@ function makeHandler() {
};
}
+const shellHook: HookDefinition = {
+ id: "audit",
+ name: "audit",
+ events: ["shell:before" as HookEventType],
+ phase: "before",
+ handler: makeHandler(),
+};
+
describe("GrokAdapter", () => {
let adapter: GrokAdapter;
+ let tmp: string;
+ let previousCwd: string;
- beforeEach(() => {
+ beforeEach(async () => {
vi.clearAllMocks();
adapter = new GrokAdapter();
+ tmp = await mkdtemp(join(tmpdir(), "grok-hooks-"));
+ previousCwd = process.cwd();
+ process.chdir(tmp);
+ });
+
+ afterEach(async () => {
+ process.chdir(previousCwd);
+ await rm(tmp, { recursive: true, force: true });
});
it("uses Grok identity and Claude-compatible events", () => {
expect(adapter.id).toBe("grok");
+ expect(adapter.name).toBe("Grok");
expect(adapter.mapEvent("tool:before")).toEqual(["PreToolUse"]);
+ expect(adapter.mapEvent("session:end")).toEqual(["SessionEnd"]);
expect(adapter.mapNativeEvent("SessionStart")).toEqual(["session:start"]);
});
- it("generates .grok/hooks JSON plus a runner", async () => {
- const hook: HookDefinition = {
- id: "audit",
- name: "audit",
- events: ["tool:before" as HookEventType],
- phase: "before",
- handler: makeHandler(),
- };
- const files = await adapter.generate([hook]);
+ it("writes .grok/hooks JSON Grok actually loads plus a HookEngine runner", async () => {
+ const files = await adapter.generate([shellHook]);
+ await adapter.install(files);
+
expect(files.map((file) => file.path)).toEqual([
".grok/hooks/ai-tools.json",
".grok/hooks/ai-tools-runner.js",
]);
- const json = JSON.parse(files[0]!.content) as { hooks: Record };
- expect(json.hooks.PreToolUse).toBeDefined();
+
+ const jsonPath = join(tmp, ".grok/hooks/ai-tools.json");
+ const runnerPath = join(tmp, ".grok/hooks/ai-tools-runner.js");
+ expect(existsSync(jsonPath)).toBe(true);
+ expect(existsSync(runnerPath)).toBe(true);
+
+ expect(JSON.parse(await readFile(jsonPath, "utf-8"))).toEqual({
+ hooks: {
+ PreToolUse: [
+ {
+ hooks: [{ type: "command", command: "node ai-tools-runner.js", timeout: 10 }],
+ },
+ ],
+ },
+ });
+
+ const runner = await readFile(runnerPath, "utf-8");
+ expect(runner.startsWith("#!/usr/bin/env node\n")).toBe(true);
+ expect(runner).toContain('import { runGrokHook } from "@itz4blitz/ai-tools-hooks"');
+ expect(runner).toContain("process.exit(await runGrokHook())");
+ expect(runner).not.toContain("process.stderr.write('ai-tools grok hook runner");
+ expect(runner).not.toMatch(/process\.exit\(0\);\s*$/);
+ });
+
+ it("maps multiple universal events onto Grok native names without duplicates", async () => {
+ const files = await adapter.generate([
+ {
+ id: "multi",
+ name: "multi",
+ events: ["session:start", "shell:before", "file:write", "notification"],
+ phase: "before",
+ handler: makeHandler(),
+ },
+ ]);
+ const json = JSON.parse(files[0]!.content) as { hooks: Record };
+ expect(Object.keys(json.hooks).toSorted()).toEqual([
+ "Notification",
+ "PreToolUse",
+ "SessionStart",
+ ]);
+ expect(json.hooks.PreToolUse).toHaveLength(1);
+ });
+
+ it("detects a .grok directory even without the grok binary", async () => {
+ expect(await adapter.detect()).toBe(false);
+ await mkdir(join(tmp, ".grok"));
+ expect(await adapter.detect()).toBe(true);
});
- it("detects a .grok directory", async () => {
- vi.mocked(existsSync).mockReturnValue(true);
+ it("detects the grok command when present", async () => {
+ vi.mocked(execFile).mockImplementation(((_cmd, _args, cb) => {
+ (cb as (error: Error | null) => void)(null);
+ }) as typeof execFile);
expect(await adapter.detect()).toBe(true);
});
- it("uninstalls generated hook files", async () => {
+ it("imports command hooks from .grok/hooks/*.json", async () => {
+ await mkdir(join(tmp, ".grok/hooks"), { recursive: true });
+ await writeFile(
+ join(tmp, ".grok/hooks/dcg.json"),
+ `${JSON.stringify({
+ hooks: {
+ PreToolUse: [
+ {
+ matcher: "Bash",
+ hooks: [{ type: "command", command: "/usr/bin/dcg", timeout: 5 }],
+ },
+ ],
+ },
+ })}\n`,
+ "utf-8",
+ );
+ const imported = await adapter.import(tmp);
+ expect(imported).toHaveLength(1);
+ expect(imported[0]?.id).toBe("grok-PreToolUse-0-0");
+ expect(imported[0]?.name).toBe("/usr/bin/dcg");
+ expect(imported[0]?.events).toContain("shell:before");
+ expect(imported[0]?.phase).toBe("before");
+ });
+
+ it("skips invalid JSON while importing Grok hooks", async () => {
+ await mkdir(join(tmp, ".grok/hooks"), { recursive: true });
+ await writeFile(join(tmp, ".grok/hooks/broken.json"), "{not json", "utf-8");
+ await writeFile(join(tmp, ".grok/hooks/notes.txt"), "ignore me", "utf-8");
+ await writeFile(join(tmp, ".grok/hooks/empty.json"), "{}\n", "utf-8");
+ expect(await adapter.import(tmp)).toEqual([]);
+ });
+
+ it("imports from process.cwd when no directory is passed", async () => {
+ expect(await adapter.import()).toEqual([]);
+ expect(adapter.mapEvent("unknown:event" as HookEventType)).toEqual([]);
+ expect(adapter.mapNativeEvent("NotAThing")).toEqual([]);
+ });
+
+ it("uninstalls generated hook files from the project", async () => {
+ await adapter.install(await adapter.generate([shellHook]));
+ expect(existsSync(join(tmp, ".grok/hooks/ai-tools.json"))).toBe(true);
await adapter.uninstall();
- expect(removeFileSpy).toHaveBeenCalledWith(".grok/hooks/ai-tools.json");
- expect(removeFileSpy).toHaveBeenCalledWith(".grok/hooks/ai-tools-runner.js");
+ expect(existsSync(join(tmp, ".grok/hooks/ai-tools.json"))).toBe(false);
+ expect(existsSync(join(tmp, ".grok/hooks/ai-tools-runner.js"))).toBe(false);
});
});
diff --git a/packages/hooks/src/adapters/grok.ts b/packages/hooks/src/adapters/grok.ts
index 3254079..43370f4 100644
--- a/packages/hooks/src/adapters/grok.ts
+++ b/packages/hooks/src/adapters/grok.ts
@@ -1,13 +1,20 @@
+import { existsSync } from "node:fs";
+import { readdir, readFile } from "node:fs/promises";
+import { resolve } from "node:path";
import { BaseAdapter, registry } from "./index.js";
+import { collectNativeEvents, parseHookGroups, type NativeHookGroup } from "./claude-compat.js";
import type {
AdapterCapabilities,
GeneratedConfig,
HookDefinition,
HookEventType,
} from "../types/index.js";
-import { existsSync } from "node:fs";
-import { resolve } from "node:path";
+/**
+ * Event mapping: ai-hooks universal events -> Grok native hooks.
+ * Grok's JSON format is Claude-compatible and is loaded from
+ * `/.grok/hooks/*.json` (and `~/.grok/hooks/*.json`).
+ */
const EVENT_MAP: Record = {
"session:start": ["SessionStart"],
"session:end": ["SessionEnd"],
@@ -43,6 +50,10 @@ const REVERSE_MAP: Record = {
Notification: ["notification"],
};
+const HOOKS_JSON = ".grok/hooks/ai-tools.json";
+const RUNNER_JS = ".grok/hooks/ai-tools-runner.js";
+const RUNNER_COMMAND = "node ai-tools-runner.js";
+
class GrokAdapter extends BaseAdapter {
readonly id = "grok";
readonly name = "Grok";
@@ -79,36 +90,56 @@ class GrokAdapter extends BaseAdapter {
}
async generate(hooks: HookDefinition[]): Promise {
- const events = new Set();
- for (const hook of hooks) {
- for (const event of hook.events) {
- for (const native of this.mapEvent(event)) events.add(native);
- }
- }
- const payload: Record = { hooks: {} };
- const hooksTable = payload.hooks as Record;
- for (const native of events) {
+ const nativeEvents = collectNativeEvents(hooks, (event) => this.mapEvent(event));
+ const hooksTable: Record = {};
+ for (const native of nativeEvents) {
hooksTable[native] = [
{
- hooks: [{ type: "command", command: "node .grok/hooks/ai-tools-runner.js" }],
+ hooks: [{ type: "command", command: RUNNER_COMMAND, timeout: 10 }],
},
];
}
+
return [
{
- path: ".grok/hooks/ai-tools.json",
- content: `${JSON.stringify(payload, null, 2)}\n`,
+ path: HOOKS_JSON,
+ content: `${JSON.stringify({ hooks: hooksTable }, null, 2)}\n`,
format: "json",
},
{
- path: ".grok/hooks/ai-tools-runner.js",
- content:
- "#!/usr/bin/env node\nprocess.stderr.write('ai-tools grok hook runner\\n');\nprocess.exit(0);\n",
+ path: RUNNER_JS,
+ content: this.generateRunner(),
format: "js",
},
];
}
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ const hooksDir = resolve(dir, ".grok/hooks");
+ if (!existsSync(hooksDir)) return [];
+ const entries = await readdir(hooksDir);
+ const imported: HookDefinition[] = [];
+ for (const entry of entries) {
+ if (!entry.endsWith(".json")) continue;
+ const fullPath = resolve(hooksDir, entry);
+ let parsed: { hooks?: Record };
+ try {
+ parsed = JSON.parse(await readFile(fullPath, "utf-8")) as {
+ hooks?: Record;
+ };
+ } catch {
+ continue;
+ }
+ for (const [nativeEvent, groups] of Object.entries(parsed.hooks ?? {})) {
+ imported.push(
+ ...parseHookGroups(groups, nativeEvent, (event) => this.mapNativeEvent(event), "grok"),
+ );
+ }
+ }
+ return imported;
+ }
+
mapEvent(event: HookEventType): string[] {
return EVENT_MAP[event] ?? [];
}
@@ -118,8 +149,28 @@ class GrokAdapter extends BaseAdapter {
}
async uninstall(): Promise {
- await this.removeFile(".grok/hooks/ai-tools.json");
- await this.removeFile(".grok/hooks/ai-tools-runner.js");
+ await this.removeFile(HOOKS_JSON);
+ await this.removeFile(RUNNER_JS);
+ }
+
+ private generateRunner(): string {
+ return `#!/usr/bin/env node
+/**
+ * ai-hooks runner for Grok.
+ * Generated by: ai-hooks generate
+ *
+ * Grok sends the event as JSON on stdin (GROK_HOOK_EVENT is also set).
+ * PreToolUse / UserPromptSubmit can block via {"decision":"deny"} on stdout.
+ *
+ * DO NOT EDIT - regenerate with: ai-hooks generate
+ */
+import { runGrokHook } from "@itz4blitz/ai-tools-hooks";
+
+const PROJECT_ROOT = ${JSON.stringify(resolve(process.cwd()))};
+process.chdir(PROJECT_ROOT);
+
+process.exit(await runGrokHook());
+`;
}
}
diff --git a/packages/hooks/src/adapters/zcode-runner.test.ts b/packages/hooks/src/adapters/zcode-runner.test.ts
new file mode 100644
index 0000000..e225c2e
--- /dev/null
+++ b/packages/hooks/src/adapters/zcode-runner.test.ts
@@ -0,0 +1,199 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { runZcodeHook } from "./zcode-runner.js";
+import type { AiHooksConfig, HookDefinition } from "../types/index.js";
+
+const temps: string[] = [];
+
+class NonErrorFailure {
+ toString(): string {
+ return "non-error";
+ }
+}
+
+afterEach(async () => {
+ await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
+});
+
+async function tempDir(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "zcode-runner-"));
+ temps.push(dir);
+ return dir;
+}
+
+function markerHook(markerPath: string): HookDefinition {
+ return {
+ id: "write-marker",
+ name: "write-marker",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ if (ctx.event.type === "shell:before") {
+ await writeFile(markerPath, ctx.event.command, "utf-8");
+ }
+ await next();
+ },
+ };
+}
+
+describe("runZcodeHook", () => {
+ it("runs a configured hook that writes a file when ZCode invokes the runner", async () => {
+ const dir = await tempDir();
+ const marker = join(dir, "ran.txt");
+ const config: AiHooksConfig = { hooks: [markerHook(marker)] };
+
+ const code = await runZcodeHook({
+ config,
+ stdin: JSON.stringify({
+ hook_event_name: "PreToolUse",
+ tool_name: "Bash",
+ tool_input: { command: "echo from-zcode" },
+ cwd: dir,
+ }),
+ });
+
+ expect(code).toBe(0);
+ expect(await readFile(marker, "utf-8")).toBe("echo from-zcode");
+ });
+
+ it("blocks PreToolUse with exit 2 and stderr, leaving stdout empty", async () => {
+ const err: string[] = [];
+ const out: string[] = [];
+ const config: AiHooksConfig = {
+ hooks: [
+ {
+ id: "block",
+ name: "block",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ void next;
+ ctx.results.push({ blocked: true, reason: "Blocked by policy" });
+ },
+ },
+ ],
+ };
+
+ const code = await runZcodeHook({
+ config,
+ stdin: JSON.stringify({
+ hookEventName: "PreToolUse",
+ toolName: "Bash",
+ toolInput: { command: "rm -rf /" },
+ }),
+ stdout: { write: (chunk: string) => out.push(chunk) },
+ stderr: { write: (chunk: string) => err.push(chunk) },
+ });
+
+ expect(code).toBe(2);
+ expect(out.join("")).toBe("");
+ expect(err.join("")).toBe("Blocked by policy");
+ });
+
+ it("fails open on missing config", async () => {
+ const err: string[] = [];
+ const code = await runZcodeHook({
+ cwd: "/tmp/ai-hooks-missing-zcode-config",
+ stdin: JSON.stringify({ hookEventName: "SessionStart" }),
+ stderr: { write: (chunk: string) => err.push(chunk) },
+ });
+ expect(code).toBe(0);
+ expect(err.join("")).toContain("[ai-hooks] Error:");
+ });
+
+ it("loads ai-hooks.config.js when no config is passed", async () => {
+ const dir = await tempDir();
+ const marker = join(dir, "from-config.txt");
+ await writeFile(
+ join(dir, "ai-hooks.config.js"),
+ `import { writeFile } from "node:fs/promises";
+export default {
+ hooks: [{
+ id: "from-file",
+ name: "from-file",
+ events: ["session:start"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ await writeFile(${JSON.stringify(marker)}, ctx.event.type, "utf8");
+ await next();
+ },
+ }],
+};
+`,
+ "utf-8",
+ );
+ const code = await runZcodeHook({
+ cwd: dir,
+ env: { ...process.env, ZCODE_HOOK_EVENT: "session_start" },
+ stdin: "{}",
+ });
+ expect(code).toBe(0);
+ expect(await readFile(marker, "utf-8")).toBe("session:start");
+ });
+
+ it("uses the default block reason and ignores unrecognized events", async () => {
+ const err: string[] = [];
+ const blocked = await runZcodeHook({
+ config: {
+ hooks: [
+ {
+ id: "block",
+ name: "block",
+ events: ["tool:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ void next;
+ ctx.results.push({ blocked: true });
+ },
+ },
+ ],
+ },
+ env: { ...process.env, CLAUDE_HOOK_EVENT: "PermissionRequest" },
+ stdin: JSON.stringify({ toolName: "Grep", toolInput: { pattern: "x" } }),
+ stderr: { write: (chunk: string) => err.push(chunk) },
+ });
+ expect(blocked).toBe(2);
+ expect(err.join("")).toBe("Blocked by ai-hooks");
+ expect(
+ await runZcodeHook({
+ config: { hooks: [] },
+ stdin: JSON.stringify({ hookEventName: "Nope" }),
+ }),
+ ).toBe(0);
+ });
+
+ it("fail-opens when a non-Error is thrown", async () => {
+ const err: string[] = [];
+ const code = await runZcodeHook({
+ config: {
+ hooks: [
+ {
+ id: "block",
+ name: "block",
+ events: ["shell:before"],
+ phase: "before",
+ handler: async (ctx, next) => {
+ void next;
+ ctx.results.push({ blocked: true });
+ },
+ },
+ ],
+ },
+ stdin: JSON.stringify({
+ hookEventName: "PreToolUse",
+ toolName: "Bash",
+ toolInput: { command: "ls" },
+ }),
+ stderr: {
+ write: (chunk: string) => {
+ if (chunk.startsWith("[ai-hooks]")) err.push(chunk);
+ else throw new NonErrorFailure();
+ },
+ },
+ });
+ expect(code).toBe(0);
+ expect(err.join("")).toContain("non-error");
+ });
+});
diff --git a/packages/hooks/src/adapters/zcode-runner.ts b/packages/hooks/src/adapters/zcode-runner.ts
new file mode 100644
index 0000000..d3137e8
--- /dev/null
+++ b/packages/hooks/src/adapters/zcode-runner.ts
@@ -0,0 +1,36 @@
+import { loadConfig } from "../config/index.js";
+import { HookEngine } from "../runtime/index.js";
+import { buildCompatEvent, isBlockingNativeEvent, payloadEventName } from "./claude-compat.js";
+import { parseCompatPayload, readStdinText, type HookRunnerIO } from "./grok-runner.js";
+
+/**
+ * Execute configured ai-hooks for a ZCode command hook.
+ * ZCode blocks PreToolUse / PermissionRequest / UserPromptSubmit with exit 2.
+ * stdout is omitted on block because ZCode's JSON schema is strict.
+ */
+export async function runZcodeHook(io: HookRunnerIO = {}): Promise {
+ const env = io.env ?? process.env;
+ const stderr = io.stderr ?? process.stderr;
+
+ try {
+ const raw = await readStdinText(io.stdin);
+ const payload = parseCompatPayload(raw);
+ const nativeEvent = payloadEventName(payload, env.ZCODE_HOOK_EVENT ?? env.CLAUDE_HOOK_EVENT);
+ const event = buildCompatEvent(nativeEvent, payload, "zcode");
+ if (!event) return 0;
+
+ const config = io.config ?? (await loadConfig(undefined, io.cwd));
+ const engine = new HookEngine(config);
+ const results = await engine.emit(event, { name: "zcode", version: "1.0" });
+ const blocked = results.find((result) => result.blocked);
+ if (blocked && isBlockingNativeEvent(nativeEvent)) {
+ stderr.write(blocked.reason ?? "Blocked by ai-hooks");
+ return 2;
+ }
+ return 0;
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ stderr.write(`[ai-hooks] Error: ${message}\n`);
+ return 0;
+ }
+}
diff --git a/packages/hooks/src/adapters/zcode.test.ts b/packages/hooks/src/adapters/zcode.test.ts
new file mode 100644
index 0000000..2264b20
--- /dev/null
+++ b/packages/hooks/src/adapters/zcode.test.ts
@@ -0,0 +1,335 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { HookContext, HookDefinition, HookEventType } from "../index.js";
+
+const { mockHome } = vi.hoisted(() => ({ mockHome: { value: "" } }));
+
+vi.mock("./index.js", async () => {
+ const { BaseAdapter } = await import("./base.js");
+ return { BaseAdapter, registry: { register: vi.fn() } };
+});
+
+vi.mock("node:os", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ homedir: () => mockHome.value || actual.homedir(),
+ };
+});
+
+vi.mock("node:child_process", () => ({
+ execFile: vi.fn((_cmd: string, _args: string[], cb: (error: Error | null) => void) => {
+ cb(new Error("not found"));
+ }),
+}));
+
+import { execFile } from "node:child_process";
+import { ZcodeAdapter } from "./zcode.js";
+
+function makeHandler() {
+ return async (ctx: HookContext, next: () => Promise) => {
+ void ctx;
+ await next();
+ };
+}
+
+const shellHook: HookDefinition = {
+ id: "guard",
+ name: "guard",
+ events: ["shell:before" as HookEventType],
+ phase: "before",
+ handler: makeHandler(),
+};
+
+const RUNNER_COMMAND = `node "\${ZCODE_PROJECT_DIR}/.zcode/hooks/ai-tools-runner.js"`;
+
+describe("ZcodeAdapter", () => {
+ let adapter: ZcodeAdapter;
+ let tmp: string;
+ let home: string;
+ let previousCwd: string;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ tmp = await mkdtemp(join(tmpdir(), "zcode-hooks-"));
+ home = await mkdtemp(join(tmpdir(), "zcode-home-"));
+ mockHome.value = home;
+ adapter = new ZcodeAdapter();
+ previousCwd = process.cwd();
+ process.chdir(tmp);
+ });
+
+ afterEach(async () => {
+ process.chdir(previousCwd);
+ mockHome.value = "";
+ await rm(tmp, { recursive: true, force: true });
+ await rm(home, { recursive: true, force: true });
+ });
+
+ it("uses ZCode identity and the seven native events", () => {
+ expect(adapter.id).toBe("zcode");
+ expect(adapter.name).toBe("ZCode");
+ expect(adapter.mapEvent("shell:before")).toEqual(["PreToolUse"]);
+ expect(adapter.mapEvent("session:end")).toEqual(["Stop"]);
+ expect(adapter.mapEvent("notification")).toEqual([]);
+ expect(adapter.mapNativeEvent("UserPromptSubmit")).toEqual(["prompt:submit"]);
+ expect(adapter.mapNativeEvent("PermissionRequest")).toEqual(["tool:before"]);
+ expect(adapter.userConfigPath).toBe(join(home, ".zcode/cli/config.json"));
+ expect(adapter.userHooksPath).toBe(join(home, ".zcode/hooks.json"));
+ });
+
+ it("writes .zcode/hooks.json and .zcode/config.json in the formats ZCode loads", async () => {
+ const files = await adapter.generate([shellHook]);
+ await adapter.install(files);
+
+ expect(files.map((file) => file.path)).toEqual([
+ ".zcode/hooks.json",
+ ".zcode/config.json",
+ ".zcode/hooks/ai-tools-runner.js",
+ ]);
+
+ const hookEntry = {
+ type: "command",
+ command: RUNNER_COMMAND,
+ timeout: 10,
+ statusMessage: "ai-hooks",
+ };
+
+ expect(JSON.parse(await readFile(join(tmp, ".zcode/hooks.json"), "utf-8"))).toEqual({
+ version: 1,
+ hooks: {
+ PreToolUse: [{ hooks: [hookEntry] }],
+ },
+ });
+
+ expect(JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8"))).toEqual({
+ hooks: {
+ enabled: true,
+ events: {
+ PreToolUse: [{ hooks: [hookEntry] }],
+ },
+ },
+ });
+
+ const runner = await readFile(join(tmp, ".zcode/hooks/ai-tools-runner.js"), "utf-8");
+ expect(runner.startsWith("#!/usr/bin/env node\n")).toBe(true);
+ expect(runner).toContain('import { runZcodeHook } from "@itz4blitz/ai-tools-hooks"');
+ expect(runner).toContain("process.exit(await runZcodeHook())");
+ expect(runner).not.toMatch(/process\.exit\(0\);\s*$/);
+ });
+
+ it("merges generated hooks into an existing .zcode/config.json without dropping other keys", async () => {
+ await mkdir(join(tmp, ".zcode"), { recursive: true });
+ await writeFile(
+ join(tmp, ".zcode/config.json"),
+ `${JSON.stringify({
+ model: "keep-me",
+ hooks: {
+ enabled: false,
+ events: {
+ Stop: [
+ {
+ hooks: [{ type: "command", command: "echo custom", timeout: 5 }],
+ },
+ ],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+
+ await adapter.install(await adapter.generate([shellHook]));
+ const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as {
+ model: string;
+ hooks: { enabled: boolean; events: Record };
+ };
+ expect(config.model).toBe("keep-me");
+ expect(config.hooks.enabled).toBe(true);
+ expect(config.hooks.events.Stop).toHaveLength(1);
+ expect(config.hooks.events.PreToolUse).toHaveLength(1);
+ });
+
+ it("detects a project .zcode directory", async () => {
+ expect(await adapter.detect()).toBe(false);
+ await mkdir(join(tmp, ".zcode"));
+ expect(await adapter.detect()).toBe(true);
+ });
+
+ it("detects user ~/.zcode/hooks.json without a zcode binary", async () => {
+ await mkdir(join(home, ".zcode"), { recursive: true });
+ await writeFile(join(home, ".zcode/hooks.json"), "{}\n", "utf-8");
+ expect(await adapter.detect()).toBe(true);
+ });
+
+ it("detects the zcode command", async () => {
+ vi.mocked(execFile).mockImplementation(((_cmd, _args, cb) => {
+ (cb as (error: Error | null) => void)(null);
+ }) as typeof execFile);
+ expect(await adapter.detect()).toBe(true);
+ });
+
+ it("imports workspace hooks.json and config.json events", async () => {
+ await mkdir(join(tmp, ".zcode"), { recursive: true });
+ await writeFile(
+ join(tmp, ".zcode/hooks.json"),
+ `${JSON.stringify({
+ version: 1,
+ hooks: {
+ SessionStart: [{ hooks: [{ type: "command", command: "echo session", timeout: 30 }] }],
+ },
+ })}\n`,
+ "utf-8",
+ );
+ await writeFile(
+ join(tmp, ".zcode/config.json"),
+ `${JSON.stringify({
+ hooks: {
+ enabled: true,
+ events: {
+ PreToolUse: [
+ {
+ matcher: "Bash",
+ hooks: [{ type: "command", command: "/usr/bin/dcg", timeout: 5 }],
+ },
+ ],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+
+ const imported = await adapter.import(tmp);
+ expect(imported.map((hook) => hook.name).toSorted()).toEqual(["/usr/bin/dcg", "echo session"]);
+ const session = imported.find((hook) => hook.name === "echo session");
+ expect(session?.events).toEqual(["session:start"]);
+ expect(session?.phase).toBe("before");
+ const pre = imported.find((hook) => hook.name === "/usr/bin/dcg");
+ expect(pre?.events).toContain("shell:before");
+ });
+
+ it("imports user ~/.zcode/hooks.json and cli/config.json hooks.events", async () => {
+ await mkdir(join(home, ".zcode/cli"), { recursive: true });
+ await writeFile(
+ join(home, ".zcode/hooks.json"),
+ `${JSON.stringify({
+ version: 1,
+ hooks: {
+ Stop: [{ matcher: "", hooks: [{ type: "command", command: "echo stop", timeout: 20 }] }],
+ },
+ })}\n`,
+ "utf-8",
+ );
+ await writeFile(
+ join(home, ".zcode/cli/config.json"),
+ `${JSON.stringify({
+ model: "keep-me",
+ hooks: {
+ enabled: true,
+ events: {
+ UserPromptSubmit: [
+ { matcher: "", hooks: [{ type: "command", command: "echo prompt", timeout: 20 }] },
+ ],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+
+ const imported = await adapter.importUser();
+ expect(imported.map((hook) => hook.name).toSorted()).toEqual(["echo prompt", "echo stop"]);
+ expect(imported.find((hook) => hook.name === "echo prompt")?.events).toEqual(["prompt:submit"]);
+ expect(imported.find((hook) => hook.name === "echo stop")?.events).toContain("session:end");
+ });
+
+ it("imports zcode.json workspace config and ignores invalid JSON", async () => {
+ await writeFile(
+ join(tmp, "zcode.json"),
+ `${JSON.stringify({
+ hooks: {
+ enabled: true,
+ events: {
+ PostToolUse: [{ hooks: [{ type: "command", command: "echo after" }] }],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ await mkdir(join(tmp, ".zcode"), { recursive: true });
+ await writeFile(join(tmp, ".zcode/config.json"), "[1,2,3]", "utf-8");
+ await writeFile(join(tmp, ".zcode/hooks.json"), "{not-json", "utf-8");
+ const imported = await adapter.import();
+ expect(imported.map((hook) => hook.name)).toEqual(["echo after"]);
+ expect(imported[0]?.phase).toBe("after");
+ });
+
+ it("detects user ~/.zcode/cli/config.json", async () => {
+ await mkdir(join(home, ".zcode/cli"), { recursive: true });
+ await writeFile(join(home, ".zcode/cli/config.json"), "{}\n", "utf-8");
+ expect(await adapter.detect()).toBe(true);
+ });
+
+ it("maps unknown events to empty arrays and imports nothing from missing user files", async () => {
+ expect(adapter.mapEvent("unknown:event" as HookEventType)).toEqual([]);
+ expect(adapter.mapNativeEvent("NotAThing")).toEqual([]);
+ expect(await adapter.importUser()).toEqual([]);
+ });
+
+ it("does not rewrite missing workspace config on uninstall", async () => {
+ await adapter.uninstall();
+ expect(existsSync(join(tmp, ".zcode/config.json"))).toBe(false);
+ });
+
+ it("preserves non-command matcher groups while replacing ai-hooks entries", async () => {
+ await mkdir(join(tmp, ".zcode"), { recursive: true });
+ await writeFile(
+ join(tmp, ".zcode/config.json"),
+ `${JSON.stringify({
+ hooks: {
+ events: {
+ PreToolUse: "not-an-array",
+ Stop: [{ matcher: "x" }, { hooks: [{ type: "command", command: RUNNER_COMMAND }] }],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ await adapter.install(await adapter.generate([shellHook]));
+ const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as {
+ hooks: { events: Record };
+ };
+ expect(config.hooks.events.PreToolUse).toHaveLength(1);
+ expect(config.hooks.events.Stop).toHaveLength(1);
+ });
+
+ it("uninstalls generated files and strips only ai-hooks entries from config.json", async () => {
+ await mkdir(join(tmp, ".zcode"), { recursive: true });
+ await writeFile(
+ join(tmp, ".zcode/config.json"),
+ `${JSON.stringify({
+ model: "keep-me",
+ hooks: {
+ enabled: true,
+ events: {
+ Stop: [{ hooks: [{ type: "command", command: "echo custom" }] }],
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ await adapter.install(await adapter.generate([shellHook]));
+ await adapter.uninstall();
+
+ expect(existsSync(join(tmp, ".zcode/hooks.json"))).toBe(false);
+ expect(existsSync(join(tmp, ".zcode/hooks/ai-tools-runner.js"))).toBe(false);
+ const config = JSON.parse(await readFile(join(tmp, ".zcode/config.json"), "utf-8")) as {
+ model: string;
+ hooks: { events: Record };
+ };
+ expect(config.model).toBe("keep-me");
+ expect(config.hooks.events.Stop).toHaveLength(1);
+ expect(config.hooks.events.PreToolUse).toBeUndefined();
+ });
+});
diff --git a/packages/hooks/src/adapters/zcode.ts b/packages/hooks/src/adapters/zcode.ts
new file mode 100644
index 0000000..51ad080
--- /dev/null
+++ b/packages/hooks/src/adapters/zcode.ts
@@ -0,0 +1,309 @@
+import { existsSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { resolve } from "node:path";
+import { BaseAdapter, registry } from "./index.js";
+import { collectNativeEvents, parseHookGroups, type NativeHookGroup } from "./claude-compat.js";
+import type {
+ AdapterCapabilities,
+ GeneratedConfig,
+ HookDefinition,
+ HookEventType,
+} from "../types/index.js";
+
+/**
+ * ZCode loads configuration-file hooks from:
+ * - workspace `/.zcode/config.json` (or `zcode.json`) → hooks.events
+ * - user `~/.zcode/cli/config.json` → hooks.events (requires hooks.enabled)
+ * - user `~/.zcode/hooks.json` → { version, hooks } (Claude-compatible)
+ *
+ * Supported events: SessionStart, UserPromptSubmit, PreToolUse,
+ * PermissionRequest, PostToolUse, PostToolUseFailure, Stop.
+ */
+const EVENT_MAP: Record = {
+ "session:start": ["SessionStart"],
+ "session:end": ["Stop"],
+ "prompt:submit": ["UserPromptSubmit"],
+ "prompt:response": ["Stop"],
+ "tool:before": ["PreToolUse"],
+ "tool:after": ["PostToolUse"],
+ "file:read": ["PreToolUse"],
+ "file:write": ["PreToolUse"],
+ "file:edit": ["PreToolUse"],
+ "file:delete": ["PreToolUse"],
+ "shell:before": ["PreToolUse"],
+ "shell:after": ["PostToolUse"],
+ "mcp:before": ["PreToolUse"],
+ "mcp:after": ["PostToolUse"],
+ notification: [],
+};
+
+const REVERSE_MAP: Record = {
+ SessionStart: ["session:start"],
+ UserPromptSubmit: ["prompt:submit"],
+ PreToolUse: [
+ "tool:before",
+ "file:read",
+ "file:write",
+ "file:edit",
+ "file:delete",
+ "shell:before",
+ "mcp:before",
+ ],
+ PermissionRequest: ["tool:before"],
+ PostToolUse: ["tool:after", "shell:after", "mcp:after"],
+ PostToolUseFailure: ["tool:after"],
+ Stop: ["session:end", "prompt:response"],
+};
+
+const HOOKS_JSON = ".zcode/hooks.json";
+const CONFIG_JSON = ".zcode/config.json";
+const RUNNER_JS = ".zcode/hooks/ai-tools-runner.js";
+const RUNNER_COMMAND = `node "\${ZCODE_PROJECT_DIR}/.zcode/hooks/ai-tools-runner.js"`;
+
+class ZcodeAdapter extends BaseAdapter {
+ readonly id = "zcode";
+ readonly name = "ZCode";
+ readonly version = "1.0";
+ readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`;
+ readonly userHooksPath = `${homedir()}/.zcode/hooks.json`;
+
+ readonly capabilities: AdapterCapabilities = {
+ beforeHooks: true,
+ afterHooks: true,
+ mcp: true,
+ configFile: true,
+ supportedEvents: [
+ "session:start",
+ "session:end",
+ "prompt:submit",
+ "prompt:response",
+ "tool:before",
+ "tool:after",
+ "file:read",
+ "file:write",
+ "file:edit",
+ "file:delete",
+ "shell:before",
+ "shell:after",
+ "mcp:before",
+ "mcp:after",
+ ],
+ blockableEvents: [
+ "prompt:submit",
+ "tool:before",
+ "file:write",
+ "file:edit",
+ "file:delete",
+ "shell:before",
+ "mcp:before",
+ ],
+ };
+
+ async detect(): Promise {
+ if (await this.commandExists("zcode")) return true;
+ if (existsSync(resolve(process.cwd(), ".zcode"))) return true;
+ if (existsSync(this.userConfigPath) || existsSync(this.userHooksPath)) return true;
+ return false;
+ }
+
+ async generate(hooks: HookDefinition[]): Promise {
+ const nativeEvents = collectNativeEvents(hooks, (event) => this.mapEvent(event));
+ const groups = this.buildGroups(nativeEvents);
+ const config = await this.mergeWorkspaceConfig(groups);
+
+ return [
+ {
+ path: HOOKS_JSON,
+ content: `${JSON.stringify({ version: 1, hooks: groups }, null, 2)}\n`,
+ format: "json",
+ },
+ {
+ path: CONFIG_JSON,
+ content: `${JSON.stringify(config, null, 2)}\n`,
+ format: "json",
+ },
+ {
+ path: RUNNER_JS,
+ content: this.generateRunner(),
+ format: "js",
+ },
+ ];
+ }
+
+ async import(cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ const imported: HookDefinition[] = [];
+ imported.push(
+ ...(await this.importHooksFile(resolve(dir, HOOKS_JSON), "zcode")),
+ ...(await this.importConfigFile(resolve(dir, CONFIG_JSON), "zcode")),
+ ...(await this.importConfigFile(resolve(dir, "zcode.json"), "zcode")),
+ );
+ return imported;
+ }
+
+ async importUser(): Promise {
+ return [
+ ...(await this.importHooksFile(this.userHooksPath, "zcode-user")),
+ ...(await this.importConfigFile(this.userConfigPath, "zcode-user")),
+ ];
+ }
+
+ mapEvent(event: HookEventType): string[] {
+ return EVENT_MAP[event] ?? [];
+ }
+
+ mapNativeEvent(nativeEvent: string): HookEventType[] {
+ return REVERSE_MAP[nativeEvent] ?? [];
+ }
+
+ async uninstall(): Promise {
+ await this.removeFile(HOOKS_JSON);
+ await this.removeFile(RUNNER_JS);
+ await this.stripWorkspaceConfig();
+ }
+
+ private buildGroups(nativeEvents: string[]): Record {
+ const groups: Record = {};
+ for (const native of nativeEvents) {
+ groups[native] = [
+ {
+ hooks: [
+ {
+ type: "command",
+ command: RUNNER_COMMAND,
+ timeout: 10,
+ statusMessage: "ai-hooks",
+ },
+ ],
+ },
+ ];
+ }
+ return groups;
+ }
+
+ private generateRunner(): string {
+ return `#!/usr/bin/env node
+/**
+ * ai-hooks runner for ZCode.
+ * Generated by: ai-hooks generate
+ *
+ * ZCode sends the event as JSON on stdin. Blocking events deny with exit 2.
+ *
+ * DO NOT EDIT - regenerate with: ai-hooks generate
+ */
+import { runZcodeHook } from "@itz4blitz/ai-tools-hooks";
+
+const PROJECT_ROOT = ${JSON.stringify(resolve(process.cwd()))};
+process.chdir(PROJECT_ROOT);
+
+process.exit(await runZcodeHook());
+`;
+ }
+
+ private async mergeWorkspaceConfig(
+ groups: Record,
+ ): Promise> {
+ const existing = await this.readJsonRecord(resolve(process.cwd(), CONFIG_JSON));
+ const existingHooks = asObject(existing.hooks);
+ const existingEvents = asObject(existingHooks.events);
+ const mergedEvents: Record = { ...existingEvents };
+
+ for (const [event, entries] of Object.entries(existingEvents)) {
+ mergedEvents[event] = stripAiHooksGroups(entries);
+ }
+ for (const [event, entries] of Object.entries(groups)) {
+ const current = Array.isArray(mergedEvents[event]) ? mergedEvents[event] : [];
+ mergedEvents[event] = [...current, ...entries];
+ }
+
+ return {
+ ...existing,
+ hooks: {
+ ...existingHooks,
+ enabled: true,
+ events: mergedEvents,
+ },
+ };
+ }
+
+ private async stripWorkspaceConfig(): Promise {
+ const fullPath = resolve(process.cwd(), CONFIG_JSON);
+ const existing = await this.readJsonRecord(fullPath);
+ if (Object.keys(existing).length === 0) return;
+ const existingHooks = asObject(existing.hooks);
+ const existingEvents = asObject(existingHooks.events);
+ const stripped: Record = {};
+ for (const [event, entries] of Object.entries(existingEvents)) {
+ const kept = stripAiHooksGroups(entries);
+ if (kept.length > 0) stripped[event] = kept;
+ }
+ const next = {
+ ...existing,
+ hooks: {
+ ...existingHooks,
+ events: stripped,
+ },
+ };
+ await this.writeJsonFile(CONFIG_JSON, next);
+ }
+
+ private async importHooksFile(fullPath: string, source: string): Promise {
+ const data = await this.readJsonRecord(fullPath);
+ return this.definitionsFromEvents(asObject(data.hooks), source);
+ }
+
+ private async importConfigFile(fullPath: string, source: string): Promise {
+ const data = await this.readJsonRecord(fullPath);
+ const hooks = asObject(data.hooks);
+ return this.definitionsFromEvents(asObject(hooks.events), source);
+ }
+
+ private definitionsFromEvents(events: Record, source: string): HookDefinition[] {
+ const imported: HookDefinition[] = [];
+ for (const [nativeEvent, groups] of Object.entries(events)) {
+ imported.push(
+ ...parseHookGroups(groups, nativeEvent, (event) => this.mapNativeEvent(event), source),
+ );
+ }
+ return imported;
+ }
+
+ private async readJsonRecord(fullPath: string): Promise> {
+ if (!existsSync(fullPath)) return {};
+ try {
+ const parsed: unknown = JSON.parse(await readFile(fullPath, "utf-8"));
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ return parsed as Record;
+ }
+ } catch {
+ return {};
+ }
+ return {};
+ }
+}
+
+function asObject(value: unknown): Record {
+ if (value && typeof value === "object" && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return {};
+}
+
+function stripAiHooksGroups(entries: unknown): NativeHookGroup[] {
+ if (!Array.isArray(entries)) return [];
+ return entries.filter((entry) => {
+ const group = asObject(entry);
+ const hooks = group.hooks;
+ if (!Array.isArray(hooks)) return true;
+ return !hooks.some((hook) => {
+ const command = asObject(hook).command;
+ return typeof command === "string" && command.includes("ai-tools-runner.js");
+ });
+ }) as NativeHookGroup[];
+}
+
+const adapter = new ZcodeAdapter();
+registry.register(adapter);
+export { ZcodeAdapter };
+export default adapter;
diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts
index 6ff9aba..e222a7f 100644
--- a/packages/hooks/src/index.ts
+++ b/packages/hooks/src/index.ts
@@ -15,6 +15,8 @@ export { executeChain, HookTimeoutError } from "./runtime/index.js";
// Adapter system
export { registry } from "./adapters/index.js";
export { BaseAdapter } from "./adapters/index.js";
+export { runGrokHook } from "./adapters/grok-runner.js";
+export { runZcodeHook } from "./adapters/zcode-runner.js";
// Built-in hooks
export {
diff --git a/packages/hooks/src/types/adapter.ts b/packages/hooks/src/types/adapter.ts
index d3287d4..2229f9d 100644
--- a/packages/hooks/src/types/adapter.ts
+++ b/packages/hooks/src/types/adapter.ts
@@ -84,6 +84,11 @@ export interface Adapter {
* Map a tool's native event name back to universal event type(s).
*/
mapNativeEvent(nativeEvent: string): HookEventType[];
+
+ /**
+ * Import native hook configs from a project directory into universal definitions.
+ */
+ import?(cwd?: string): Promise;
}
/**
diff --git a/packages/mcp/src/adapters/claude-code.test.ts b/packages/mcp/src/adapters/claude-code.test.ts
index 089d167..a181093 100644
--- a/packages/mcp/src/adapters/claude-code.test.ts
+++ b/packages/mcp/src/adapters/claude-code.test.ts
@@ -16,7 +16,8 @@ vi.mock("node:fs/promises", () => ({
}));
import { existsSync } from "node:fs";
-import { readFile } from "node:fs/promises";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
import { ClaudeCodeMCPAdapter } from "./claude-code.js";
import type { MCPServerDefinition } from "../types/index.js";
@@ -39,6 +40,9 @@ describe("ClaudeCodeMCPAdapter", () => {
it("has correct name", () => expect(adapter.name).toBe("Claude Code"));
it("has native support", () => expect(adapter.nativeSupport).toBe(true));
it("has correct config path", () => expect(adapter.configPath).toBe(".mcp.json"));
+ it("uses ~/.claude.json for user MCP, not ~/.mcp.json", () => {
+ expect(adapter.userConfigPath).toBe(`${homedir()}/.claude.json`);
+ });
});
describe("generate", () => {
@@ -60,9 +64,24 @@ describe("ClaudeCodeMCPAdapter", () => {
};
const files = await adapter.generate([sseServer]);
const parsed = JSON.parse(files[0]!.content);
+ expect(parsed.mcpServers["sse-server"].type).toBe("sse");
expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000");
});
+ it("emits type http for HTTP remotes", async () => {
+ const httpServer: MCPServerDefinition = {
+ id: "http-server",
+ name: "HTTP Server",
+ transport: { type: "http", url: "https://mcp.linear.app/mcp" },
+ };
+ const files = await adapter.generate([httpServer]);
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcpServers: Record;
+ };
+ expect(parsed.mcpServers["http-server"]?.type).toBe("http");
+ expect(parsed.mcpServers["http-server"]?.url).toBe("https://mcp.linear.app/mcp");
+ });
+
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
const parsed = JSON.parse(files[0]!.content);
@@ -133,5 +152,61 @@ describe("ClaudeCodeMCPAdapter", () => {
const result = await adapter.import();
expect(result).toEqual([]);
});
+
+ it("imports user MCP from ~/.claude.json mcpServers", async () => {
+ vi.mocked(existsSync).mockImplementation((path) => String(path).endsWith(".claude.json"));
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({
+ theme: "keep-me",
+ mcpServers: {
+ screenpipe: { command: "bun", args: ["x", "screenpipe-mcp@latest"] },
+ },
+ }),
+ );
+ const result = await adapter.importUser();
+ expect(result).toHaveLength(1);
+ expect(result[0]!.id).toBe("screenpipe");
+ expect(result[0]!.transport.type).toBe("stdio");
+ });
+
+ it("round-trips type http from host files", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: { docs: { type: "http", url: "https://code.claude.com/docs/mcp" } },
+ }),
+ );
+ const result = await adapter.import("/test");
+ expect(result[0]!.transport.type).toBe("http");
+ });
+ });
+
+ describe("install merge", () => {
+ it("does not clobber unrelated ~/.claude.json keys", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({ theme: "dark", mcpServers: { keep: { command: "old" } } }),
+ );
+ await adapter.install(
+ [
+ {
+ path: `${homedir()}/.claude.json`,
+ content: JSON.stringify({
+ mcpServers: { github: { command: "gh-mcp", args: [] } },
+ }),
+ format: "json",
+ },
+ ],
+ "/tmp",
+ );
+ const written = JSON.parse(String(vi.mocked(writeFile).mock.calls[0]?.[1])) as {
+ theme: string;
+ mcpServers: Record;
+ };
+ expect(written.theme).toBe("dark");
+ expect(written.mcpServers.keep?.command).toBe("old");
+ expect(written.mcpServers.github?.command).toBe("gh-mcp");
+ expect(vi.mocked(mkdir)).toHaveBeenCalled();
+ });
});
});
diff --git a/packages/mcp/src/adapters/claude-code.ts b/packages/mcp/src/adapters/claude-code.ts
index 7b9aecf..f5a3699 100644
--- a/packages/mcp/src/adapters/claude-code.ts
+++ b/packages/mcp/src/adapters/claude-code.ts
@@ -1,37 +1,30 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { MCPServerDefinition, GeneratedFile } from "../types/index.js";
-import { existsSync } from "node:fs";
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import {
+ encodeMcpServersFile,
+ fromMcpServersMap,
+ mergeMcpServersJson,
+ type McpServersMap,
+} from "./mcp-servers-json.js";
class ClaudeCodeMCPAdapter extends BaseMCPAdapter {
readonly id = "claude-code";
readonly name = "Claude Code";
readonly nativeSupport = true;
readonly configPath = ".mcp.json";
+ readonly userConfigPath = `${homedir()}/.claude.json`;
readonly command = "claude";
async generate(servers: MCPServerDefinition[]): Promise {
- const mcpServers: Record = {};
- for (const server of servers) {
- if (server.transport.type === "stdio") {
- mcpServers[server.id] = {
- command: server.transport.command,
- args: server.transport.args ?? [],
- env: server.transport.env ?? {},
- };
- } else {
- mcpServers[server.id] = {
- url: server.transport.url,
- headers: server.transport.headers ?? {},
- };
- }
- }
return [
{
path: this.configPath,
- content: JSON.stringify({ mcpServers }, null, 2) + "\n",
+ content: encodeMcpServersFile(servers),
format: "json",
},
];
@@ -39,36 +32,30 @@ class ClaudeCodeMCPAdapter extends BaseMCPAdapter {
async import(cwd?: string): Promise {
const dir = cwd ?? process.cwd();
- const fullPath = resolve(dir, this.configPath);
- if (!existsSync(fullPath)) return [];
- const raw = await readFile(fullPath, "utf-8");
- const data = JSON.parse(raw) as { mcpServers?: Record> };
- const servers: MCPServerDefinition[] = [];
- for (const [id, config] of Object.entries(data.mcpServers ?? {})) {
- if (config.command) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "stdio",
- command: config.command as string,
- args: config.args as string[] | undefined,
- env: config.env as Record | undefined,
- },
- });
- } else if (config.url) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "sse",
- url: config.url as string,
- headers: config.headers as Record | undefined,
- },
- });
+ return this.readMcpServers(resolve(dir, this.configPath));
+ }
+
+ override async importUser(): Promise {
+ return this.readMcpServers(this.userConfigPath);
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ await mkdir(dirname(fullPath), { recursive: true });
+ let content = file.content;
+ if (existsSync(fullPath)) {
+ content = mergeMcpServersJson(await readFile(fullPath, "utf-8"), file.content);
}
+ await writeFile(fullPath, content, "utf-8");
}
- return servers;
+ }
+
+ private async readMcpServers(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const data = JSON.parse(await readFile(fullPath, "utf-8")) as { mcpServers?: McpServersMap };
+ return fromMcpServersMap(data.mcpServers ?? {});
}
}
diff --git a/packages/mcp/src/adapters/codex.test.ts b/packages/mcp/src/adapters/codex.test.ts
index 10c173d..dc58bec 100644
--- a/packages/mcp/src/adapters/codex.test.ts
+++ b/packages/mcp/src/adapters/codex.test.ts
@@ -62,6 +62,24 @@ describe("CodexMCPAdapter", () => {
expect(files[0]!.content).toContain('url = "http://localhost:3000"');
});
+ it("writes http remotes with url and http_headers", async () => {
+ const httpServer: MCPServerDefinition = {
+ id: "figma",
+ name: "figma",
+ transport: {
+ type: "http",
+ url: "https://mcp.figma.com/mcp",
+ headers: { "X-Figma-Region": "us-east-1" },
+ },
+ };
+ const files = await adapter.generate([httpServer]);
+ expect(files[0]!.format).toBe("toml");
+ expect(files[0]!.content).toContain("[mcp_servers.figma]");
+ expect(files[0]!.content).toContain('url = "https://mcp.figma.com/mcp"');
+ expect(files[0]!.content).toContain("[mcp_servers.figma.http_headers]");
+ expect(files[0]!.content).toContain('X-Figma-Region = "us-east-1"');
+ });
+
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
expect(files[0]!.content).toBe("");
diff --git a/packages/mcp/src/adapters/codex.ts b/packages/mcp/src/adapters/codex.ts
index 47f03cc..a1d1f5e 100644
--- a/packages/mcp/src/adapters/codex.ts
+++ b/packages/mcp/src/adapters/codex.ts
@@ -19,7 +19,7 @@ class CodexMCPAdapter extends BaseMCPAdapter {
return [
{
path: this.configPath,
- content: encodeMcpToml(servers),
+ content: encodeMcpToml(servers, { headerTable: "http_headers" }),
format: "toml",
},
];
@@ -48,6 +48,7 @@ class CodexMCPAdapter extends BaseMCPAdapter {
await readFile(fullPath, "utf-8"),
parseMcpToml(file.content),
managedIds,
+ { headerTable: "http_headers" },
);
}
await mkdir(dirname(fullPath), { recursive: true });
diff --git a/packages/mcp/src/adapters/cursor.test.ts b/packages/mcp/src/adapters/cursor.test.ts
index 9db78ab..d4aeba5 100644
--- a/packages/mcp/src/adapters/cursor.test.ts
+++ b/packages/mcp/src/adapters/cursor.test.ts
@@ -39,6 +39,9 @@ describe("CursorMCPAdapter", () => {
it("has correct name", () => expect(adapter.name).toBe("Cursor"));
it("has native support", () => expect(adapter.nativeSupport).toBe(true));
it("has correct config path", () => expect(adapter.configPath).toBe(".cursor/mcp.json"));
+ it("has user config path ~/.cursor/mcp.json", () => {
+ expect(adapter.userConfigPath).toMatch(/\.cursor\/mcp\.json$/);
+ });
});
describe("generate", () => {
@@ -60,9 +63,31 @@ describe("CursorMCPAdapter", () => {
};
const files = await adapter.generate([sseServer]);
const parsed = JSON.parse(files[0]!.content);
+ expect(parsed.mcpServers["sse-server"].type).toBe("sse");
expect(parsed.mcpServers["sse-server"].url).toBe("http://localhost:3000");
});
+ it("emits type http for HTTP remotes", async () => {
+ const httpServer: MCPServerDefinition = {
+ id: "http-server",
+ name: "HTTP Server",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer x" },
+ },
+ };
+ const files = await adapter.generate([httpServer]);
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcpServers: Record>;
+ };
+ expect(parsed.mcpServers["http-server"]).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer x" },
+ });
+ });
+
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
const parsed = JSON.parse(files[0]!.content);
@@ -121,6 +146,23 @@ describe("CursorMCPAdapter", () => {
expect(result[0]!.transport.type).toBe("sse");
});
+ it("round-trips type http when the host file includes it", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({
+ mcpServers: {
+ linear: { type: "http", url: "https://mcp.linear.app/mcp", headers: { A: "1" } },
+ },
+ }),
+ );
+ const result = await adapter.import("/test");
+ expect(result[0]!.transport).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { A: "1" },
+ });
+ });
+
it("imports with missing servers key", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify({}));
diff --git a/packages/mcp/src/adapters/cursor.ts b/packages/mcp/src/adapters/cursor.ts
index 5d4e791..5603ff2 100644
--- a/packages/mcp/src/adapters/cursor.ts
+++ b/packages/mcp/src/adapters/cursor.ts
@@ -1,37 +1,30 @@
+import { homedir } from "node:os";
+import { existsSync } from "node:fs";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { MCPServerDefinition, GeneratedFile } from "../types/index.js";
-import { existsSync } from "node:fs";
-import { readFile } from "node:fs/promises";
-import { resolve } from "node:path";
+import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import {
+ encodeMcpServersFile,
+ fromMcpServersMap,
+ mergeMcpServersJson,
+ type McpServersMap,
+} from "./mcp-servers-json.js";
class CursorMCPAdapter extends BaseMCPAdapter {
readonly id = "cursor";
readonly name = "Cursor";
readonly nativeSupport = true;
readonly configPath = ".cursor/mcp.json";
+ readonly userConfigPath = `${homedir()}/.cursor/mcp.json`;
readonly command = "cursor";
async generate(servers: MCPServerDefinition[]): Promise {
- const mcpServers: Record = {};
- for (const server of servers) {
- if (server.transport.type === "stdio") {
- mcpServers[server.id] = {
- command: server.transport.command,
- args: server.transport.args ?? [],
- env: server.transport.env ?? {},
- };
- } else {
- mcpServers[server.id] = {
- url: server.transport.url,
- headers: server.transport.headers ?? {},
- };
- }
- }
return [
{
path: this.configPath,
- content: JSON.stringify({ mcpServers }, null, 2) + "\n",
+ content: encodeMcpServersFile(servers),
format: "json",
},
];
@@ -39,36 +32,26 @@ class CursorMCPAdapter extends BaseMCPAdapter {
async import(cwd?: string): Promise {
const dir = cwd ?? process.cwd();
- const fullPath = resolve(dir, this.configPath);
- if (!existsSync(fullPath)) return [];
- const raw = await readFile(fullPath, "utf-8");
- const data = JSON.parse(raw) as { mcpServers?: Record> };
- const servers: MCPServerDefinition[] = [];
- for (const [id, config] of Object.entries(data.mcpServers ?? {})) {
- if (config.command) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "stdio",
- command: config.command as string,
- args: config.args as string[] | undefined,
- env: config.env as Record | undefined,
- },
- });
- } else if (config.url) {
- servers.push({
- id,
- name: id,
- transport: {
- type: "sse",
- url: config.url as string,
- headers: config.headers as Record | undefined,
- },
- });
+ return this.readMcpServers(resolve(dir, this.configPath));
+ }
+
+ override async install(files: GeneratedFile[], cwd?: string): Promise {
+ const dir = cwd ?? process.cwd();
+ for (const file of files) {
+ const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
+ await mkdir(dirname(fullPath), { recursive: true });
+ let content = file.content;
+ if (existsSync(fullPath)) {
+ content = mergeMcpServersJson(await readFile(fullPath, "utf-8"), file.content);
}
+ await writeFile(fullPath, content, "utf-8");
}
- return servers;
+ }
+
+ private async readMcpServers(fullPath: string): Promise {
+ if (!existsSync(fullPath)) return [];
+ const data = JSON.parse(await readFile(fullPath, "utf-8")) as { mcpServers?: McpServersMap };
+ return fromMcpServersMap(data.mcpServers ?? {});
}
}
diff --git a/packages/mcp/src/adapters/grok.test.ts b/packages/mcp/src/adapters/grok.test.ts
index 35de140..9f3eacc 100644
--- a/packages/mcp/src/adapters/grok.test.ts
+++ b/packages/mcp/src/adapters/grok.test.ts
@@ -53,9 +53,29 @@ describe("GrokMCPAdapter", () => {
expect(files[0]?.format).toBe("toml");
expect(files[0]?.content).toContain("[mcp_servers.context7]");
expect(files[0]?.content).toContain("[mcp_servers.context7.env]");
+ expect(files[0]?.content).toContain('command = "/home/blitz/.local/bin/op-mcp-run-grok"');
+ expect(files[0]?.content).toContain('args = ["npx", "-y", "@upstash/context7-mcp"]');
expect(files[0]?.content).not.toContain("mcpServers");
});
+ it("generates url remotes with headers", async () => {
+ const files = await adapter.generate([
+ {
+ id: "linear",
+ name: "linear",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ },
+ },
+ ]);
+ expect(files[0]?.content).toContain("[mcp_servers.linear]");
+ expect(files[0]?.content).toContain('url = "https://mcp.linear.app/mcp"');
+ expect(files[0]?.content).toContain("[mcp_servers.linear.headers]");
+ expect(files[0]?.content).toContain('Authorization = "Bearer token"');
+ });
+
it("generates quoted tables for names with spaces", async () => {
const files = await adapter.generate([
{
diff --git a/packages/mcp/src/adapters/mcp-servers-json.test.ts b/packages/mcp/src/adapters/mcp-servers-json.test.ts
new file mode 100644
index 0000000..8d1b5f0
--- /dev/null
+++ b/packages/mcp/src/adapters/mcp-servers-json.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from "vitest";
+import {
+ encodeMcpServersFile,
+ fromMcpServersMap,
+ mergeMcpServersJson,
+ toMcpServersMap,
+} from "./mcp-servers-json.js";
+import type { MCPServerDefinition } from "../types/index.js";
+
+const stdio: MCPServerDefinition = {
+ id: "github",
+ name: "github",
+ transport: { type: "stdio", command: "gh-mcp", args: [], env: { A: "1" } },
+};
+
+const http: MCPServerDefinition = {
+ id: "linear",
+ name: "linear",
+ transport: { type: "http", url: "https://mcp.linear.app/mcp", headers: { Authorization: "x" } },
+};
+
+describe("mcp-servers-json", () => {
+ it("encodes http/sse type and round-trips through JSON", () => {
+ const encoded = encodeMcpServersFile([stdio, http]);
+ const parsed = JSON.parse(encoded) as { mcpServers: Record> };
+ expect(parsed.mcpServers.linear?.type).toBe("http");
+ expect(fromMcpServersMap(parsed.mcpServers).map((s) => s.transport.type)).toEqual([
+ "stdio",
+ "http",
+ ]);
+ });
+
+ it("imports url without type as sse", () => {
+ const servers = fromMcpServersMap({ hosted: { url: "https://example.com/sse" } });
+ expect(servers[0]?.transport).toEqual({ type: "sse", url: "https://example.com/sse" });
+ });
+
+ it("skips entries that have neither command nor url", () => {
+ expect(fromMcpServersMap({ broken: { type: "http" } })).toEqual([]);
+ });
+
+ it("merges mcpServers without clobbering sibling keys", () => {
+ const merged = mergeMcpServersJson(
+ JSON.stringify({ theme: "dark", mcpServers: { keep: { command: "old" } } }),
+ encodeMcpServersFile([stdio]),
+ );
+ const parsed = JSON.parse(merged) as {
+ theme: string;
+ mcpServers: Record;
+ };
+ expect(parsed.theme).toBe("dark");
+ expect(parsed.mcpServers.keep?.command).toBe("old");
+ expect(parsed.mcpServers.github?.command).toBe("gh-mcp");
+ });
+
+ it("creates mcpServers when the existing file lacks a valid map", () => {
+ const merged = mergeMcpServersJson(
+ JSON.stringify({ theme: "dark", mcpServers: 1 }),
+ encodeMcpServersFile([stdio]),
+ );
+ const parsed = JSON.parse(merged) as { theme: string; mcpServers: Record };
+ expect(parsed.theme).toBe("dark");
+ expect(parsed.mcpServers.github).toBeDefined();
+ });
+
+ it("omits invalid args/env/headers on import", () => {
+ const servers = fromMcpServersMap({
+ github: { command: "gh-mcp", args: [1, 2], env: "nope" },
+ linear: { type: "http", url: "https://x", headers: ["nope"] },
+ });
+ expect(servers[0]?.transport).toEqual({ type: "stdio", command: "gh-mcp" });
+ expect(servers[1]?.transport).toEqual({ type: "http", url: "https://x" });
+ });
+
+ it("exposes toMcpServersMap for stdio defaults", () => {
+ expect(
+ toMcpServersMap([{ id: "x", name: "x", transport: { type: "stdio", command: "npx" } }]),
+ ).toEqual({
+ x: { command: "npx", args: [], env: {} },
+ });
+ });
+
+ it("round-trips explicit sse type", () => {
+ const sse: MCPServerDefinition = {
+ id: "events",
+ name: "events",
+ transport: { type: "sse", url: "http://localhost:3000/sse" },
+ };
+ const map = toMcpServersMap([sse]);
+ expect(map.events?.type).toBe("sse");
+ expect(fromMcpServersMap(map)[0]?.transport.type).toBe("sse");
+ });
+
+ it("merges when generated JSON has no mcpServers key", () => {
+ const merged = mergeMcpServersJson(JSON.stringify({ theme: "dark" }), "{}");
+ expect(JSON.parse(merged)).toEqual({ theme: "dark", mcpServers: {} });
+ });
+});
diff --git a/packages/mcp/src/adapters/mcp-servers-json.ts b/packages/mcp/src/adapters/mcp-servers-json.ts
new file mode 100644
index 0000000..81810ba
--- /dev/null
+++ b/packages/mcp/src/adapters/mcp-servers-json.ts
@@ -0,0 +1,73 @@
+import type { MCPServerDefinition, MCPTransport } from "../types/index.js";
+
+export type McpServersMap = Record>;
+
+function isStringRecord(value: unknown): value is Record {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
+ return Object.values(value).every((item) => typeof item === "string");
+}
+
+function stringArray(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) return undefined;
+ if (!value.every((item) => typeof item === "string")) return undefined;
+ return value;
+}
+
+export function toMcpServersMap(servers: MCPServerDefinition[]): McpServersMap {
+ const mcpServers: McpServersMap = {};
+ for (const server of servers) {
+ if (server.transport.type === "stdio") {
+ mcpServers[server.id] = {
+ command: server.transport.command,
+ args: server.transport.args ?? [],
+ env: server.transport.env ?? {},
+ };
+ } else {
+ mcpServers[server.id] = {
+ type: server.transport.type,
+ url: server.transport.url,
+ headers: server.transport.headers ?? {},
+ };
+ }
+ }
+ return mcpServers;
+}
+
+export function fromMcpServersMap(mcpServers: McpServersMap): MCPServerDefinition[] {
+ const servers: MCPServerDefinition[] = [];
+ for (const [id, config] of Object.entries(mcpServers)) {
+ if (typeof config.command === "string") {
+ const transport: Extract = {
+ type: "stdio",
+ command: config.command,
+ };
+ const args = stringArray(config.args);
+ if (args) transport.args = args;
+ if (isStringRecord(config.env)) transport.env = config.env;
+ servers.push({ id, name: id, transport });
+ } else if (typeof config.url === "string") {
+ const type = config.type === "http" ? "http" : "sse";
+ const transport: Extract = { type, url: config.url };
+ if (isStringRecord(config.headers)) transport.headers = config.headers;
+ servers.push({ id, name: id, transport });
+ }
+ }
+ return servers;
+}
+
+export function encodeMcpServersFile(servers: MCPServerDefinition[]): string {
+ return `${JSON.stringify({ mcpServers: toMcpServersMap(servers) }, null, 2)}\n`;
+}
+
+export function mergeMcpServersJson(existingRaw: string, generatedRaw: string): string {
+ const existing = JSON.parse(existingRaw) as Record;
+ const generated = JSON.parse(generatedRaw) as { mcpServers?: McpServersMap };
+ const current =
+ existing.mcpServers !== null &&
+ typeof existing.mcpServers === "object" &&
+ !Array.isArray(existing.mcpServers)
+ ? (existing.mcpServers as McpServersMap)
+ : {};
+ existing.mcpServers = { ...current, ...generated.mcpServers };
+ return `${JSON.stringify(existing, null, 2)}\n`;
+}
diff --git a/packages/mcp/src/adapters/opencode.test.ts b/packages/mcp/src/adapters/opencode.test.ts
index 559085e..6f1819e 100644
--- a/packages/mcp/src/adapters/opencode.test.ts
+++ b/packages/mcp/src/adapters/opencode.test.ts
@@ -68,6 +68,27 @@ describe("OpenCodeMCPAdapter", () => {
expect(parsed.mcp["sse-server"]?.url).toBe("http://localhost:3000");
});
+ it("emits headers on remote MCP entries", async () => {
+ const httpServer: MCPServerDefinition = {
+ id: "linear",
+ name: "linear",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ },
+ };
+ const files = await adapter.generate([httpServer]);
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcp: Record }>;
+ };
+ expect(parsed.mcp.linear).toMatchObject({
+ type: "remote",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ });
+
it("handles empty servers array", async () => {
const files = await adapter.generate([]);
const parsed = JSON.parse(files[0]!.content) as { mcp: Record };
@@ -134,6 +155,27 @@ describe("OpenCodeMCPAdapter", () => {
expect(result[0]!.transport.type).toBe("http");
});
+ it("imports remote headers", async () => {
+ vi.mocked(existsSync).mockReturnValue(true);
+ vi.mocked(readFile).mockResolvedValue(
+ JSON.stringify({
+ mcp: {
+ linear: {
+ type: "remote",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ },
+ },
+ }),
+ );
+ const result = await adapter.import("/test");
+ expect(result[0]!.transport).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ });
+
it("imports with missing servers key", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue(JSON.stringify({}));
diff --git a/packages/mcp/src/adapters/opencode.ts b/packages/mcp/src/adapters/opencode.ts
index 433b6f6..1a1795c 100644
--- a/packages/mcp/src/adapters/opencode.ts
+++ b/packages/mcp/src/adapters/opencode.ts
@@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import type { GeneratedFile, MCPServerDefinition, MCPTransport } from "../types/index.js";
type OpenCodeEntry = {
type?: string;
@@ -12,6 +12,7 @@ type OpenCodeEntry = {
url?: string;
enabled?: boolean;
environment?: Record;
+ headers?: Record;
oauth?: Record;
};
@@ -80,11 +81,15 @@ function toOpenCodeMcp(servers: MCPServerDefinition[]): Record 0) {
+ entry.headers = server.transport.headers;
+ }
+ mcp[server.id] = entry;
}
}
return mcp;
@@ -108,10 +113,12 @@ function fromOpenCodeMcp(mcp: Record): MCPServerDefinitio
enabled: entry.enabled,
});
} else if (entry.url) {
+ const transport: Extract = { type: "http", url: entry.url };
+ if (entry.headers) transport.headers = entry.headers;
servers.push({
id,
name: id,
- transport: { type: "http", url: entry.url },
+ transport,
enabled: entry.enabled,
});
}
diff --git a/packages/mcp/src/adapters/roundtrip.test.ts b/packages/mcp/src/adapters/roundtrip.test.ts
index 94f20fa..88dc10f 100644
--- a/packages/mcp/src/adapters/roundtrip.test.ts
+++ b/packages/mcp/src/adapters/roundtrip.test.ts
@@ -1,7 +1,8 @@
-import { mkdtemp, readFile, rm } from "node:fs/promises";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
+import { ClaudeCodeMCPAdapter } from "./claude-code.js";
import { CodexMCPAdapter } from "./codex.js";
import { CursorMCPAdapter } from "./cursor.js";
import { GrokMCPAdapter } from "./grok.js";
@@ -9,10 +10,34 @@ import { OpenCodeMCPAdapter } from "./opencode.js";
import { ZcodeMCPAdapter } from "./zcode.js";
import type { MCPServerDefinition } from "../types/index.js";
-const server: MCPServerDefinition = {
+const stdio: MCPServerDefinition = {
id: "github",
name: "github",
- transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp", args: [] },
+ transport: {
+ type: "stdio",
+ command: "/home/blitz/.local/bin/gh-mcp",
+ args: ["--verbose"],
+ env: { TOKEN: "secret" },
+ },
+};
+
+const http: MCPServerDefinition = {
+ id: "linear",
+ name: "linear",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ },
+};
+
+const sse: MCPServerDefinition = {
+ id: "events",
+ name: "events",
+ transport: {
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ },
};
describe("MCP adapter filesystem round-trips", () => {
@@ -28,71 +53,348 @@ describe("MCP adapter filesystem round-trips", () => {
return dir;
}
- it("Grok writes TOML that import can read back", async () => {
- const cwd = await scratch();
- const adapter = new GrokMCPAdapter();
- const files = await adapter.generate([server]);
- await adapter.install(files, cwd);
- const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
- expect(raw).toContain("[mcp_servers.github]");
- const imported = await adapter.import(cwd);
- expect(imported).toEqual([
- {
- id: "github",
- name: "github",
- transport: { type: "stdio", command: "/home/blitz/.local/bin/gh-mcp" },
- },
- ]);
+ describe("Cursor .cursor/mcp.json", () => {
+ it("writes mcpServers with type http/sse and round-trips them", async () => {
+ const cwd = await scratch();
+ const adapter = new CursorMCPAdapter();
+ await adapter.install(await adapter.generate([stdio, http, sse]), cwd);
+ const parsed = JSON.parse(await readFile(join(cwd, ".cursor/mcp.json"), "utf-8")) as {
+ mcpServers: Record>;
+ };
+ expect(parsed.mcpServers.github).toMatchObject({
+ command: "/home/blitz/.local/bin/gh-mcp",
+ args: ["--verbose"],
+ env: { TOKEN: "secret" },
+ });
+ expect(parsed.mcpServers.linear).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ expect(parsed.mcpServers.events).toMatchObject({
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ });
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport);
+ expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse");
+ expect(imported.find((s) => s.id === "github")?.transport).toMatchObject({
+ type: "stdio",
+ command: "/home/blitz/.local/bin/gh-mcp",
+ args: ["--verbose"],
+ env: { TOKEN: "secret" },
+ });
+ });
+
+ it("imports host files that omit type", async () => {
+ const cwd = await scratch();
+ await mkdir(join(cwd, ".cursor"), { recursive: true });
+ await writeFile(
+ join(cwd, ".cursor/mcp.json"),
+ `${JSON.stringify({
+ mcpServers: {
+ github: { command: "gh-mcp", args: [] },
+ hosted: { url: "https://mcp.example.com/sse" },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ const imported = await new CursorMCPAdapter().import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio");
+ expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({
+ type: "sse",
+ url: "https://mcp.example.com/sse",
+ });
+ });
});
- it("Codex writes TOML that import can read back", async () => {
- const cwd = await scratch();
- const adapter = new CodexMCPAdapter();
- await adapter.install(await adapter.generate([server]), cwd);
- const imported = await adapter.import(cwd);
- expect(imported[0]?.id).toBe("github");
- expect(imported[0]?.transport.type).toBe("stdio");
+ describe("Claude Code .mcp.json / ~/.claude.json", () => {
+ it("writes project .mcp.json mcpServers and round-trips type", async () => {
+ const cwd = await scratch();
+ const adapter = new ClaudeCodeMCPAdapter();
+ expect(adapter.configPath).toBe(".mcp.json");
+ expect(adapter.userConfigPath).toMatch(/\.claude\.json$/);
+ await adapter.install(await adapter.generate([stdio, http, sse]), cwd);
+ const parsed = JSON.parse(await readFile(join(cwd, ".mcp.json"), "utf-8")) as {
+ mcpServers: Record>;
+ };
+ expect(parsed.mcpServers.linear).toMatchObject({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ expect(parsed.mcpServers.events).toMatchObject({
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ });
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "linear")?.transport.type).toBe("http");
+ expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse");
+ });
+
+ it("merges mcpServers into existing user settings without clobbering other keys", async () => {
+ const cwd = await scratch();
+ const userFile = join(cwd, ".claude.json");
+ await writeFile(
+ userFile,
+ `${JSON.stringify(
+ {
+ theme: "dark",
+ numStartups: 9,
+ mcpServers: { keep: { command: "keep-me" } },
+ },
+ null,
+ 2,
+ )}\n`,
+ "utf-8",
+ );
+ const adapter = new ClaudeCodeMCPAdapter();
+ const files = await adapter.generate([http]);
+ files[0]!.path = userFile;
+ await adapter.install(files, cwd);
+ const parsed = JSON.parse(await readFile(userFile, "utf-8")) as {
+ theme: string;
+ numStartups: number;
+ mcpServers: Record>;
+ };
+ expect(parsed.theme).toBe("dark");
+ expect(parsed.numStartups).toBe(9);
+ expect(parsed.mcpServers.keep).toEqual({ command: "keep-me" });
+ expect(parsed.mcpServers.linear).toMatchObject({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ });
+ });
+
+ it("imports project files that omit type", async () => {
+ const cwd = await scratch();
+ await writeFile(
+ join(cwd, ".mcp.json"),
+ `${JSON.stringify({
+ mcpServers: { hosted: { url: "https://mcp.example.com/mcp" } },
+ })}\n`,
+ "utf-8",
+ );
+ const imported = await new ClaudeCodeMCPAdapter().import(cwd);
+ expect(imported[0]?.transport).toMatchObject({
+ type: "sse",
+ url: "https://mcp.example.com/mcp",
+ });
+ });
});
- it("OpenCode writes opencode.json mcp.local command arrays", async () => {
- const cwd = await scratch();
- const adapter = new OpenCodeMCPAdapter();
- await adapter.install(await adapter.generate([server]), cwd);
- const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as {
- mcp: Record;
- };
- expect(parsed.mcp.github?.type).toBe("local");
- expect(parsed.mcp.github?.command[0]).toBe("/home/blitz/.local/bin/gh-mcp");
- const imported = await adapter.import(cwd);
- expect(imported[0]?.id).toBe("github");
+ describe("ZCode .zcode/config.json mcp.servers", () => {
+ it("writes { mcp: { servers } } and never a fake mcp.json", async () => {
+ const cwd = await scratch();
+ const adapter = new ZcodeMCPAdapter();
+ expect(adapter.configPath).toBe(".zcode/config.json");
+ await adapter.install(await adapter.generate([stdio, http, sse]), cwd);
+ const raw = await readFile(join(cwd, ".zcode/config.json"), "utf-8");
+ expect(raw).not.toContain("mcpServers");
+ const parsed = JSON.parse(raw) as {
+ mcp: { servers: Record> };
+ };
+ expect(parsed.mcp.servers.github).toMatchObject({
+ type: "stdio",
+ command: "/home/blitz/.local/bin/gh-mcp",
+ args: ["--verbose"],
+ env: { TOKEN: "secret" },
+ });
+ expect(parsed.mcp.servers.linear).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ expect(parsed.mcp.servers.events).toMatchObject({
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ });
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport);
+ expect(imported.find((s) => s.id === "events")?.transport.type).toBe("sse");
+ });
+
+ it("merges mcp.servers into existing config without clobbering other keys", async () => {
+ const cwd = await scratch();
+ const userFile = join(cwd, "cli-config.json");
+ await writeFile(
+ userFile,
+ `${JSON.stringify({ model: "keep-me", mcp: { servers: { keep: { type: "stdio", command: "old" } } } })}\n`,
+ "utf-8",
+ );
+ const adapter = new ZcodeMCPAdapter();
+ const files = await adapter.generate([stdio]);
+ files[0]!.path = userFile;
+ await adapter.install(files, cwd);
+ const parsed = JSON.parse(await readFile(userFile, "utf-8")) as {
+ model: string;
+ mcp: { servers: Record };
+ };
+ expect(parsed.model).toBe("keep-me");
+ expect(parsed.mcp.servers.keep?.command).toBe("old");
+ expect(parsed.mcp.servers.github?.type).toBe("stdio");
+ expect(parsed.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp");
+ });
+
+ it("imports host files that omit type", async () => {
+ const cwd = await scratch();
+ await mkdir(join(cwd, ".zcode"), { recursive: true });
+ await writeFile(
+ join(cwd, ".zcode/config.json"),
+ `${JSON.stringify({
+ mcp: {
+ servers: {
+ github: { command: "gh-mcp", args: [] },
+ hosted: { url: "https://mcp.example.com/mcp" },
+ },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ const imported = await new ZcodeMCPAdapter().import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio");
+ expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({
+ type: "http",
+ url: "https://mcp.example.com/mcp",
+ });
+ });
});
- it("ZCode writes project mcp.json", async () => {
- const cwd = await scratch();
- const adapter = new ZcodeMCPAdapter();
- await adapter.install(await adapter.generate([server]), cwd);
- const imported = await adapter.import(cwd);
- expect(imported[0]?.id).toBe("github");
+ describe("Grok TOML ~/.grok/config.toml [mcp_servers]", () => {
+ it("writes TOML that preserves command/args/env/url", async () => {
+ const cwd = await scratch();
+ const adapter = new GrokMCPAdapter();
+ await adapter.install(await adapter.generate([stdio, http]), cwd);
+ const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
+ expect(raw).toContain("[mcp_servers.github]");
+ expect(raw).toContain('command = "/home/blitz/.local/bin/gh-mcp"');
+ expect(raw).toContain('args = ["--verbose"]');
+ expect(raw).toContain("[mcp_servers.github.env]");
+ expect(raw).toContain('TOKEN = "secret"');
+ expect(raw).toContain("[mcp_servers.linear]");
+ expect(raw).toContain('url = "https://mcp.linear.app/mcp"');
+ expect(raw).not.toContain("mcpServers");
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport).toEqual(stdio.transport);
+ expect(imported.find((s) => s.id === "linear")?.transport).toMatchObject({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ });
+
+ it("Grok merge keeps unrelated tables", async () => {
+ const cwd = await scratch();
+ const adapter = new GrokMCPAdapter();
+ await mkdir(join(cwd, ".grok"), { recursive: true });
+ await writeFile(join(cwd, ".grok/config.toml"), "[ui]\nyolo = true\n", "utf-8");
+ await adapter.install(await adapter.generate([stdio]), cwd);
+ const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
+ expect(raw).toContain("[ui]");
+ expect(raw).toContain("yolo = true");
+ expect(raw).toContain("[mcp_servers.github]");
+ });
});
- it("Cursor writes .cursor/mcp.json", async () => {
- const cwd = await scratch();
- const adapter = new CursorMCPAdapter();
- await adapter.install(await adapter.generate([server]), cwd);
- const imported = await adapter.import(cwd);
- expect(imported[0]?.id).toBe("github");
+ describe("Codex TOML ~/.codex/config.toml [mcp_servers]", () => {
+ it("writes native TOML not JSON and round-trips command/args/env/url", async () => {
+ const cwd = await scratch();
+ const adapter = new CodexMCPAdapter();
+ await adapter.install(await adapter.generate([stdio, http]), cwd);
+ const raw = await readFile(join(cwd, ".codex/config.toml"), "utf-8");
+ expect(raw.startsWith("{")).toBe(false);
+ expect(raw).toContain("[mcp_servers.github]");
+ expect(raw).toContain('command = "/home/blitz/.local/bin/gh-mcp"');
+ expect(raw).toContain('args = ["--verbose"]');
+ expect(raw).toContain("[mcp_servers.github.env]");
+ expect(raw).toContain('TOKEN = "secret"');
+ expect(raw).toContain('url = "https://mcp.linear.app/mcp"');
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport).toEqual(stdio.transport);
+ expect(imported.find((s) => s.id === "linear")?.transport).toMatchObject({
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ });
+
+ it("merges into existing Codex config without dropping model", async () => {
+ const cwd = await scratch();
+ const adapter = new CodexMCPAdapter();
+ await mkdir(join(cwd, ".codex"), { recursive: true });
+ await writeFile(join(cwd, ".codex/config.toml"), 'model = "gpt-5"\n', "utf-8");
+ await adapter.install(await adapter.generate([stdio, http]), cwd);
+ const raw = await readFile(join(cwd, ".codex/config.toml"), "utf-8");
+ expect(raw).toContain('model = "gpt-5"');
+ expect(raw).toContain("[mcp_servers.github]");
+ expect(raw).toContain("[mcp_servers.linear.http_headers]");
+ expect(raw).toContain('url = "https://mcp.linear.app/mcp"');
+ });
});
- it("Grok merge keeps unrelated tables", async () => {
- const cwd = await scratch();
- const adapter = new GrokMCPAdapter();
- const { mkdir, writeFile } = await import("node:fs/promises");
- await mkdir(join(cwd, ".grok"), { recursive: true });
- await writeFile(join(cwd, ".grok/config.toml"), "[ui]\nyolo = true\n", "utf-8");
- await adapter.install(await adapter.generate([server]), cwd);
- const raw = await readFile(join(cwd, ".grok/config.toml"), "utf-8");
- expect(raw).toContain("[ui]");
- expect(raw).toContain("yolo = true");
- expect(raw).toContain("[mcp_servers.github]");
+ describe("OpenCode opencode.json mcp local/remote", () => {
+ it("writes mcp local command arrays and remote url+headers", async () => {
+ const cwd = await scratch();
+ const adapter = new OpenCodeMCPAdapter();
+ await adapter.install(await adapter.generate([stdio, http]), cwd);
+ const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as {
+ mcp: Record>;
+ };
+ expect(parsed.mcp.github).toMatchObject({
+ type: "local",
+ command: ["/home/blitz/.local/bin/gh-mcp", "--verbose"],
+ environment: { TOKEN: "secret" },
+ });
+ expect(parsed.mcp.linear).toMatchObject({
+ type: "remote",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer token" },
+ });
+ const imported = await adapter.import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport).toMatchObject({
+ type: "stdio",
+ command: "/home/blitz/.local/bin/gh-mcp",
+ args: ["--verbose"],
+ env: { TOKEN: "secret" },
+ });
+ expect(imported.find((s) => s.id === "linear")?.transport).toEqual(http.transport);
+ });
+
+ it("merges mcp into existing opencode.json without clobbering model", async () => {
+ const cwd = await scratch();
+ const adapter = new OpenCodeMCPAdapter();
+ await writeFile(
+ join(cwd, "opencode.json"),
+ `${JSON.stringify({ model: "keep-me", mcp: { keep: { type: "local", command: ["old"] } } })}\n`,
+ "utf-8",
+ );
+ await adapter.install(await adapter.generate([stdio]), cwd);
+ const parsed = JSON.parse(await readFile(join(cwd, "opencode.json"), "utf-8")) as {
+ model: string;
+ mcp: Record;
+ };
+ expect(parsed.model).toBe("keep-me");
+ expect(parsed.mcp.keep?.command).toEqual(["old"]);
+ expect(parsed.mcp.github?.type).toBe("local");
+ });
+
+ it("imports host files that omit type", async () => {
+ const cwd = await scratch();
+ await writeFile(
+ join(cwd, "opencode.json"),
+ `${JSON.stringify({
+ mcp: {
+ github: { command: ["gh-mcp"] },
+ hosted: { url: "https://mcp.example.com/mcp" },
+ },
+ })}\n`,
+ "utf-8",
+ );
+ const imported = await new OpenCodeMCPAdapter().import(cwd);
+ expect(imported.find((s) => s.id === "github")?.transport.type).toBe("stdio");
+ expect(imported.find((s) => s.id === "hosted")?.transport).toMatchObject({
+ type: "http",
+ url: "https://mcp.example.com/mcp",
+ });
+ });
});
});
diff --git a/packages/mcp/src/adapters/zcode.test.ts b/packages/mcp/src/adapters/zcode.test.ts
index 4b38c65..8210369 100644
--- a/packages/mcp/src/adapters/zcode.test.ts
+++ b/packages/mcp/src/adapters/zcode.test.ts
@@ -37,7 +37,7 @@ describe("ZcodeMCPAdapter", () => {
it("uses ZCode user and project paths", () => {
expect(adapter.id).toBe("zcode");
expect(adapter.name).toBe("ZCode");
- expect(adapter.configPath).toBe(".zcode/mcp.json");
+ expect(adapter.configPath).toBe(".zcode/config.json");
expect(adapter.userConfigPath).toBe(`${homedir()}/.zcode/cli/config.json`);
expect(adapter.command).toBe("zcode");
});
@@ -47,13 +47,47 @@ describe("ZcodeMCPAdapter", () => {
expect(await adapter.detect("/tmp/empty")).toBe(true);
});
- it("generates project mcpServers JSON", async () => {
+ it("generates project { mcp: { servers } } JSON, not mcpServers", async () => {
const files = await adapter.generate([stdio]);
- expect(files[0]?.path).toBe(".zcode/mcp.json");
+ expect(files[0]?.path).toBe(".zcode/config.json");
const parsed = JSON.parse(files[0]!.content) as {
- mcpServers: Record;
+ mcp: { servers: Record };
};
- expect(parsed.mcpServers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp");
+ expect(parsed.mcp.servers.github?.type).toBe("stdio");
+ expect(parsed.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp");
+ expect(parsed.mcp.servers.github?.args).toEqual([]);
+ expect(files[0]!.content).not.toContain("mcpServers");
+ });
+
+ it("emits type http/sse for remotes", async () => {
+ const files = await adapter.generate([
+ {
+ id: "linear",
+ name: "linear",
+ transport: {
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer x" },
+ },
+ },
+ {
+ id: "events",
+ name: "events",
+ transport: { type: "sse", url: "http://localhost:3000/sse" },
+ },
+ ]);
+ const parsed = JSON.parse(files[0]!.content) as {
+ mcp: { servers: Record> };
+ };
+ expect(parsed.mcp.servers.linear).toEqual({
+ type: "http",
+ url: "https://mcp.linear.app/mcp",
+ headers: { Authorization: "Bearer x" },
+ });
+ expect(parsed.mcp.servers.events).toMatchObject({
+ type: "sse",
+ url: "http://localhost:3000/sse",
+ });
});
it("imports user mcp.servers without clobbering other config keys", async () => {
@@ -84,23 +118,16 @@ describe("ZcodeMCPAdapter", () => {
vi.mocked(readFile).mockResolvedValue(
JSON.stringify({ model: "keep-me", mcp: { servers: {} } }),
);
- await adapter.install(
- [
- {
- path: `${homedir()}/.zcode/cli/config.json`,
- content: JSON.stringify({ mcpServers: { github: { command: "gh-mcp", args: [] } } }),
- format: "json",
- },
- ],
- "/tmp",
- );
+ const files = await adapter.generate([stdio]);
+ files[0]!.path = `${homedir()}/.zcode/cli/config.json`;
+ await adapter.install(files, "/tmp");
const written = JSON.parse(String(vi.mocked(writeFile).mock.calls[0]?.[1])) as {
model: string;
mcp: { servers: Record };
};
expect(written.model).toBe("keep-me");
expect(written.mcp.servers.github?.type).toBe("stdio");
- expect(written.mcp.servers.github?.command).toBe("gh-mcp");
+ expect(written.mcp.servers.github?.command).toBe("/home/blitz/.local/bin/gh-mcp");
expect(vi.mocked(mkdir)).toHaveBeenCalled();
});
});
diff --git a/packages/mcp/src/adapters/zcode.ts b/packages/mcp/src/adapters/zcode.ts
index 42988c2..f0c9929 100644
--- a/packages/mcp/src/adapters/zcode.ts
+++ b/packages/mcp/src/adapters/zcode.ts
@@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, resolve } from "node:path";
import { BaseMCPAdapter } from "./base.js";
import { registry } from "./registry.js";
-import type { GeneratedFile, MCPServerDefinition } from "../types/index.js";
+import type { GeneratedFile, MCPServerDefinition, MCPTransport } from "../types/index.js";
type ZcodeServer = {
type?: string;
@@ -12,6 +12,7 @@ type ZcodeServer = {
args?: string[];
env?: Record;
url?: string;
+ headers?: Record;
timeoutMs?: number;
};
@@ -19,7 +20,7 @@ class ZcodeMCPAdapter extends BaseMCPAdapter {
readonly id = "zcode";
readonly name = "ZCode";
readonly nativeSupport = true;
- readonly configPath = ".zcode/mcp.json";
+ readonly configPath = ".zcode/config.json";
readonly userConfigPath = `${homedir()}/.zcode/cli/config.json`;
readonly command = "zcode";
@@ -32,7 +33,7 @@ class ZcodeMCPAdapter extends BaseMCPAdapter {
return [
{
path: this.configPath,
- content: `${JSON.stringify({ mcpServers: toMcpServers(servers) }, null, 2)}\n`,
+ content: `${JSON.stringify({ mcp: { servers: toZcodeServers(servers) } }, null, 2)}\n`,
format: "json",
},
];
@@ -40,11 +41,11 @@ class ZcodeMCPAdapter extends BaseMCPAdapter {
async import(cwd?: string): Promise {
const dir = cwd ?? process.cwd();
- return this.readMcpServers(resolve(dir, this.configPath));
+ return this.readZcode(resolve(dir, this.configPath));
}
override async importUser(): Promise {
- return this.readUserServers(this.userConfigPath);
+ return this.readZcode(this.userConfigPath);
}
override async install(files: GeneratedFile[], cwd?: string): Promise {
@@ -52,21 +53,17 @@ class ZcodeMCPAdapter extends BaseMCPAdapter {
for (const file of files) {
const fullPath = isAbsolute(file.path) ? file.path : resolve(dir, file.path);
await mkdir(dirname(fullPath), { recursive: true });
- if (fullPath.endsWith("config.json") && existsSync(fullPath)) {
+ if (existsSync(fullPath)) {
const existing = JSON.parse(await readFile(fullPath, "utf-8")) as {
mcp?: { servers?: Record };
};
const generated = JSON.parse(file.content) as {
- mcpServers?: Record<
- string,
- { command?: string; args?: string[]; env?: Record