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 diff --git a/schema.json b/schema.json index 417bddc..702ab24 100644 --- a/schema.json +++ b/schema.json @@ -122,7 +122,31 @@ "SubagentStop", "PreCompact", "SessionStart", - "SessionEnd" + "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" ], "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..13297d9 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 { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, @@ -27,10 +28,48 @@ const CLAUDE_MCP = ".mcp.json"; const CLAUDE_SETTINGS = path.join(".claude", "settings.json"); const CLAUDE_SKILLS_DIR = path.join(".claude", "skills"); +/** + * 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", displayName: "Claude Code", paths: { skillsDir: CLAUDE_SKILLS_DIR, rulesFilename: CLAUDE_RULES, rulesRoot: "." }, + hookEvents: CLAUDE_HOOK_EVENTS, render: { async rules(state, ctx) { @@ -49,7 +88,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 +141,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..1076d6c 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 { identityEventMap, renderNativeHooks, scrapeNativeHooks } from "../hooks-map.js"; import { linkSkills, mirrorRules, removePaths, writeIfChanged } from "../shared.js"; const CODEX_RULES = "AGENTS.md"; @@ -19,21 +19,25 @@ 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([ +/** Codex uses the canonical event names verbatim, for the subset it understands. */ +const CODEX_HOOK_EVENTS: HookEventMap = identityEventMap([ + "SessionStart", + "SubagentStart", "PreToolUse", + "PermissionRequest", "PostToolUse", - "UserPromptSubmit", "PreCompact", + "PostCompact", + "UserPromptSubmit", "SubagentStop", "Stop", - "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 +58,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 +71,9 @@ 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`, - ); - } + // 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/gemini-cli/index.ts b/src/agents/adapters/gemini-cli/index.ts new file mode 100644 index 0000000..9fae176 --- /dev/null +++ b/src/agents/adapters/gemini-cli/index.ts @@ -0,0 +1,268 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + AgentAdapter, + HookEntry, + HookEventMap, + MaterializeContext, + McpDeclaration, + ResolvedMcp, +} from "../../../core/index.js"; +import { + importMcpServers, + pickEnv, + pickStringArray, + readConfigOrDefault, +} from "../../../core/index.js"; +import { renderNativeHooks, scrapeNativeHooks } 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' 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 GEMINI_HOOK_EVENTS: HookEventMap = { + PreToolUse: "BeforeTool", + PostToolUse: "AfterTool", + UserPromptSubmit: "BeforeAgent", + Stop: "AfterAgent", + PreCompact: "PreCompress", + Notification: "Notification", + SessionStart: "SessionStart", + SessionEnd: "SessionEnd", + BeforeModel: "BeforeModel", + AfterModel: "AfterModel", + BeforeToolSelection: "BeforeToolSelection", +}; + +/** + * Gemini CLI (Google's official terminal agent). Reads hierarchical `GEMINI.md` + * 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: { skillsDir: GEMINI_SKILLS_DIR, rulesFilename: GEMINI_RULES, rulesRoot: "." }, + hookEvents: GEMINI_HOOK_EVENTS, + + render: { + async rules(state, ctx) { + await mirrorRules(state as string[], GEMINI_RULES, ctx); + }, + 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_SKILLS_DIR), + ]; + }, +}; + +// ---------- shared settings.json (mcp + hooks live in the same file) ---------- + +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; + } +} + +/** + * 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 ${key}`); + return; + } + if (value !== undefined) { + settings.data[key] = value; + } else { + 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, label); +} + +// ---------- 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 = + usable.length > 0 + ? Object.fromEntries(usable.map((m) => [m.name, toGeminiServer(m)])) + : undefined; + await updateSettingsKey( + "mcpServers", + value, + ctx, + `${GEMINI_SETTINGS} (${usable.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; +} + +// 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 { + ...(decl.command ? { httpUrl: decl.command } : {}), + ...(decl.headers ? { headers: decl.headers } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; + } + if (decl.transport === "sse") { + return { + ...(decl.command ? { url: decl.command } : {}), + ...(decl.headers ? { headers: decl.headers } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; + } + return { + ...(decl.command ? { command: decl.command } : {}), + ...(decl.args ? { args: decl.args } : {}), + ...(decl.env ? { env: decl.env } : {}), + }; +} + +// ---------- 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`. + // 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(hooks).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 []; + } + return scrapeNativeHooks(settings.data["hooks"], GEMINI_HOOK_EVENTS); +} + +// ---------- 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; diff --git a/src/agents/adapters/hooks-map.ts b/src/agents/adapters/hooks-map.ts index d40f71b..eba18dd 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,46 @@ 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; +} + +/** + * 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])); +} + /** - * 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 +74,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 +98,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/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/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..9f6f859 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,32 @@ 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 4b6c6d5..17e6ff5 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,40 @@ export type HookEvent = | "SubagentStop" | "PreCompact" | "SessionStart" - | "SessionEnd"; + | "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"; + +/** + * 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 +667,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). */ diff --git a/src/domains/agents/index.ts b/src/domains/agents/index.ts index 920dde3..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 = @@ -24,7 +26,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 { @@ -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] }); }, }, diff --git a/test/agents/adapters.test.ts b/test/agents/adapters.test.ts index 7f2fd04..6854370 100644 --- a/test/agents/adapters.test.ts +++ b/test/agents/adapters.test.ts @@ -3,9 +3,12 @@ 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 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; @@ -165,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" }, ]); }); @@ -179,3 +182,239 @@ 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("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[] = [ + { 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"), + 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", "skills")); + 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); + }); +}); 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/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(); }); }); 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", () => {