From bdaf05186e40ba65c32c2fd5743a0eada4205459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 10:51:19 +0200 Subject: [PATCH 01/11] feat(agents): add Gemini CLI adapter Adapt Google's Gemini CLI harness into the closed agent set. It mirrors canonical rules to GEMINI.md and renders MCP servers into the shared .gemini/settings.json under the mcpServers key, preserving other settings. Remote transports map to Gemini's field-keyed shape: httpUrl for HTTP, url for SSE. Gemini has no hook or skills mechanism, so those slices are omitted. Registered in ADAPTERS and added to the curated default set. --- src/agents/adapters/gemini-cli/index.ts | 163 ++++++++++++++++++++++++ src/agents/adapters/index.ts | 5 +- src/domains/agents/index.ts | 2 +- 3 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 src/agents/adapters/gemini-cli/index.ts diff --git a/src/agents/adapters/gemini-cli/index.ts b/src/agents/adapters/gemini-cli/index.ts new file mode 100644 index 0000000..aca70de --- /dev/null +++ b/src/agents/adapters/gemini-cli/index.ts @@ -0,0 +1,163 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + AgentAdapter, + MaterializeContext, + McpDeclaration, + ResolvedMcp, +} from "../../../core/index.js"; +import { + importMcpServers, + pickEnv, + pickStringArray, + readConfigOrDefault, +} from "../../../core/index.js"; +import { mirrorRules, removePaths, ruleMirrorPaths, writeIfChanged } from "../shared.js"; + +const GEMINI_RULES = "GEMINI.md"; +const GEMINI_DIR = ".gemini"; +const GEMINI_SETTINGS = path.join(GEMINI_DIR, "settings.json"); + +/** + * Gemini CLI (Google's official terminal agent). Reads hierarchical `GEMINI.md` + * context files and configures MCP servers under `mcpServers` in the shared + * `.gemini/settings.json`. It has no lifecycle-hook or skills mechanism, so + * those slices are intentionally absent. + */ +const geminiCli: AgentAdapter = { + id: "gemini-cli", + displayName: "Gemini CLI", + paths: { rulesFilename: GEMINI_RULES, rulesRoot: "." }, + + render: { + async rules(state, ctx) { + await mirrorRules(state as string[], GEMINI_RULES, ctx); + }, + async mcp(state, ctx) { + await writeGeminiMcp(state as ResolvedMcp[], ctx); + }, + }, + + scrape: { + mcp: (ctx) => importGeminiMcp(ctx), + }, + + async claims(ctx) { + const config = await readConfigOrDefault(ctx.configPath); + const canonical = Object.keys(config.rules?.files ?? {}); + return [ + ...ruleMirrorPaths(canonical, GEMINI_RULES, ctx.projectRoot), + path.join(ctx.projectRoot, GEMINI_SETTINGS), + ]; + }, +}; + +// ---------- mcp (.gemini/settings.json — shared file, mcpServers key only) ---------- + +interface LoadedSettings { + data: Record; + existed: boolean; +} + +async function readSettings(file: string): Promise { + let raw: string; + try { + raw = await fs.readFile(file, "utf8"); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return { data: {}, existed: false }; + throw err; + } + try { + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { data: {}, existed: true }; + } + return { data: parsed as Record, existed: true }; + } catch { + return null; + } +} + +async function writeGeminiMcp(servers: ResolvedMcp[], ctx: MaterializeContext): Promise { + const file = path.join(ctx.projectRoot, GEMINI_SETTINGS); + const settings = await readSettings(file); + if (settings === null) { + ctx.logger.warn(`${GEMINI_SETTINGS} is not valid JSON; skipping mcp`); + return; + } + if (servers.length > 0) { + settings.data["mcpServers"] = Object.fromEntries( + servers.map((m) => [m.name, toGeminiServer(m)]), + ); + } else { + // No servers declared → drop the key but keep any other settings intact. + if (!settings.existed || !("mcpServers" in settings.data)) return; + delete settings.data["mcpServers"]; + } + const content = JSON.stringify(settings.data, null, 2) + "\n"; + await writeIfChanged(file, content, ctx, `${GEMINI_SETTINGS} (${servers.length} servers)`); +} + +async function importGeminiMcp(ctx: MaterializeContext): Promise { + return await importMcpServers(ctx, { + relativePath: GEMINI_SETTINGS, + format: "JSON", + parse: (raw) => JSON.parse(raw), + containerKey: "mcpServers", + fromEntry: fromGeminiServer, + }); +} + +function fromGeminiServer(name: string, entry: unknown): McpDeclaration | undefined { + if (!entry || typeof entry !== "object") return undefined; + const e = entry as Record; + // HTTP streaming: `httpUrl`; SSE: `url`. Gemini keys the transport by field. + const httpUrl = typeof e["httpUrl"] === "string" ? (e["httpUrl"] as string) : undefined; + const url = typeof e["url"] === "string" ? (e["url"] as string) : undefined; + const remote = httpUrl ?? url; + if (remote) { + const decl: McpDeclaration = { + name, + transport: httpUrl ? "http" : "sse", + command: remote, + }; + const env = pickEnv(e["env"]); + if (env) decl.env = env; + const headers = pickEnv(e["headers"]); + if (headers) decl.headers = headers; + return decl; + } + const command = typeof e["command"] === "string" ? (e["command"] as string) : undefined; + if (!command) return undefined; + const decl: McpDeclaration = { name, transport: "stdio", command }; + const args = pickStringArray(e["args"]); + if (args && args.length > 0) decl.args = args; + const env = pickEnv(e["env"]); + if (env) decl.env = env; + return decl; +} + +function toGeminiServer(decl: ResolvedMcp): Record { + if (decl.transport === "http") { + return { + httpUrl: decl.command ?? "", + ...(decl.headers ? { headers: decl.headers } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; + } + if (decl.transport === "sse") { + return { + url: decl.command ?? "", + ...(decl.headers ? { headers: decl.headers } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; + } + return { + command: decl.command ?? "", + ...(decl.args ? { args: decl.args } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; +} + +export { removePaths }; +export default geminiCli; diff --git a/src/agents/adapters/index.ts b/src/agents/adapters/index.ts index 14ee52b..5417869 100644 --- a/src/agents/adapters/index.ts +++ b/src/agents/adapters/index.ts @@ -1,16 +1,17 @@ import type { AgentAdapter } from "../../core/index.js"; import claudeCode from "./claude-code/index.js"; import codex from "./codex/index.js"; +import geminiCli from "./gemini-cli/index.js"; /** Static set of built-in agent adapters (the closed agent set). */ -export const ADAPTERS: AgentAdapter[] = [claudeCode, codex]; +export const ADAPTERS: AgentAdapter[] = [claudeCode, codex, geminiCli]; /** * Agents pre-selected by `agnos --init` (and the set written when init runs * non-interactively). The single place to curate the out-of-the-box default as * new agents are added to {@link ADAPTERS}. */ -export const DEFAULT_AGENT_IDS: string[] = ["claude-code", "codex"]; +export const DEFAULT_AGENT_IDS: string[] = ["claude-code", "codex", "gemini-cli"]; export function adapterById(id: string): AgentAdapter | undefined { return ADAPTERS.find((a) => a.id === id); diff --git a/src/domains/agents/index.ts b/src/domains/agents/index.ts index 920dde3..b5fe9c4 100644 --- a/src/domains/agents/index.ts +++ b/src/domains/agents/index.ts @@ -24,7 +24,7 @@ const AGENTS_ARG = { name: "agents", required: false, variadic: true, - description: "agent ids (claude-code | codex); omit to pick interactively", + description: "agent ids (claude-code | codex | gemini-cli); omit to pick interactively", } as const; function agentDescription(adapter: AgentAdapter): string | undefined { From c657be4768be085c10971df56f090a8e8a27c51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 10:51:29 +0200 Subject: [PATCH 02/11] test(agents): cover the Gemini CLI adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a round-trip suite for the gemini-cli adapter: stdio and remote (httpUrl/url) MCP render → scrape, preservation of unrelated settings keys, mcpServers removal when empty, and claims. Update the agents init-step assertions to the expanded curated default set. --- test/agents/adapters.test.ts | 104 ++++++++++++++++++++++++++++++++++- test/core/init-steps.test.ts | 6 +- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/test/agents/adapters.test.ts b/test/agents/adapters.test.ts index 7f2fd04..caf760d 100644 --- a/test/agents/adapters.test.ts +++ b/test/agents/adapters.test.ts @@ -3,9 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; import type { HookEntry, MaterializeContext, ResolvedMcp } from "../../src/core/index.js"; -import { createLogger } from "../../src/core/index.js"; +import { createLogger, SCHEMA_VERSION } from "../../src/core/index.js"; import claudeCode from "../../src/agents/adapters/claude-code/index.js"; import codex from "../../src/agents/adapters/codex/index.js"; +import geminiCli from "../../src/agents/adapters/gemini-cli/index.js"; let tmp: string; @@ -179,3 +180,104 @@ describe("codex adapter", () => { expect(claims).toContain(path.join(tmp, ".agents", "skills")); }); }); + +describe("gemini-cli adapter", () => { + it("mcp render → scrape round-trips and preserves other settings keys", async () => { + const ctx = ctxFor(tmp); + await fs.mkdir(path.join(tmp, ".gemini"), { recursive: true }); + await fs.writeFile( + path.join(tmp, ".gemini", "settings.json"), + JSON.stringify({ theme: "dark" }), + ); + await geminiCli.render!["mcp"]!(SERVERS, ctx); + const settings = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(settings.theme).toBe("dark"); // untouched + expect(settings.mcpServers.fs).toEqual({ + command: "npx", + args: ["-y", "server-fs"], + env: { TOKEN: "x" }, + }); + const scraped = (await geminiCli.scrape!["mcp"]!(ctx)) as ResolvedMcp[]; + expect(scraped).toEqual([ + { + name: "fs", + transport: "stdio", + command: "npx", + args: ["-y", "server-fs"], + env: { TOKEN: "x" }, + }, + ]); + }); + + it("http transport maps to httpUrl and round-trips", async () => { + const ctx = ctxFor(tmp); + const remote: ResolvedMcp[] = [ + { + name: "hosted", + command: "https://mcp.acme.com/mcp", + transport: "http", + headers: { Authorization: "Bearer t" }, + }, + ]; + await geminiCli.render!["mcp"]!(remote, ctx); + const written = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(written.mcpServers.hosted).toEqual({ + httpUrl: "https://mcp.acme.com/mcp", + headers: { Authorization: "Bearer t" }, + }); + const scraped = (await geminiCli.scrape!["mcp"]!(ctx)) as ResolvedMcp[]; + expect(scraped).toEqual([ + { + name: "hosted", + transport: "http", + command: "https://mcp.acme.com/mcp", + headers: { Authorization: "Bearer t" }, + }, + ]); + }); + + it("sse transport maps to url and round-trips", async () => { + const ctx = ctxFor(tmp); + const remote: ResolvedMcp[] = [ + { name: "events", command: "https://mcp.acme.com/sse", transport: "sse" }, + ]; + await geminiCli.render!["mcp"]!(remote, ctx); + const written = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(written.mcpServers.events).toEqual({ url: "https://mcp.acme.com/sse" }); + const scraped = (await geminiCli.scrape!["mcp"]!(ctx)) as ResolvedMcp[]; + expect(scraped).toEqual([ + { name: "events", transport: "sse", command: "https://mcp.acme.com/sse" }, + ]); + }); + + it("drops the mcpServers key when no servers remain, keeping other settings", async () => { + const ctx = ctxFor(tmp); + await fs.mkdir(path.join(tmp, ".gemini"), { recursive: true }); + await fs.writeFile( + path.join(tmp, ".gemini", "settings.json"), + JSON.stringify({ theme: "dark", mcpServers: { old: { command: "x" } } }), + ); + await geminiCli.render!["mcp"]!([], ctx); + const settings = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(settings).toEqual({ theme: "dark" }); + }); + + it("claims the GEMINI.md mirror and settings file", async () => { + const ctx = ctxFor(tmp); + await fs.writeFile( + path.join(tmp, "agnos.json"), + JSON.stringify({ schemaVersion: SCHEMA_VERSION, rules: { files: { "AGENTS.md": [] } } }), + ); + const claims = await geminiCli.claims!(ctx); + expect(claims).toContain(path.join(tmp, "GEMINI.md")); + expect(claims).toContain(path.join(tmp, ".gemini", "settings.json")); + }); +}); diff --git a/test/core/init-steps.test.ts b/test/core/init-steps.test.ts index 77bf0b6..96b9919 100644 --- a/test/core/init-steps.test.ts +++ b/test/core/init-steps.test.ts @@ -45,7 +45,7 @@ afterEach(async () => { describe("agents init multiselect step", () => { it("writes the curated default agent set non-interactively (-y)", async () => { await runDomainInitSteps(agentsDomain, ctxFor(), { yes: true, dryRun: false }); - expect((await readCfg()).agents).toEqual(["claude-code", "codex"]); + expect((await readCfg()).agents).toEqual(["claude-code", "codex", "gemini-cli"]); }); it("preserves an existing selection rather than reapplying the default", async () => { @@ -57,7 +57,9 @@ describe("agents init multiselect step", () => { it("under --dry logs the array value and writes nothing", async () => { const ctx = ctxFor(); await runDomainInitSteps(agentsDomain, ctx, { yes: false, dryRun: true }); - expect(logs.some((l) => l.includes('select = ["claude-code","codex"]'))).toBe(true); + expect(logs.some((l) => l.includes('select = ["claude-code","codex","gemini-cli"]'))).toBe( + true, + ); expect((await readCfg()).agents).toBeUndefined(); }); }); From e621e2eac13fe88abed44251cf01ee638b636a55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 10:51:38 +0200 Subject: [PATCH 03/11] docs(readme): list Gemini CLI as a built-in agent Note GEMINI.md + .gemini/settings.json in the materialization example and add Gemini CLI to the fixed built-in agent set. --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ba7e97a..c5d6829 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ ## What is agnos? -**agnos is a project-level configuration manager for AI coding agents.** You declare your docs, rules, skills, MCP servers, and hooks once in a single `agnos.json` at the root of your repo. agnos materializes that declaration into whatever each agent expects to find on disk: `CLAUDE.md` + `.mcp.json` + `.claude/settings.json` for **Claude Code**, `AGENTS.md` + `.codex/config.toml` for **OpenAI Codex**, and so on. +**agnos is a project-level configuration manager for AI coding agents.** You declare your docs, rules, skills, MCP servers, and hooks once in a single `agnos.json` at the root of your repo. agnos materializes that declaration into whatever each agent expects to find on disk: `CLAUDE.md` + `.mcp.json` + `.claude/settings.json` for **Claude Code**, `AGENTS.md` + `.codex/config.toml` for **OpenAI Codex**, `GEMINI.md` + `.gemini/settings.json` for **Gemini CLI**, and so on. -agnos ships as a **single package** (`@luxia/agnos`) with a fixed, built-in set of agents (Claude Code, Codex) and domains (docs, rules, skills, mcp, hooks, agents). Point it at your project, run it once, or leave it in watch mode: every agent's files stay in sync with your one source of truth. +agnos ships as a **single package** (`@luxia/agnos`) with a fixed, built-in set of agents (Claude Code, Codex, Gemini CLI) and domains (docs, rules, skills, mcp, hooks, agents). Point it at your project, run it once, or leave it in watch mode: every agent's files stay in sync with your one source of truth. ## What does it solve? @@ -60,7 +60,7 @@ agnos collapses all of that into one declarative `agnos.json`. You edit intent; ## Features - 🎯 **One source of truth**: declare docs, rules, skills, MCP servers, and hooks once in `agnos.json`. -- 🔌 **Multi-agent output**: renders native files for Claude Code and OpenAI Codex from the same config. +- 🔌 **Multi-agent output**: renders native files for Claude Code, OpenAI Codex, and Gemini CLI from the same config. - 👀 **Watch mode**: a per-domain watcher tree keeps agent files in sync as your sources change; edit a rule fragment and the canonical files re-render. - 🧩 **Composable rules**: inject titled sections (by frontmatter `title`) from fragment files into your canonical rules file, preserving your hand-written sections. - 📚 **Docs index**: compile a metadata index from your docs directory and surface it to agents. @@ -128,7 +128,7 @@ You can also run a single domain: `agnos rules --once`, `agnos docs`, etc. { "$schema": "https://unpkg.com/@luxia/agnos/schema.json", "schemaVersion": 1, - "agents": ["claude-code", "codex"], + "agents": ["claude-code", "codex", "gemini-cli"], "docs": { "root": ".docs" }, "rules": { "files": { @@ -168,7 +168,7 @@ You can also run a single domain: `agnos rules --once`, `agnos docs`, etc. | Field | Type | Description | | ---------------- | --------------------------- | -------------------------------------------------------------------------------------------- | | `schemaVersion` | `1` | Required. Config schema version; must be `1`. | -| `agents` | `string[]` | Active agent ids: `"claude-code"`, `"codex"`. | +| `agents` | `string[]` | Active agent ids: `"claude-code"`, `"codex"`, `"gemini-cli"`. | | `docs.root` | `string` | Directory the docs index is compiled from (default `.docs`). | | `rules.files` | `{ [canonical]: string[] }` | Maps each canonical rules file → fragment files whose titled sections are injected into it. | | `skills.route` | `string` | Canonical skills directory (default `.agnos/skills`); agents link their own skills dir here. | @@ -270,7 +270,7 @@ The sole config reader: renders every active agent's native files. `add`/`remove ## Active development -⚠️ **agnos is under active development.** The built-in roster is currently **Claude Code** and **OpenAI Codex**, with more agents planned. Config schema, CLI flags, and rendered output may change between releases: pin a version in CI and read the release notes before upgrading. Feedback and bug reports are very welcome. +⚠️ **agnos is under active development.** The built-in roster is currently **Claude Code**, **OpenAI Codex**, and **Gemini CLI**, with more agents planned. Config schema, CLI flags, and rendered output may change between releases: pin a version in CI and read the release notes before upgrading. Feedback and bug reports are very welcome. ## Contributing From b9d779e594956bb6165da70c3c082083ea5feb30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:08:45 +0200 Subject: [PATCH 04/11] feat(agents): support Gemini CLI hooks and skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini CLI does expose lifecycle hooks and agent skills, so wire up both slices. Hooks live under the `hooks` key of the shared .gemini/settings.json and use Gemini's own event names, so translate the closed agnos vocabulary across the boundary (PreToolUse↔BeforeTool, Stop↔AfterAgent, PreCompact↔ PreCompress, etc.), dropping events with no counterpart. Skills link .gemini/skills to the canonical skills directory. Also stop claiming .gemini/settings.json: it is a shared user file (theme and other settings), so removing the agent must not delete it — key-level teardown happens through render, matching the Claude Code adapter. --- src/agents/adapters/gemini-cli/index.ts | 168 ++++++++++++++++++++---- 1 file changed, 146 insertions(+), 22 deletions(-) diff --git a/src/agents/adapters/gemini-cli/index.ts b/src/agents/adapters/gemini-cli/index.ts index aca70de..97ce3f8 100644 --- a/src/agents/adapters/gemini-cli/index.ts +++ b/src/agents/adapters/gemini-cli/index.ts @@ -2,6 +2,8 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { AgentAdapter, + HookEntry, + HookEvent, MaterializeContext, McpDeclaration, ResolvedMcp, @@ -12,22 +14,54 @@ import { pickStringArray, readConfigOrDefault, } from "../../../core/index.js"; -import { mirrorRules, removePaths, ruleMirrorPaths, writeIfChanged } from "../shared.js"; +import { flattenHooks, groupHooks } from "../hooks-map.js"; +import { + linkSkills, + mirrorRules, + removePaths, + ruleMirrorPaths, + writeIfChanged, +} from "../shared.js"; const GEMINI_RULES = "GEMINI.md"; const GEMINI_DIR = ".gemini"; const GEMINI_SETTINGS = path.join(GEMINI_DIR, "settings.json"); +const GEMINI_SKILLS_DIR = path.join(GEMINI_DIR, "skills"); + +/** + * Gemini names hook events differently from agnos' closed vocabulary. Only the + * events with a faithful semantic counterpart are translated; the rest (e.g. + * `SubagentStop`, or Gemini's `BeforeModel`) are dropped on render/scrape. + */ +const AGNOS_TO_GEMINI_EVENT: Partial> = { + PreToolUse: "BeforeTool", + PostToolUse: "AfterTool", + UserPromptSubmit: "BeforeAgent", + Stop: "AfterAgent", + PreCompact: "PreCompress", + Notification: "Notification", + SessionStart: "SessionStart", + SessionEnd: "SessionEnd", +}; + +const GEMINI_TO_AGNOS_EVENT: Record = Object.fromEntries( + Object.entries(AGNOS_TO_GEMINI_EVENT).map(([agnos, gemini]) => [gemini, agnos as HookEvent]), +); + +const GEMINI_HOOK_EVENTS: ReadonlySet = new Set( + Object.keys(AGNOS_TO_GEMINI_EVENT) as HookEvent[], +); /** * Gemini CLI (Google's official terminal agent). Reads hierarchical `GEMINI.md` - * context files and configures MCP servers under `mcpServers` in the shared - * `.gemini/settings.json`. It has no lifecycle-hook or skills mechanism, so - * those slices are intentionally absent. + * context files; MCP servers and lifecycle hooks both live in the shared + * `.gemini/settings.json`, and skills are self-contained directories under + * `.gemini/skills/`. */ const geminiCli: AgentAdapter = { id: "gemini-cli", displayName: "Gemini CLI", - paths: { rulesFilename: GEMINI_RULES, rulesRoot: "." }, + paths: { skillsDir: GEMINI_SKILLS_DIR, rulesFilename: GEMINI_RULES, rulesRoot: "." }, render: { async rules(state, ctx) { @@ -36,23 +70,33 @@ const geminiCli: AgentAdapter = { async mcp(state, ctx) { await writeGeminiMcp(state as ResolvedMcp[], ctx); }, + async hooks(state, ctx) { + await writeGeminiHooks(state as HookEntry[], ctx); + }, + async skills(state, ctx) { + await linkSkills(GEMINI_SKILLS_DIR, state as string, ctx); + }, }, scrape: { mcp: (ctx) => importGeminiMcp(ctx), + hooks: async (ctx) => importGeminiHooks(ctx), + skills: (ctx) => listSkillDirs(ctx), }, async claims(ctx) { const config = await readConfigOrDefault(ctx.configPath); const canonical = Object.keys(config.rules?.files ?? {}); + // `.gemini/settings.json` is a shared user file (holds theme, etc.), so it is + // deliberately not claimed — cleanup empties its keys via render, not deletion. return [ ...ruleMirrorPaths(canonical, GEMINI_RULES, ctx.projectRoot), - path.join(ctx.projectRoot, GEMINI_SETTINGS), + path.join(ctx.projectRoot, GEMINI_SKILLS_DIR), ]; }, }; -// ---------- mcp (.gemini/settings.json — shared file, mcpServers key only) ---------- +// ---------- shared settings.json (mcp + hooks live in the same file) ---------- interface LoadedSettings { data: Record; @@ -78,24 +122,60 @@ async function readSettings(file: string): Promise { } } -async function writeGeminiMcp(servers: ResolvedMcp[], ctx: MaterializeContext): Promise { +/** + * Set (or delete, when `value` is undefined) a single top-level key in the + * shared settings file, preserving every other key. Absent + nothing-to-do is a + * no-op, so mcp and hooks compose cleanly on the same file. + */ +async function updateSettingsKey( + key: string, + value: unknown, + ctx: MaterializeContext, + label: string, +): Promise { const file = path.join(ctx.projectRoot, GEMINI_SETTINGS); const settings = await readSettings(file); if (settings === null) { - ctx.logger.warn(`${GEMINI_SETTINGS} is not valid JSON; skipping mcp`); + ctx.logger.warn(`${GEMINI_SETTINGS} is not valid JSON; skipping ${key}`); return; } - if (servers.length > 0) { - settings.data["mcpServers"] = Object.fromEntries( - servers.map((m) => [m.name, toGeminiServer(m)]), - ); + if (value !== undefined) { + settings.data[key] = value; } else { - // No servers declared → drop the key but keep any other settings intact. - if (!settings.existed || !("mcpServers" in settings.data)) return; - delete settings.data["mcpServers"]; + if (!settings.existed || !(key in settings.data)) return; + const { [key]: _dropped, ...rest } = settings.data; + settings.data = rest; } const content = JSON.stringify(settings.data, null, 2) + "\n"; - await writeIfChanged(file, content, ctx, `${GEMINI_SETTINGS} (${servers.length} servers)`); + await writeIfChanged(file, content, ctx, label); +} + +/** Rename an event-keyed record through a name map, dropping unmapped keys. */ +function renameEventKeys( + native: Record, + map: Record, +): Record { + const out: Record = {}; + for (const [event, groups] of Object.entries(native)) { + const mapped = map[event]; + if (mapped) out[mapped] = groups; + } + return out; +} + +// ---------- mcp ---------- + +async function writeGeminiMcp(servers: ResolvedMcp[], ctx: MaterializeContext): Promise { + const value = + servers.length > 0 + ? Object.fromEntries(servers.map((m) => [m.name, toGeminiServer(m)])) + : undefined; + await updateSettingsKey( + "mcpServers", + value, + ctx, + `${GEMINI_SETTINGS} (${servers.length} servers)`, + ); } async function importGeminiMcp(ctx: MaterializeContext): Promise { @@ -116,11 +196,7 @@ function fromGeminiServer(name: string, entry: unknown): McpDeclaration | undefi const url = typeof e["url"] === "string" ? (e["url"] as string) : undefined; const remote = httpUrl ?? url; if (remote) { - const decl: McpDeclaration = { - name, - transport: httpUrl ? "http" : "sse", - command: remote, - }; + const decl: McpDeclaration = { name, transport: httpUrl ? "http" : "sse", command: remote }; const env = pickEnv(e["env"]); if (env) decl.env = env; const headers = pickEnv(e["headers"]); @@ -159,5 +235,53 @@ function toGeminiServer(decl: ResolvedMcp): Record { }; } +// ---------- hooks (.gemini/settings.json#hooks, Gemini event vocabulary) ---------- + +async function writeGeminiHooks(entries: HookEntry[], ctx: MaterializeContext): Promise { + // Gemini handlers carry no user-facing status text, so drop `message`. + const { hooks, dropped } = groupHooks(entries, { + events: GEMINI_HOOK_EVENTS, + withMessage: false, + }); + if (dropped > 0) { + ctx.logger.warn( + `gemini-cli: skipped ${dropped} hook${dropped === 1 ? "" : "s"} for unsupported events`, + ); + } + const renamed = renameEventKeys(hooks, AGNOS_TO_GEMINI_EVENT as Record); + const value = Object.keys(renamed).length > 0 ? renamed : undefined; + await updateSettingsKey( + "hooks", + value, + ctx, + `${GEMINI_SETTINGS} (${Object.keys(renamed).length} hook events)`, + ); +} + +async function importGeminiHooks(ctx: MaterializeContext): Promise { + const file = path.join(ctx.projectRoot, GEMINI_SETTINGS); + const settings = await readSettings(file); + if (settings === null) { + ctx.logger.warn(`${GEMINI_SETTINGS} is not valid JSON; skipping hooks import`); + return []; + } + const native = settings.data["hooks"]; + if (!native || typeof native !== "object" || Array.isArray(native)) return []; + const renamed = renameEventKeys(native as Record, GEMINI_TO_AGNOS_EVENT); + return flattenHooks(renamed); +} + +// ---------- skills (scrape) ---------- + +async function listSkillDirs(ctx: MaterializeContext): Promise { + const dir = path.join(ctx.projectRoot, GEMINI_SKILLS_DIR); + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name); + } catch { + return []; + } +} + export { removePaths }; export default geminiCli; From 16e3ae59e40abd9921861c8812e03603c760bdc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:08:49 +0200 Subject: [PATCH 05/11] test(agents): cover Gemini CLI hooks and skills Add coverage for the hook event-vocabulary translation and unsupported- event drop, the mcp+hooks shared-settings compose path, and update the claims assertion to the skills dir (settings.json is no longer claimed). --- test/agents/adapters.test.ts | 43 ++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/test/agents/adapters.test.ts b/test/agents/adapters.test.ts index caf760d..25c7665 100644 --- a/test/agents/adapters.test.ts +++ b/test/agents/adapters.test.ts @@ -270,7 +270,45 @@ describe("gemini-cli adapter", () => { expect(settings).toEqual({ theme: "dark" }); }); - it("claims the GEMINI.md mirror and settings file", async () => { + it("hooks render translates the event vocabulary and round-trips the mapped ones", async () => { + const ctx = ctxFor(tmp); + const withUnsupported: HookEntry[] = [ + { event: "PreToolUse", matcher: "git", type: "command", command: "echo guard", message: "m" }, + { event: "SessionStart", type: "command", command: "date" }, + { event: "SubagentStop", type: "command", command: "sub" }, // no Gemini equivalent + ]; + await geminiCli.render!["hooks"]!(withUnsupported, ctx); + const written = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + // agnos PreToolUse → Gemini BeforeTool; message dropped (Gemini has no status field). + expect(written.hooks.BeforeTool).toEqual([ + { matcher: "git", hooks: [{ type: "command", command: "echo guard" }] }, + ]); + expect(written.hooks.SessionStart).toBeDefined(); + expect(written.hooks.SubagentStop).toBeUndefined(); + const scraped = (await geminiCli.scrape!["hooks"]!(ctx)) as HookEntry[]; + expect(scraped).toEqual([ + { event: "PreToolUse", matcher: "git", type: "command", command: "echo guard" }, + { event: "SessionStart", type: "command", command: "date" }, + ]); + }); + + it("mcp and hooks share settings.json without clobbering each other", async () => { + const ctx = ctxFor(tmp); + await geminiCli.render!["mcp"]!(SERVERS, ctx); + await geminiCli.render!["hooks"]!( + [{ event: "SessionStart", type: "command", command: "date" }], + ctx, + ); + const settings = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(settings.mcpServers.fs).toBeDefined(); // survived the hooks write + expect(settings.hooks.SessionStart).toBeDefined(); + }); + + it("claims the GEMINI.md mirror and skills dir, not the shared settings file", async () => { const ctx = ctxFor(tmp); await fs.writeFile( path.join(tmp, "agnos.json"), @@ -278,6 +316,7 @@ describe("gemini-cli adapter", () => { ); const claims = await geminiCli.claims!(ctx); expect(claims).toContain(path.join(tmp, "GEMINI.md")); - expect(claims).toContain(path.join(tmp, ".gemini", "settings.json")); + expect(claims).toContain(path.join(tmp, ".gemini", "skills")); + expect(claims).not.toContain(path.join(tmp, ".gemini", "settings.json")); }); }); From 33bf64c553a4002d17fc39d7ee340b42e380c797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:34:58 +0200 Subject: [PATCH 06/11] feat(hooks): centralize the hook-event vocabulary as a mapping registry Promote the canonical event set to the union of every event any agent exposes (adds BeforeModel, AfterModel, BeforeToolSelection) and give each adapter a declared `hookEvents` map from canonical names to its own native names. The shared machinery (renderNativeHooks/scrapeNativeHooks) drives render and scrape off that map, replacing the per-adapter drop-set (Codex) and hand-rolled rename table (Gemini). Nothing is dropped from the central registry, so scraping preserves every event even when only one agent supports it; a per-agent render simply omits events that agent can't express. schema.json tracks the widened enum. --- schema.json | 5 +- src/agents/adapters/claude-code/index.ts | 22 +++++++-- src/agents/adapters/codex/index.ts | 35 ++++++------- src/agents/adapters/gemini-cli/index.ts | 58 ++++++---------------- src/agents/adapters/hooks-map.ts | 63 +++++++++++++----------- src/core/index.ts | 1 + src/core/schema.ts | 11 +++-- src/core/types/public.ts | 24 +++++++-- 8 files changed, 118 insertions(+), 101 deletions(-) diff --git a/schema.json b/schema.json index 417bddc..15212d0 100644 --- a/schema.json +++ b/schema.json @@ -122,7 +122,10 @@ "SubagentStop", "PreCompact", "SessionStart", - "SessionEnd" + "SessionEnd", + "BeforeModel", + "AfterModel", + "BeforeToolSelection" ], "description": "Normalized hook event name." }, diff --git a/src/agents/adapters/claude-code/index.ts b/src/agents/adapters/claude-code/index.ts index ef36977..62254d3 100644 --- a/src/agents/adapters/claude-code/index.ts +++ b/src/agents/adapters/claude-code/index.ts @@ -3,6 +3,7 @@ import path from "node:path"; import type { AgentAdapter, HookEntry, + HookEventMap, MaterializeContext, McpDeclaration, ResolvedMcp, @@ -13,7 +14,7 @@ import { pickStringArray, readConfigOrDefault, } from "../../../core/index.js"; -import { flattenHooks, groupHooks } from "../hooks-map.js"; +import { renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, @@ -27,10 +28,24 @@ const CLAUDE_MCP = ".mcp.json"; const CLAUDE_SETTINGS = path.join(".claude", "settings.json"); const CLAUDE_SKILLS_DIR = path.join(".claude", "skills"); +/** Claude Code uses the canonical event names verbatim (identity mapping). */ +const CLAUDE_HOOK_EVENTS: HookEventMap = { + PreToolUse: "PreToolUse", + PostToolUse: "PostToolUse", + UserPromptSubmit: "UserPromptSubmit", + Notification: "Notification", + Stop: "Stop", + SubagentStop: "SubagentStop", + PreCompact: "PreCompact", + SessionStart: "SessionStart", + SessionEnd: "SessionEnd", +}; + const claudeCode: AgentAdapter = { id: "claude-code", displayName: "Claude Code", paths: { skillsDir: CLAUDE_SKILLS_DIR, rulesFilename: CLAUDE_RULES, rulesRoot: "." }, + hookEvents: CLAUDE_HOOK_EVENTS, render: { async rules(state, ctx) { @@ -49,7 +64,8 @@ const claudeCode: AgentAdapter = { scrape: { mcp: (ctx) => importMcpFile(ctx), - hooks: async (ctx) => flattenHooks((await readSettings(settingsPath(ctx)))?.data["hooks"]), + hooks: async (ctx) => + scrapeNativeHooks((await readSettings(settingsPath(ctx)))?.data["hooks"], CLAUDE_HOOK_EVENTS), skills: (ctx) => listSkillDirs(ctx), }, @@ -101,7 +117,7 @@ async function writeClaudeHooks(entries: HookEntry[], ctx: MaterializeContext): ctx.logger.warn(`${CLAUDE_SETTINGS} is not valid JSON; skipping hooks`); return; } - const { hooks } = groupHooks(entries, { withMessage: true }); + const { hooks } = renderNativeHooks(entries, CLAUDE_HOOK_EVENTS, { withMessage: true }); const hasHooks = Object.keys(hooks).length > 0; if (hasHooks) { settings.data["hooks"] = hooks; diff --git a/src/agents/adapters/codex/index.ts b/src/agents/adapters/codex/index.ts index 93c1095..081a8c8 100644 --- a/src/agents/adapters/codex/index.ts +++ b/src/agents/adapters/codex/index.ts @@ -4,13 +4,13 @@ import TOML from "@iarna/toml"; import type { AgentAdapter, HookEntry, - HookEvent, + HookEventMap, MaterializeContext, McpDeclaration, ResolvedMcp, } from "../../../core/index.js"; import { importMcpServers, pickEnv, pickStringArray } from "../../../core/index.js"; -import { flattenHooks, groupHooks } from "../hooks-map.js"; +import { renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, removePaths, writeIfChanged } from "../shared.js"; const CODEX_RULES = "AGENTS.md"; @@ -19,21 +19,22 @@ const CODEX_CONFIG = path.join(CODEX_DIR, "config.toml"); const CODEX_HOOKS = path.join(CODEX_DIR, "hooks.json"); const CODEX_SKILLS_DIR = path.join(".agents", "skills"); -/** New-vocabulary events Codex understands (intersection with the closed set). */ -const CODEX_EVENTS: ReadonlySet = new Set([ - "PreToolUse", - "PostToolUse", - "UserPromptSubmit", - "PreCompact", - "SubagentStop", - "Stop", - "SessionStart", -]); +/** Codex uses the canonical event names verbatim, for the subset it understands. */ +const CODEX_HOOK_EVENTS: HookEventMap = { + PreToolUse: "PreToolUse", + PostToolUse: "PostToolUse", + UserPromptSubmit: "UserPromptSubmit", + PreCompact: "PreCompact", + SubagentStop: "SubagentStop", + Stop: "Stop", + SessionStart: "SessionStart", +}; const codex: AgentAdapter = { id: "codex", displayName: "OpenAI Codex", paths: { skillsDir: CODEX_SKILLS_DIR, rulesFilename: CODEX_RULES, rulesRoot: "." }, + hookEvents: CODEX_HOOK_EVENTS, render: { async rules(state, ctx) { @@ -54,7 +55,7 @@ const codex: AgentAdapter = { scrape: { mcp: (ctx) => importCodexConfig(ctx), - hooks: async (ctx) => flattenHooks(await readCodexHooks(ctx)), + hooks: async (ctx) => scrapeNativeHooks(await readCodexHooks(ctx), CODEX_HOOK_EVENTS), skills: () => Promise.resolve([]), }, @@ -67,12 +68,8 @@ const codex: AgentAdapter = { async function writeCodexHooks(entries: HookEntry[], ctx: MaterializeContext): Promise { const file = path.join(ctx.projectRoot, CODEX_HOOKS); - const { hooks, dropped } = groupHooks(entries, { events: CODEX_EVENTS, withMessage: false }); - if (dropped > 0) { - ctx.logger.warn( - `codex: skipped ${dropped} hook${dropped === 1 ? "" : "s"} for unsupported events`, - ); - } + // Unsupported events are surfaced at `hooks add` time; render just skips them. + const { hooks } = renderNativeHooks(entries, CODEX_HOOK_EVENTS, { withMessage: false }); if (Object.keys(hooks).length === 0) { if (!ctx.dryRun) await fs.rm(file, { force: true }).catch(() => {}); return; diff --git a/src/agents/adapters/gemini-cli/index.ts b/src/agents/adapters/gemini-cli/index.ts index 97ce3f8..fcc9856 100644 --- a/src/agents/adapters/gemini-cli/index.ts +++ b/src/agents/adapters/gemini-cli/index.ts @@ -3,7 +3,7 @@ import path from "node:path"; import type { AgentAdapter, HookEntry, - HookEvent, + HookEventMap, MaterializeContext, McpDeclaration, ResolvedMcp, @@ -14,7 +14,7 @@ import { pickStringArray, readConfigOrDefault, } from "../../../core/index.js"; -import { flattenHooks, groupHooks } from "../hooks-map.js"; +import { renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, @@ -29,11 +29,11 @@ const GEMINI_SETTINGS = path.join(GEMINI_DIR, "settings.json"); const GEMINI_SKILLS_DIR = path.join(GEMINI_DIR, "skills"); /** - * Gemini names hook events differently from agnos' closed vocabulary. Only the - * events with a faithful semantic counterpart are translated; the rest (e.g. - * `SubagentStop`, or Gemini's `BeforeModel`) are dropped on render/scrape. + * Gemini names hook events differently from agnos' canonical vocabulary. Every + * canonical event with a Gemini counterpart is mapped (nothing is dropped from + * the registry); `SubagentStop` is the only canonical event Gemini lacks. */ -const AGNOS_TO_GEMINI_EVENT: Partial> = { +const GEMINI_HOOK_EVENTS: HookEventMap = { PreToolUse: "BeforeTool", PostToolUse: "AfterTool", UserPromptSubmit: "BeforeAgent", @@ -42,16 +42,11 @@ const AGNOS_TO_GEMINI_EVENT: Partial> = { Notification: "Notification", SessionStart: "SessionStart", SessionEnd: "SessionEnd", + BeforeModel: "BeforeModel", + AfterModel: "AfterModel", + BeforeToolSelection: "BeforeToolSelection", }; -const GEMINI_TO_AGNOS_EVENT: Record = Object.fromEntries( - Object.entries(AGNOS_TO_GEMINI_EVENT).map(([agnos, gemini]) => [gemini, agnos as HookEvent]), -); - -const GEMINI_HOOK_EVENTS: ReadonlySet = new Set( - Object.keys(AGNOS_TO_GEMINI_EVENT) as HookEvent[], -); - /** * Gemini CLI (Google's official terminal agent). Reads hierarchical `GEMINI.md` * context files; MCP servers and lifecycle hooks both live in the shared @@ -62,6 +57,7 @@ const geminiCli: AgentAdapter = { id: "gemini-cli", displayName: "Gemini CLI", paths: { skillsDir: GEMINI_SKILLS_DIR, rulesFilename: GEMINI_RULES, rulesRoot: "." }, + hookEvents: GEMINI_HOOK_EVENTS, render: { async rules(state, ctx) { @@ -150,19 +146,6 @@ async function updateSettingsKey( await writeIfChanged(file, content, ctx, label); } -/** Rename an event-keyed record through a name map, dropping unmapped keys. */ -function renameEventKeys( - native: Record, - map: Record, -): Record { - const out: Record = {}; - for (const [event, groups] of Object.entries(native)) { - const mapped = map[event]; - if (mapped) out[mapped] = groups; - } - return out; -} - // ---------- mcp ---------- async function writeGeminiMcp(servers: ResolvedMcp[], ctx: MaterializeContext): Promise { @@ -239,22 +222,14 @@ function toGeminiServer(decl: ResolvedMcp): Record { async function writeGeminiHooks(entries: HookEntry[], ctx: MaterializeContext): Promise { // Gemini handlers carry no user-facing status text, so drop `message`. - const { hooks, dropped } = groupHooks(entries, { - events: GEMINI_HOOK_EVENTS, - withMessage: false, - }); - if (dropped > 0) { - ctx.logger.warn( - `gemini-cli: skipped ${dropped} hook${dropped === 1 ? "" : "s"} for unsupported events`, - ); - } - const renamed = renameEventKeys(hooks, AGNOS_TO_GEMINI_EVENT as Record); - const value = Object.keys(renamed).length > 0 ? renamed : undefined; + // Unsupported events are surfaced at `hooks add` time; render just skips them. + const { hooks } = renderNativeHooks(entries, GEMINI_HOOK_EVENTS, { withMessage: false }); + const value = Object.keys(hooks).length > 0 ? hooks : undefined; await updateSettingsKey( "hooks", value, ctx, - `${GEMINI_SETTINGS} (${Object.keys(renamed).length} hook events)`, + `${GEMINI_SETTINGS} (${Object.keys(hooks).length} hook events)`, ); } @@ -265,10 +240,7 @@ async function importGeminiHooks(ctx: MaterializeContext): Promise ctx.logger.warn(`${GEMINI_SETTINGS} is not valid JSON; skipping hooks import`); return []; } - const native = settings.data["hooks"]; - if (!native || typeof native !== "object" || Array.isArray(native)) return []; - const renamed = renameEventKeys(native as Record, GEMINI_TO_AGNOS_EVENT); - return flattenHooks(renamed); + return scrapeNativeHooks(settings.data["hooks"], GEMINI_HOOK_EVENTS); } // ---------- skills (scrape) ---------- diff --git a/src/agents/adapters/hooks-map.ts b/src/agents/adapters/hooks-map.ts index d40f71b..49902cd 100644 --- a/src/agents/adapters/hooks-map.ts +++ b/src/agents/adapters/hooks-map.ts @@ -1,11 +1,10 @@ -import type { HookEntry, HookEvent } from "../../core/index.js"; -import { hookEventSchema } from "../../core/index.js"; +import type { HookEntry, HookEvent, HookEventMap } from "../../core/index.js"; /** * An agent's native hooks shape: event → matcher groups → command handlers. - * Both Claude Code (`.claude/settings.json#hooks`) and Codex (`.codex/hooks.json#hooks`) - * use this record form, so the regroup/flatten logic is shared; agents differ - * only in which events they support and whether they carry `statusMessage`. + * Claude Code, Codex, and Gemini CLI all use this record form; agents differ + * only in their native event names (see {@link HookEventMap}) and whether they + * carry a `statusMessage`. */ export interface NativeHookHandler { type: "command"; @@ -20,34 +19,38 @@ export interface NativeHookGroup { export type NativeHooks = Record; -const KNOWN_EVENTS: ReadonlySet = new Set(hookEventSchema.options); - -export interface GroupOptions { - /** Restrict to these events (e.g. the subset an agent supports). Omit = all. */ - events?: ReadonlySet; +export interface RenderOptions { /** Render `message` as the handler's `statusMessage` (agents that support it). */ withMessage?: boolean; } +/** Whether an agent's mapping supports a given canonical event. */ +export function supportsHookEvent(map: HookEventMap | undefined, event: HookEvent): boolean { + return !!map && event in map; +} + /** - * Regroup a flat hook array into an agent's native record, grouping by - * `(event, matcher)`. Deterministic: events and matcher groups appear in first- - * seen order, so re-rendering identical input yields byte-identical output. - * Returns the count of entries dropped because the agent doesn't support them. + * Render the canonical flat hook array into an agent's native record, keyed by + * that agent's *native* event names via `map`. Groups by `(nativeEvent, matcher)` + * in first-seen order, so re-rendering identical input yields byte-identical + * output. Entries whose event the agent doesn't support are skipped and counted + * in `dropped` — they stay in the central registry, just not in this agent. */ -export function groupHooks( +export function renderNativeHooks( entries: HookEntry[], - opts: GroupOptions = {}, + map: HookEventMap, + opts: RenderOptions = {}, ): { hooks: NativeHooks; dropped: number } { const hooks: NativeHooks = {}; let dropped = 0; for (const entry of entries) { - if (opts.events && !opts.events.has(entry.event)) { + const nativeEvent = map[entry.event]; + if (!nativeEvent) { dropped++; continue; } - const groups = (hooks[entry.event] ??= []); + const groups = (hooks[nativeEvent] ??= []); const matcher = entry.matcher; let group = groups.find((g) => g.matcher === matcher); if (!group) { @@ -63,15 +66,21 @@ export function groupHooks( } /** - * Flatten an agent's native hooks record back into the canonical flat array. - * Keeps only known events and `command` handlers; maps `statusMessage` → - * `message`. Used by the reverse-import (`scrape`) path. Never throws. + * Flatten an agent's native hooks record back into the canonical flat array, + * translating native event names to canonical ones via `map`. Keeps only events + * the agent maps and `command` handlers; maps `statusMessage` → `message`. Used + * by the reverse-import (`scrape`) path. Never throws. */ -export function flattenHooks(native: unknown): HookEntry[] { +export function scrapeNativeHooks(native: unknown, map: HookEventMap): HookEntry[] { if (!native || typeof native !== "object" || Array.isArray(native)) return []; + const nativeToCanonical = new Map(); + for (const [canonical, nativeName] of Object.entries(map)) { + if (nativeName) nativeToCanonical.set(nativeName, canonical as HookEvent); + } const out: HookEntry[] = []; - for (const [event, rawGroups] of Object.entries(native as Record)) { - if (!KNOWN_EVENTS.has(event) || !Array.isArray(rawGroups)) continue; + for (const [nativeEvent, rawGroups] of Object.entries(native as Record)) { + const event = nativeToCanonical.get(nativeEvent); + if (!event || !Array.isArray(rawGroups)) continue; for (const rawGroup of rawGroups) { if (!rawGroup || typeof rawGroup !== "object") continue; const group = rawGroup as { matcher?: unknown; hooks?: unknown }; @@ -81,11 +90,7 @@ export function flattenHooks(native: unknown): HookEntry[] { if (!rawHandler || typeof rawHandler !== "object") continue; const h = rawHandler as Record; if (h["type"] !== "command" || typeof h["command"] !== "string") continue; - const entry: HookEntry = { - event: event as HookEvent, - type: "command", - command: h["command"], - }; + const entry: HookEntry = { event, type: "command", command: h["command"] }; if (matcher !== undefined) entry.matcher = matcher; if (typeof h["statusMessage"] === "string") entry.message = h["statusMessage"]; out.push(entry); diff --git a/src/core/index.ts b/src/core/index.ts index 49374b1..8c475ce 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -12,6 +12,7 @@ export type { FlagType, HookEntry, HookEvent, + HookEventMap, HooksDeclaration, InitStep, InitStepBase, diff --git a/src/core/schema.ts b/src/core/schema.ts index df7205c..0e7d621 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -47,9 +47,11 @@ export const mcpDeclarationSchema = z.object({ }); /** - * Closed, normalized vocabulary of hook events. Each agent adapter maps the - * subset it supports and skips the rest. (Proposed set — finalize against the - * agents' real event names in a later milestone.) + * Canonical, normalized vocabulary of hook events — the union of every event + * any supported agent exposes. Each adapter declares a `hookEvents` map from + * these canonical names to its own native names; events an agent lacks are not + * rendered to it (and `hooks add` warns which agents skip them). Nothing is + * dropped from the central registry, so scraping preserves every event. */ export const hookEventSchema = z.enum([ "PreToolUse", @@ -61,6 +63,9 @@ export const hookEventSchema = z.enum([ "PreCompact", "SessionStart", "SessionEnd", + "BeforeModel", + "AfterModel", + "BeforeToolSelection", ]); /** diff --git a/src/core/types/public.ts b/src/core/types/public.ts index 4b6c6d5..a55a749 100644 --- a/src/core/types/public.ts +++ b/src/core/types/public.ts @@ -83,8 +83,9 @@ export interface McpDeclaration { // ---------- Hooks domain ---------- /** - * Closed, normalized vocabulary of hook events. Each agent adapter maps the - * subset it supports and skips the rest. + * Canonical, normalized vocabulary of hook events — the union of every event + * any supported agent exposes. Each adapter maps these to its native names via + * {@link HookEventMap}; events an agent lacks are simply not rendered to it. */ export type HookEvent = | "PreToolUse" @@ -95,7 +96,17 @@ export type HookEvent = | "SubagentStop" | "PreCompact" | "SessionStart" - | "SessionEnd"; + | "SessionEnd" + | "BeforeModel" + | "AfterModel" + | "BeforeToolSelection"; + +/** + * An agent's hook-event mapping: canonical {@link HookEvent} → the agent's own + * native event name. A present key means the agent supports that event (and the + * value is what to write in its native config); an absent key means it doesn't. + */ +export type HookEventMap = Partial>; /** * A single hook entry — a flat, strict 5-field shape. Agents render it into @@ -633,6 +644,13 @@ export interface AgentAdapter { id: string; displayName: string; paths?: AgentPaths; + /** + * Canonical→native hook-event mapping. Declared once here and consumed by the + * shared hooks machinery for render/scrape and by `hooks add` to warn which + * installed agents don't support a given event. Omit for agents with no hook + * system (they support no events). + */ + hookEvents?: HookEventMap; /** Render a resolved slice (keyed by domain id) into this agent's native files. */ render?: Record Promise>; /** Scrape this agent's native files back into agnos.json declarations (keyed by domain id). */ From b364e00273137c966220a8d526e87e93a63259d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:35:03 +0200 Subject: [PATCH 07/11] feat(hooks): warn when adding an event some installed agents lack `hooks add` now resolves the installed agents and, using the central hookEvents maps, warns which of them do not support the event being added (naming each), so the hook silently not reaching an agent is surfaced up front rather than only at render time. --- src/domains/agents/index.ts | 15 +++++++++++++++ src/domains/hooks/index.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/domains/agents/index.ts b/src/domains/agents/index.ts index b5fe9c4..71e8187 100644 --- a/src/domains/agents/index.ts +++ b/src/domains/agents/index.ts @@ -6,6 +6,7 @@ import type { CommandSpec, Domain, ExclusiveChoice, + HookEvent, MaterializeContext, ResolveContext, ResolvedMcp, @@ -13,6 +14,7 @@ import type { import { buildPaths, readConfigOrDefault, writeConfig } from "../../core/index.js"; import { adapterById, ADAPTERS, DEFAULT_AGENT_IDS } from "../../agents/adapters/index.js"; import { removePaths } from "../../agents/adapters/shared.js"; +import { supportsHookEvent } from "../../agents/adapters/hooks-map.js"; import { multiSelectInteractive, writeChange } from "../cli-helpers.js"; const ADD_HINT = @@ -68,6 +70,19 @@ export function activeAdapters(config: AgnosConfig, ctx: ResolveContext): AgentA return out; } +/** + * Installed agents whose adapter does not support `event` — used by `hooks add` + * to warn that a newly-added hook will not reach those agents. Support is read + * from each adapter's declared `hookEvents` map (the central mapping registry). + */ +export function agentsMissingHookEvent( + event: HookEvent, + config: AgnosConfig, + ctx: ResolveContext, +): AgentAdapter[] { + return activeAdapters(config, ctx).filter((a) => !supportsHookEvent(a.hookEvents, event)); +} + /** * Render one agent. §13.1: each slice is atomic — a slice failure warns with the * reason and continues to the next slice; it never aborts the agent or the run. diff --git a/src/domains/hooks/index.ts b/src/domains/hooks/index.ts index 1021163..84bc6b6 100644 --- a/src/domains/hooks/index.ts +++ b/src/domains/hooks/index.ts @@ -9,7 +9,7 @@ import { reqArg, writeChange, } from "../cli-helpers.js"; -import { scrapeActive } from "../agents/index.js"; +import { agentsMissingHookEvent, scrapeActive } from "../agents/index.js"; export { hookIdentity }; @@ -54,6 +54,13 @@ const commands: Record = { if (hooks.some((h) => hookIdentity(h) === hookIdentity(entry))) { throw new Error(`a hook with that (event, matcher, command) already exists`); } + const unsupported = agentsMissingHookEvent(entry.event, config, ctx); + if (unsupported.length > 0) { + ctx.logger.warn( + `event "${entry.event}" is not supported by ${unsupported.map((a) => a.displayName).join(", ")}; ` + + `the hook will not be rendered for ${unsupported.length === 1 ? "it" : "them"}`, + ); + } await writeChange(ctx, `added ${entry.event} hook`, { ...config, hooks: [...hooks, entry] }); }, }, From 49fc13dcfa90723f6fadac005499d370fece2892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:35:07 +0200 Subject: [PATCH 08/11] test(hooks): cover the mapping registry and add-time warning Rewrite the hooks-map suite around renderNativeHooks/scrapeNativeHooks with identity and rename maps, and add hooks-add tests asserting the unsupported- agent warning fires (naming the agent) and stays silent when all support it. --- test/agents/hooks-map.test.ts | 78 +++++++++++++++++++++++++---------- test/domains/commands.test.ts | 28 ++++++++++++- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/test/agents/hooks-map.test.ts b/test/agents/hooks-map.test.ts index 7122b1b..b8973ce 100644 --- a/test/agents/hooks-map.test.ts +++ b/test/agents/hooks-map.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from "vitest"; -import type { HookEntry } from "../../src/core/index.js"; +import type { HookEntry, HookEventMap } from "../../src/core/index.js"; import { - flattenHooks, - groupHooks, hookIdentity, + renderNativeHooks, + scrapeNativeHooks, + supportsHookEvent, type NativeHooks, } from "../../src/agents/adapters/hooks-map.js"; @@ -13,9 +14,19 @@ const entries: HookEntry[] = [ { event: "SessionStart", type: "command", command: "date" }, ]; -describe("groupHooks", () => { +/** Identity map covering the events used in these tests. */ +const IDENTITY: HookEventMap = { + PreToolUse: "PreToolUse", + SessionStart: "SessionStart", + Stop: "Stop", +}; + +/** A rename map (canonical → native), like Gemini's. */ +const RENAME: HookEventMap = { PreToolUse: "BeforeTool", SessionStart: "SessionStart" }; + +describe("renderNativeHooks", () => { it("groups by (event, matcher) preserving first-seen order", () => { - const { hooks } = groupHooks(entries, { withMessage: true }); + const { hooks } = renderNativeHooks(entries, IDENTITY, { withMessage: true }); expect(Object.keys(hooks)).toEqual(["PreToolUse", "SessionStart"]); expect(hooks["PreToolUse"]).toHaveLength(1); expect(hooks["PreToolUse"]?.[0]?.matcher).toBe("git"); @@ -24,49 +35,74 @@ describe("groupHooks", () => { expect(hooks["SessionStart"]?.[0]?.matcher).toBeUndefined(); }); + it("keys by the agent's native event name", () => { + const { hooks } = renderNativeHooks(entries, RENAME, { withMessage: false }); + expect(Object.keys(hooks)).toEqual(["BeforeTool", "SessionStart"]); + }); + it("maps message → statusMessage only when withMessage is set", () => { expect( - groupHooks(entries, { withMessage: true }).hooks["PreToolUse"]?.[0]?.hooks[0], + renderNativeHooks(entries, IDENTITY, { withMessage: true }).hooks["PreToolUse"]?.[0] + ?.hooks[0], ).toHaveProperty("statusMessage", "guard"); expect( - groupHooks(entries, { withMessage: false }).hooks["PreToolUse"]?.[0]?.hooks[0], + renderNativeHooks(entries, IDENTITY, { withMessage: false }).hooks["PreToolUse"]?.[0] + ?.hooks[0], ).not.toHaveProperty("statusMessage"); }); - it("drops events outside the agent's supported set and counts them", () => { - const { hooks, dropped } = groupHooks(entries, { events: new Set(["PreToolUse"]) }); + it("skips events the agent doesn't map and counts them", () => { + const { hooks, dropped } = renderNativeHooks(entries, { PreToolUse: "PreToolUse" }); expect(Object.keys(hooks)).toEqual(["PreToolUse"]); expect(dropped).toBe(1); }); it("is deterministic (identical input → identical output)", () => { - expect(JSON.stringify(groupHooks(entries, { withMessage: true }))).toBe( - JSON.stringify(groupHooks(entries, { withMessage: true })), + expect(JSON.stringify(renderNativeHooks(entries, IDENTITY, { withMessage: true }))).toBe( + JSON.stringify(renderNativeHooks(entries, IDENTITY, { withMessage: true })), ); }); }); -describe("flattenHooks", () => { - it("round-trips a grouped record back to flat entries", () => { - const { hooks } = groupHooks(entries, { withMessage: true }); - expect(flattenHooks(hooks)).toEqual(entries); +describe("scrapeNativeHooks", () => { + it("round-trips a rendered record back to flat entries (identity map)", () => { + const { hooks } = renderNativeHooks(entries, IDENTITY, { withMessage: true }); + expect(scrapeNativeHooks(hooks, IDENTITY)).toEqual(entries); }); - it("skips unknown events and non-command handlers", () => { + it("translates native event names back to canonical (rename map)", () => { + const { hooks } = renderNativeHooks(entries, RENAME, { withMessage: false }); + expect(scrapeNativeHooks(hooks, RENAME)).toEqual([ + { event: "PreToolUse", matcher: "git", type: "command", command: "echo a" }, + { event: "PreToolUse", matcher: "git", type: "command", command: "echo b" }, + { event: "SessionStart", type: "command", command: "date" }, + ]); + }); + + it("skips unmapped events and non-command handlers", () => { const native: NativeHooks = { Nope: [{ hooks: [{ type: "command", command: "x" }] }], Stop: [ { hooks: [{ type: "command", command: "ok" }, { type: "http", command: "y" } as never] }, ], }; - const flat = flattenHooks(native); - expect(flat).toEqual([{ event: "Stop", type: "command", command: "ok" }]); + expect(scrapeNativeHooks(native, IDENTITY)).toEqual([ + { event: "Stop", type: "command", command: "ok" }, + ]); }); it("returns [] for non-object input", () => { - expect(flattenHooks(undefined)).toEqual([]); - expect(flattenHooks([])).toEqual([]); - expect(flattenHooks("nope")).toEqual([]); + expect(scrapeNativeHooks(undefined, IDENTITY)).toEqual([]); + expect(scrapeNativeHooks([], IDENTITY)).toEqual([]); + expect(scrapeNativeHooks("nope", IDENTITY)).toEqual([]); + }); +}); + +describe("supportsHookEvent", () => { + it("reflects presence of the event in the map", () => { + expect(supportsHookEvent(RENAME, "PreToolUse")).toBe(true); + expect(supportsHookEvent(RENAME, "Stop")).toBe(false); + expect(supportsHookEvent(undefined, "Stop")).toBe(false); }); }); diff --git a/test/domains/commands.test.ts b/test/domains/commands.test.ts index 4baf944..0c29a1f 100644 --- a/test/domains/commands.test.ts +++ b/test/domains/commands.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "node:fs/promises"; import path from "node:path"; import os from "node:os"; -import type { CommandContext, Domain } from "../../src/core/index.js"; +import type { CommandContext, Domain, Logger } from "../../src/core/index.js"; import { createLogger, createRepoFetcher, readConfigOrDefault } from "../../src/core/index.js"; import mcpDomain from "../../src/domains/mcp/index.js"; import hooksDomain from "../../src/domains/hooks/index.js"; @@ -11,13 +11,15 @@ import { agentsDomain } from "../../src/domains/agents/index.js"; let tmp: string; +let capturedLogger: Logger | undefined; + const ctxFor = (args: string[], extra: Record = {}): CommandContext => ({ agnosRoot: tmp, projectRoot: tmp, cacheDir: path.join(tmp, ".agnos", "cache"), configPath: path.join(tmp, "agnos.json"), statePath: path.join(tmp, ".agnos", "state.json"), - logger: createLogger({ quiet: true }), + logger: capturedLogger ?? createLogger({ quiet: true }), // Real fetcher: for `file:` (local) sources it just returns the absolute path, // so skills `add` discovery works without any network access. fetcher: createRepoFetcher({ projectRoot: tmp, cacheDir: path.join(tmp, ".agnos", "cache") }), @@ -35,6 +37,7 @@ const run = (d: Domain, name: string, args: string[], extra?: Record { tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agnos-cmd-")); + capturedLogger = undefined; await writeCfg({}); }); afterEach(async () => { @@ -187,6 +190,27 @@ describe("hooks subcommands", () => { await run(hooksDomain, "add", ["Stop", "echo bye"]); await expect(run(hooksDomain, "remove", [])).rejects.toThrow(/terminal|specify/i); }); + + it("warns which installed agents don't support the event when adding", async () => { + await writeCfg({ agents: ["claude-code", "codex", "gemini-cli"] }); + const warnings: string[] = []; + capturedLogger = { ...createLogger({ quiet: true }), warn: (m) => warnings.push(String(m)) }; + // Notification is unsupported by codex; SubagentStop is unsupported by gemini-cli. + await run(hooksDomain, "add", ["Notification", "notify.sh"]); + expect(warnings.some((w) => w.includes("Notification") && w.includes("OpenAI Codex"))).toBe( + true, + ); + await run(hooksDomain, "add", ["SubagentStop", "sub.sh"]); + expect(warnings.some((w) => w.includes("SubagentStop") && w.includes("Gemini CLI"))).toBe(true); + }); + + it("does not warn when every installed agent supports the event", async () => { + await writeCfg({ agents: ["claude-code", "codex", "gemini-cli"] }); + const warnings: string[] = []; + capturedLogger = { ...createLogger({ quiet: true }), warn: (m) => warnings.push(String(m)) }; + await run(hooksDomain, "add", ["PreToolUse", "guard.sh"]); // supported by all three + expect(warnings).toEqual([]); + }); }); describe("skills subcommands", () => { From 6b94a2363ea90f6e8745e1b6a38ee79ef869dbf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:52:10 +0200 Subject: [PATCH 09/11] feat(hooks): map every documented event of each adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-checking the three agents' current hook docs showed the maps were far from complete: Claude Code exposes 30 events (we mapped 9), Codex 10 (we mapped 7, missing SubagentStart, PermissionRequest, PostCompact), and Gemini's 11 were already covered. Widen the canonical registry to the full union — Claude's 30 names plus Gemini's three model-loop events (BeforeModel, AfterModel, BeforeToolSelection) — so nothing is dropped, and complete each adapter's hookEvents map. Add an identityEventMap helper for the agents that use the canonical names verbatim (Claude Code, Codex). Codex's docs list a handler statusMessage, so render its hooks with messages preserved. --- schema.json | 21 ++++++++++ src/agents/adapters/claude-code/index.ts | 50 ++++++++++++++++++------ src/agents/adapters/codex/index.ts | 28 +++++++------ src/agents/adapters/hooks-map.ts | 8 ++++ src/core/schema.ts | 23 +++++++++++ src/core/types/public.ts | 23 +++++++++++ 6 files changed, 128 insertions(+), 25 deletions(-) diff --git a/schema.json b/schema.json index 15212d0..702ab24 100644 --- a/schema.json +++ b/schema.json @@ -123,6 +123,27 @@ "PreCompact", "SessionStart", "SessionEnd", + "Setup", + "UserPromptExpansion", + "PermissionRequest", + "PermissionDenied", + "PostToolUseFailure", + "PostToolBatch", + "MessageDisplay", + "SubagentStart", + "TaskCreated", + "TaskCompleted", + "StopFailure", + "TeammateIdle", + "InstructionsLoaded", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "PostCompact", + "Elicitation", + "ElicitationResult", "BeforeModel", "AfterModel", "BeforeToolSelection" diff --git a/src/agents/adapters/claude-code/index.ts b/src/agents/adapters/claude-code/index.ts index 62254d3..13297d9 100644 --- a/src/agents/adapters/claude-code/index.ts +++ b/src/agents/adapters/claude-code/index.ts @@ -14,7 +14,7 @@ import { pickStringArray, readConfigOrDefault, } from "../../../core/index.js"; -import { renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; +import { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, @@ -28,18 +28,42 @@ const CLAUDE_MCP = ".mcp.json"; const CLAUDE_SETTINGS = path.join(".claude", "settings.json"); const CLAUDE_SKILLS_DIR = path.join(".claude", "skills"); -/** Claude Code uses the canonical event names verbatim (identity mapping). */ -const CLAUDE_HOOK_EVENTS: HookEventMap = { - PreToolUse: "PreToolUse", - PostToolUse: "PostToolUse", - UserPromptSubmit: "UserPromptSubmit", - Notification: "Notification", - Stop: "Stop", - SubagentStop: "SubagentStop", - PreCompact: "PreCompact", - SessionStart: "SessionStart", - SessionEnd: "SessionEnd", -}; +/** + * Claude Code exposes the widest native event set and uses the canonical event + * names verbatim (identity mapping). Canonical names are derived from this list. + */ +const CLAUDE_HOOK_EVENTS: HookEventMap = identityEventMap([ + "SessionStart", + "SessionEnd", + "Setup", + "InstructionsLoaded", + "ConfigChange", + "CwdChanged", + "UserPromptSubmit", + "UserPromptExpansion", + "Stop", + "StopFailure", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "TeammateIdle", + "Notification", + "MessageDisplay", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "PreCompact", + "PostCompact", + "Elicitation", + "ElicitationResult", +]); const claudeCode: AgentAdapter = { id: "claude-code", diff --git a/src/agents/adapters/codex/index.ts b/src/agents/adapters/codex/index.ts index 081a8c8..1076d6c 100644 --- a/src/agents/adapters/codex/index.ts +++ b/src/agents/adapters/codex/index.ts @@ -10,7 +10,7 @@ import type { ResolvedMcp, } from "../../../core/index.js"; import { importMcpServers, pickEnv, pickStringArray } from "../../../core/index.js"; -import { renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; +import { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, removePaths, writeIfChanged } from "../shared.js"; const CODEX_RULES = "AGENTS.md"; @@ -20,15 +20,18 @@ const CODEX_HOOKS = path.join(CODEX_DIR, "hooks.json"); const CODEX_SKILLS_DIR = path.join(".agents", "skills"); /** Codex uses the canonical event names verbatim, for the subset it understands. */ -const CODEX_HOOK_EVENTS: HookEventMap = { - PreToolUse: "PreToolUse", - PostToolUse: "PostToolUse", - UserPromptSubmit: "UserPromptSubmit", - PreCompact: "PreCompact", - SubagentStop: "SubagentStop", - Stop: "Stop", - SessionStart: "SessionStart", -}; +const CODEX_HOOK_EVENTS: HookEventMap = identityEventMap([ + "SessionStart", + "SubagentStart", + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "UserPromptSubmit", + "SubagentStop", + "Stop", +]); const codex: AgentAdapter = { id: "codex", @@ -68,8 +71,9 @@ const codex: AgentAdapter = { async function writeCodexHooks(entries: HookEntry[], ctx: MaterializeContext): Promise { const file = path.join(ctx.projectRoot, CODEX_HOOKS); - // Unsupported events are surfaced at `hooks add` time; render just skips them. - const { hooks } = renderNativeHooks(entries, CODEX_HOOK_EVENTS, { withMessage: false }); + // Codex supports a handler `statusMessage`; unsupported events are surfaced at + // `hooks add` time, so render just skips them. + const { hooks } = renderNativeHooks(entries, CODEX_HOOK_EVENTS, { withMessage: true }); if (Object.keys(hooks).length === 0) { if (!ctx.dryRun) await fs.rm(file, { force: true }).catch(() => {}); return; diff --git a/src/agents/adapters/hooks-map.ts b/src/agents/adapters/hooks-map.ts index 49902cd..eba18dd 100644 --- a/src/agents/adapters/hooks-map.ts +++ b/src/agents/adapters/hooks-map.ts @@ -29,6 +29,14 @@ export function supportsHookEvent(map: HookEventMap | undefined, event: HookEven return !!map && event in map; } +/** + * Build an identity {@link HookEventMap} for an agent that uses the canonical + * event names verbatim — the value equals the key for every listed event. + */ +export function identityEventMap(events: readonly HookEvent[]): HookEventMap { + return Object.fromEntries(events.map((e) => [e, e])); +} + /** * Render the canonical flat hook array into an agent's native record, keyed by * that agent's *native* event names via `map`. Groups by `(nativeEvent, matcher)` diff --git a/src/core/schema.ts b/src/core/schema.ts index 0e7d621..9f6f859 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -63,6 +63,29 @@ export const hookEventSchema = z.enum([ "PreCompact", "SessionStart", "SessionEnd", + // Additional Claude Code events (the widest native set). + "Setup", + "UserPromptExpansion", + "PermissionRequest", + "PermissionDenied", + "PostToolUseFailure", + "PostToolBatch", + "MessageDisplay", + "SubagentStart", + "TaskCreated", + "TaskCompleted", + "StopFailure", + "TeammateIdle", + "InstructionsLoaded", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "PostCompact", + "Elicitation", + "ElicitationResult", + // Gemini CLI model-loop events (no Claude/Codex counterpart). "BeforeModel", "AfterModel", "BeforeToolSelection", diff --git a/src/core/types/public.ts b/src/core/types/public.ts index a55a749..17e6ff5 100644 --- a/src/core/types/public.ts +++ b/src/core/types/public.ts @@ -97,6 +97,29 @@ export type HookEvent = | "PreCompact" | "SessionStart" | "SessionEnd" + // Additional Claude Code events (the widest native set). + | "Setup" + | "UserPromptExpansion" + | "PermissionRequest" + | "PermissionDenied" + | "PostToolUseFailure" + | "PostToolBatch" + | "MessageDisplay" + | "SubagentStart" + | "TaskCreated" + | "TaskCompleted" + | "StopFailure" + | "TeammateIdle" + | "InstructionsLoaded" + | "ConfigChange" + | "CwdChanged" + | "FileChanged" + | "WorktreeCreate" + | "WorktreeRemove" + | "PostCompact" + | "Elicitation" + | "ElicitationResult" + // Gemini CLI model-loop events (no Claude/Codex counterpart). | "BeforeModel" | "AfterModel" | "BeforeToolSelection"; From 96a37a962a49bce423c1303e2e9825ac43623d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 11:52:15 +0200 Subject: [PATCH 10/11] test(hooks): assert each adapter maps its full native event set Add a coverage suite enumerating every event each agent documents and asserting the adapter maps it (guarding against a future doc event going unmapped), plus negative checks for events an agent genuinely lacks. Update the codex round-trip to expect the now-preserved statusMessage. --- test/agents/adapters.test.ts | 88 +++++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/test/agents/adapters.test.ts b/test/agents/adapters.test.ts index 25c7665..e28a68a 100644 --- a/test/agents/adapters.test.ts +++ b/test/agents/adapters.test.ts @@ -4,9 +4,11 @@ import path from "node:path"; import os from "node:os"; import type { HookEntry, MaterializeContext, ResolvedMcp } from "../../src/core/index.js"; import { createLogger, SCHEMA_VERSION } from "../../src/core/index.js"; +import type { HookEvent } from "../../src/core/index.js"; import claudeCode from "../../src/agents/adapters/claude-code/index.js"; import codex from "../../src/agents/adapters/codex/index.js"; import geminiCli from "../../src/agents/adapters/gemini-cli/index.js"; +import { supportsHookEvent } from "../../src/agents/adapters/hooks-map.js"; let tmp: string; @@ -166,9 +168,9 @@ describe("codex adapter", () => { ]; await codex.render!["hooks"]!(withUnsupported, ctx); const scraped = (await codex.scrape!["hooks"]!(ctx)) as HookEntry[]; - // Notification dropped; message dropped (codex has no statusMessage) + // Notification dropped (unsupported by codex); message preserved via statusMessage. expect(scraped).toEqual([ - { event: "PreToolUse", matcher: "git", type: "command", command: "echo guard" }, + { event: "PreToolUse", matcher: "git", type: "command", command: "echo guard", message: "m" }, { event: "SessionStart", type: "command", command: "date" }, ]); }); @@ -320,3 +322,85 @@ describe("gemini-cli adapter", () => { expect(claims).not.toContain(path.join(tmp, ".gemini", "settings.json")); }); }); + +describe("adapter hook-event coverage", () => { + // The full native event set each agent documents. Every one must be mapped so + // the central registry drops nothing (guards against a doc event going stale). + const NATIVE: Record = { + "claude-code": [ + "SessionStart", + "SessionEnd", + "Setup", + "UserPromptSubmit", + "UserPromptExpansion", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "Notification", + "MessageDisplay", + "SubagentStart", + "SubagentStop", + "TaskCreated", + "TaskCompleted", + "Stop", + "StopFailure", + "TeammateIdle", + "InstructionsLoaded", + "ConfigChange", + "CwdChanged", + "FileChanged", + "WorktreeCreate", + "WorktreeRemove", + "PreCompact", + "PostCompact", + "Elicitation", + "ElicitationResult", + ], + codex: [ + "SessionStart", + "SubagentStart", + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "UserPromptSubmit", + "SubagentStop", + "Stop", + ], + // Gemini's native names differ; these are the canonical events they map to. + "gemini-cli": [ + "SessionStart", + "SessionEnd", + "UserPromptSubmit", + "Stop", + "BeforeModel", + "AfterModel", + "BeforeToolSelection", + "PreToolUse", + "PostToolUse", + "PreCompact", + "Notification", + ], + }; + + const adapters = { "claude-code": claudeCode, codex, "gemini-cli": geminiCli }; + + for (const [id, events] of Object.entries(NATIVE)) { + it(`${id} maps every one of its ${events.length} native events`, () => { + const map = adapters[id as keyof typeof adapters].hookEvents; + const unmapped = events.filter((e) => !supportsHookEvent(map, e)); + expect(unmapped).toEqual([]); + }); + } + + it("reflects the events each agent genuinely lacks", () => { + expect(supportsHookEvent(codex.hookEvents, "Notification")).toBe(false); + expect(supportsHookEvent(codex.hookEvents, "SessionEnd")).toBe(false); + expect(supportsHookEvent(geminiCli.hookEvents, "SubagentStop")).toBe(false); + expect(supportsHookEvent(claudeCode.hookEvents, "BeforeModel")).toBe(false); + }); +}); From d258ea6d385eef36e1eab4ebbc6ce3b5bc575b17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Gonz=C3=A1lez?= Date: Wed, 1 Jul 2026 12:17:23 +0200 Subject: [PATCH 11/11] fix(gemini-cli): never write empty-string mcp command/url A declaration missing `command` (allowed by the schema) rendered an empty httpUrl/url/command into .gemini/settings.json, producing invalid config. Skip such servers in writeGeminiMcp with a warning naming the server, and omit each field in toGeminiServer unless present, so no empty value is ever written. --- src/agents/adapters/gemini-cli/index.ts | 21 +++++++++++++++------ test/agents/adapters.test.ts | 14 ++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/agents/adapters/gemini-cli/index.ts b/src/agents/adapters/gemini-cli/index.ts index fcc9856..9fae176 100644 --- a/src/agents/adapters/gemini-cli/index.ts +++ b/src/agents/adapters/gemini-cli/index.ts @@ -149,15 +149,22 @@ async function updateSettingsKey( // ---------- mcp ---------- async function writeGeminiMcp(servers: ResolvedMcp[], ctx: MaterializeContext): Promise { + // `command` carries the executable (stdio) or the remote URL (http/sse); an + // entry without it can't produce valid Gemini config, so skip it with a warning. + const usable = servers.filter((m) => { + if (m.command) return true; + ctx.logger.warn(`gemini-cli: skipping mcp server "${m.name}" (no command/url)`); + return false; + }); const value = - servers.length > 0 - ? Object.fromEntries(servers.map((m) => [m.name, toGeminiServer(m)])) + usable.length > 0 + ? Object.fromEntries(usable.map((m) => [m.name, toGeminiServer(m)])) : undefined; await updateSettingsKey( "mcpServers", value, ctx, - `${GEMINI_SETTINGS} (${servers.length} servers)`, + `${GEMINI_SETTINGS} (${usable.length} servers)`, ); } @@ -196,23 +203,25 @@ function fromGeminiServer(name: string, entry: unknown): McpDeclaration | undefi return decl; } +// Callers guarantee `decl.command` is set (writeGeminiMcp filters); each field +// is still guarded so a stray empty value never lands in the config. function toGeminiServer(decl: ResolvedMcp): Record { if (decl.transport === "http") { return { - httpUrl: decl.command ?? "", + ...(decl.command ? { httpUrl: decl.command } : {}), ...(decl.headers ? { headers: decl.headers } : {}), ...(decl.env ? { env: decl.env } : {}), }; } if (decl.transport === "sse") { return { - url: decl.command ?? "", + ...(decl.command ? { url: decl.command } : {}), ...(decl.headers ? { headers: decl.headers } : {}), ...(decl.env ? { env: decl.env } : {}), }; } return { - command: decl.command ?? "", + ...(decl.command ? { command: decl.command } : {}), ...(decl.args ? { args: decl.args } : {}), ...(decl.env ? { env: decl.env } : {}), }; diff --git a/test/agents/adapters.test.ts b/test/agents/adapters.test.ts index e28a68a..6854370 100644 --- a/test/agents/adapters.test.ts +++ b/test/agents/adapters.test.ts @@ -272,6 +272,20 @@ describe("gemini-cli adapter", () => { expect(settings).toEqual({ theme: "dark" }); }); + it("skips an mcp server with no command/url rather than writing an empty value", async () => { + const ctx = ctxFor(tmp); + const servers: ResolvedMcp[] = [ + { name: "broken", transport: "http" }, // no command → invalid + { name: "ok", command: "npx", args: ["-y", "x"], transport: "stdio" }, + ]; + await geminiCli.render!["mcp"]!(servers, ctx); + const written = JSON.parse( + await fs.readFile(path.join(tmp, ".gemini", "settings.json"), "utf8"), + ); + expect(written.mcpServers.broken).toBeUndefined(); + expect(written.mcpServers.ok).toEqual({ command: "npx", args: ["-y", "x"] }); + }); + it("hooks render translates the event vocabulary and round-trips the mapped ones", async () => { const ctx = ctxFor(tmp); const withUnsupported: HookEntry[] = [