diff --git a/backlog/tasks/task-2 - Add-agents-selection-to-init-status-doctor-and-clean.md b/backlog/tasks/task-2 - Add-agents-selection-to-init-status-doctor-and-clean.md index ceffa93..09fa93b 100644 --- a/backlog/tasks/task-2 - Add-agents-selection-to-init-status-doctor-and-clean.md +++ b/backlog/tasks/task-2 - Add-agents-selection-to-init-status-doctor-and-clean.md @@ -1,9 +1,10 @@ --- id: TASK-2 title: 'Add --agents selection to init, status, doctor and clean' -status: To Do +status: Done assignee: [] created_date: '2026-10-24 05:41' +updated_date: '2026-10-24 05:47' labels: - multi-agent dependencies: @@ -20,8 +21,8 @@ init takes --agents claude,cursor,... and records the chosen agents in canaries. ## Acceptance Criteria -- [ ] #1 init --agents writes only the chosen agents' configs -- [ ] #2 status and doctor report each wired agent -- [ ] #3 clean unregisters every wired agent -- [ ] #4 Old state files without an agents field read as claude +- [x] #1 init --agents writes only the chosen agents' configs +- [x] #2 status and doctor report each wired agent +- [x] #3 clean unregisters every wired agent +- [x] #4 Old state files without an agents field read as claude diff --git a/src/agents/claude.ts b/src/agents/claude.ts index a50f5d4..670ffaf 100644 --- a/src/agents/claude.ts +++ b/src/agents/claude.ts @@ -16,6 +16,8 @@ export const claude: Agent = { configPath: SETTINGS_PATH, hookCommand: HOOK_COMMAND, sessionCommand: SESSION_COMMAND, + // Claude Code is always wired by default, whether or not .claude/ exists yet. + detect: () => true, wiring(root) { const [pre, session, post] = registeredSpecs(root, SETTINGS_PATH, SPECS); diff --git a/src/agents/index.ts b/src/agents/index.ts index ea1f578..c948e5a 100644 --- a/src/agents/index.ts +++ b/src/agents/index.ts @@ -1,4 +1,5 @@ import { claude } from "./claude.js"; +import type { State } from "../state.js"; import type { Agent } from "./types.js"; export type { Agent, ToolCall, Wiring } from "./types.js"; @@ -10,4 +11,20 @@ export function agentNamed(name: string): Agent | undefined { return AGENTS.find((agent) => agent.name === name); } +/** The agents named in a comma-separated list, or the first unknown name. */ +export function parseAgents(list: string): { agents: Agent[] } | { unknown: string } { + const agents: Agent[] = []; + for (const name of list.split(",").map((n) => n.trim()).filter((n) => n.length > 0)) { + const agent = agentNamed(name); + if (!agent) return { unknown: name }; + if (!agents.includes(agent)) agents.push(agent); + } + return { agents }; +} + +/** The agents init wired for this project. */ +export function wiredAgents(state: State): Agent[] { + return (state.agents ?? [claude.name]).flatMap((name) => agentNamed(name) ?? []); +} + export { claude }; diff --git a/src/agents/types.ts b/src/agents/types.ts index e776f68..8e4ad0b 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -32,6 +32,8 @@ export interface Agent { configPath: string; hookCommand: string; sessionCommand: string; + /** Whether the project already uses this agent, so init wires it by default. */ + detect(root: string): boolean; /** Throws SettingsError when the config file cannot be read safely. */ wiring(root: string): Wiring; register(root: string): boolean; diff --git a/src/commands/clean.ts b/src/commands/clean.ts index ffb6fc4..e0e117a 100644 --- a/src/commands/clean.ts +++ b/src/commands/clean.ts @@ -1,6 +1,6 @@ import { existsSync, rmSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { claude } from "../agents/index.js"; +import { AGENTS } from "../agents/index.js"; import { SettingsError } from "../settings.js"; import { isDecoyFile, readState, STATE_DIR } from "../state.js"; @@ -23,13 +23,17 @@ export function clean(root: string, options: { json: boolean; keepAlerts: boolea kept.push(decoy.path); } } + // Every agent, not only the ones recorded as wired: a hook left pointing at a + // removed install would fail open without saying so. let hookRemoved = false; let settingsProblem: string | null = null; - try { - hookRemoved = claude.unregister(root); - } catch (error) { - if (!(error instanceof SettingsError)) throw error; - settingsProblem = error.message; + for (const agent of AGENTS) { + try { + if (agent.unregister(root)) hookRemoved = true; + } catch (error) { + if (!(error instanceof SettingsError)) throw error; + settingsProblem ??= error.message; + } } if (!options.keepAlerts) rmSync(join(root, STATE_DIR), { recursive: true, force: true }); @@ -53,7 +57,7 @@ export function clean(root: string, options: { json: boolean; keepAlerts: boolea ); } if (settingsProblem) process.stdout.write(`${settingsProblem}\n`); - else process.stdout.write(hookRemoved ? "Unregistered the PreToolUse hook.\n" : "No hook to unregister.\n"); + else process.stdout.write(hookRemoved ? "Unregistered the hooks.\n" : "No hook to unregister.\n"); process.stdout.write( options.keepAlerts ? `Kept ${STATE_DIR}/alerts.jsonl.\n` : `Removed ${STATE_DIR}/.\n`, ); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3e7bf6b..07c900e 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,7 +1,7 @@ import { spawnSync } from "node:child_process"; -import { claude, type Agent } from "../agents/index.js"; +import { wiredAgents, type Agent } from "../agents/index.js"; import { SettingsError } from "../settings.js"; -import { isDecoyFile, readState, type Decoy } from "../state.js"; +import { isDecoyFile, readState, type Decoy, type State } from "../state.js"; interface Check { name: string; @@ -44,16 +44,9 @@ function decoyChecks(root: string, decoys: Decoy[]): Check { }; } -export function doctor(root: string, options: { json: boolean }, agent: Agent = claude): number { - const state = readState(root); - if (!state) { - const message = "canaryhook is not initialized here. Run: canaryhook init"; - if (options.json) process.stdout.write(`${JSON.stringify({ ok: false, message })}\n`); - else process.stderr.write(`${message}\n`); - return 1; - } - - const checks: Check[] = [decoyChecks(root, state.decoys)]; +/** Whether one agent's hook is registered, and denies and passes as it should. */ +function agentChecks(agent: Agent, root: string, state: State): Check[] { + const checks: Check[] = []; try { const registered = agent.wiring(root).pre; @@ -91,6 +84,27 @@ export function doctor(root: string, options: { json: boolean }, agent: Agent = : `an ordinary read came back ${quiet.decision}`, }); + return checks; +} + +export function doctor(root: string, options: { json: boolean }): number { + const state = readState(root); + if (!state) { + const message = "canaryhook is not initialized here. Run: canaryhook init"; + if (options.json) process.stdout.write(`${JSON.stringify({ ok: false, message })}\n`); + else process.stderr.write(`${message}\n`); + return 1; + } + + const agents = wiredAgents(state); + const checks: Check[] = [decoyChecks(root, state.decoys)]; + for (const agent of agents) { + for (const check of agentChecks(agent, root, state)) { + // Name the agent only when there is more than one to tell apart. + checks.push(agents.length > 1 ? { ...check, name: `${agent.label}: ${check.name}` } : check); + } + } + const ok = checks.every((check) => check.ok); if (options.json) { @@ -104,7 +118,7 @@ export function doctor(root: string, options: { json: boolean }, agent: Agent = process.stdout.write( ok ? "\nThe hook is wired and firing.\n" - : `\nSomething is wrong. If ${agent.hookCommand} cannot run, the hook fails open and\n` + + : "\nSomething is wrong. If the hook command cannot run, the hook fails open and\n" + "nothing is being watched. Reinstall canaryhook, or run canaryhook init --force.\n", ); return ok ? 0 : 1; diff --git a/src/commands/init.ts b/src/commands/init.ts index aeca5fa..ab2d8b6 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { buildDecoys } from "../canaries.js"; -import { claude } from "../agents/index.js"; +import { AGENTS, claude, type Agent } from "../agents/index.js"; import { SettingsError } from "../settings.js"; import { isDecoyFile, readState, writeState, STATE_DIR, type Decoy } from "../state.js"; @@ -23,7 +23,10 @@ function fail(message: string, json: boolean): number { return 1; } -export function init(root: string, options: { json: boolean; commit: boolean; force: boolean }): number { +export function init( + root: string, + options: { json: boolean; commit: boolean; force: boolean; agents?: Agent[] }, +): number { const existing = readState(root); if (existing && !options.force) { return fail(`canaryhook is already initialized in ${root}. Use --force to replant.`, options.json); @@ -31,8 +34,13 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo // Read the settings before planting anything, so a settings file canaryhook // cannot parse stops the run instead of leaving half an install behind. + // Without --agents, wire Claude Code plus any agent the project already + // uses, and keep whatever a previous init wired. + const previous = existing?.agents ?? []; + const agents = + options.agents ?? AGENTS.filter((agent) => agent.detect(root) || previous.includes(agent.name)); try { - claude.wiring(root); + for (const agent of agents) agent.wiring(root); } catch (error) { if (error instanceof SettingsError) return fail(error.message, options.json); throw error; @@ -63,12 +71,24 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo } const decoys = built.map((b) => b.decoy); - const hookAdded = claude.register(root); + // An agent dropped from the list on a replant is unwired, so it is not left + // pointing at canaryhook without being reported anywhere. + for (const agent of AGENTS) { + if (agents.includes(agent)) continue; + try { + agent.unregister(root); + } catch (error) { + // A config canaryhook cannot read is not one it wrote to. + if (!(error instanceof SettingsError)) throw error; + } + } + const added = agents.map((agent) => agent.register(root)); writeState(root, { version: 1, created: new Date().toISOString(), decoys, hookRegistered: true, + agents: agents.map((agent) => agent.name), }); const ignored = options.commit ? false : updateGitignore(root, decoys); @@ -78,8 +98,9 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo `${JSON.stringify({ ok: true, planted: decoys.map((d) => d.path), - hookRegistered: hookAdded, + hookRegistered: added.some(Boolean), settings: claude.configPath, + agents: agents.map((agent, i) => ({ name: agent.name, config: agent.configPath, registered: added[i] })), gitignoreUpdated: ignored, })}\n`, ); @@ -88,11 +109,14 @@ export function init(root: string, options: { json: boolean; commit: boolean; fo process.stdout.write(`Planted ${decoys.length} decoys:\n`); for (const decoy of decoys) process.stdout.write(` ${decoy.path} (${decoy.kind})\n`); - process.stdout.write( - hookAdded - ? `\nRegistered the PreToolUse, PostToolUse and SessionStart hooks in ${claude.configPath}.\n` - : `\nThe hooks were already registered in ${claude.configPath}.\n`, - ); + process.stdout.write("\n"); + agents.forEach((agent, i) => { + process.stdout.write( + added[i] + ? `Registered the hooks for ${agent.label} in ${agent.configPath}.\n` + : `The hooks for ${agent.label} were already registered in ${agent.configPath}.\n`, + ); + }); if (ignored) process.stdout.write("Added the decoys and .canaryhook/ to .gitignore.\n"); if (options.commit) process.stdout.write("Left .gitignore alone, as --commit was passed.\n"); process.stdout.write( diff --git a/src/commands/status.ts b/src/commands/status.ts index 8494722..71d8622 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; -import { claude } from "../agents/index.js"; +import { claude, wiredAgents } from "../agents/index.js"; import { SettingsError } from "../settings.js"; import { readAlerts, readState } from "../state.js"; @@ -18,29 +18,37 @@ export function status(root: string, options: { json: boolean }): number { kind: decoy.kind, planted: existsSync(join(root, decoy.path)), })); - let hook = false; - let sessionHook = false; - let outputHook = false; - let settingsProblem: string | null = null; - try { - ({ pre: hook, session: sessionHook, post: outputHook } = claude.wiring(root)); - } catch (error) { - if (!(error instanceof SettingsError)) throw error; - settingsProblem = error.message; - } + const agents = wiredAgents(state).map((agent) => { + try { + return { agent, ...agent.wiring(root), problem: null as string | null }; + } catch (error) { + if (!(error instanceof SettingsError)) throw error; + return { agent, pre: false, post: false, session: false, problem: error.message }; + } + }); + const all = (key: "pre" | "post" | "session") => agents.every((a) => a[key]); + const settingsProblem = agents.find((a) => a.problem !== null)?.problem ?? null; const alerts = readAlerts(root); const missing = decoys.filter((d) => !d.planted).length; - const healthy = hook && sessionHook && outputHook && missing === 0; + const healthy = all("pre") && all("session") && all("post") && missing === 0; if (options.json) { process.stdout.write( `${JSON.stringify({ initialized: true, created: state.created, - hookRegistered: hook, - sessionHookRegistered: sessionHook, - outputHookRegistered: outputHook, + hookRegistered: all("pre"), + sessionHookRegistered: all("session"), + outputHookRegistered: all("post"), settings: claude.configPath, + agents: agents.map((a) => ({ + name: a.agent.name, + config: a.agent.configPath, + hookRegistered: a.pre, + sessionHookRegistered: a.session, + outputHookRegistered: a.post, + settingsProblem: a.problem, + })), decoys, alerts: alerts.length, settingsProblem, @@ -50,10 +58,14 @@ export function status(root: string, options: { json: boolean }): number { return healthy ? 0 : 1; } + const mark = (ok: boolean) => (ok ? "registered" : "NOT registered"); process.stdout.write(`Initialized ${state.created}\n`); - process.stdout.write(`Hook ${hook ? `registered in ${claude.configPath}` : "NOT registered"}\n`); - process.stdout.write(`Session hook ${sessionHook ? "registered" : "NOT registered"}\n`); - process.stdout.write(`Output hook ${outputHook ? "registered" : "NOT registered"}\n`); + for (const a of agents) { + process.stdout.write(`${a.agent.label} (${a.agent.configPath})\n`); + process.stdout.write(` Hook ${mark(a.pre)}\n`); + process.stdout.write(` Session hook ${mark(a.session)}\n`); + process.stdout.write(` Output hook ${mark(a.post)}\n`); + } process.stdout.write("Decoys\n"); for (const decoy of decoys) { process.stdout.write(` ${decoy.planted ? "ok " : "MISSING"} ${decoy.path}\n`); diff --git a/src/index.ts b/src/index.ts index a1eac2b..1fa9dfc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import { init } from "./commands/init.js"; import { status } from "./commands/status.js"; import { verify } from "./commands/verify.js"; import { sessionStart } from "./commands/session.js"; +import { AGENTS, agentNamed, claude, parseAgents } from "./agents/index.js"; import { runHook } from "./hook.js"; import { findRoot } from "./state.js"; import { VERSION } from "./version.js"; @@ -27,6 +28,8 @@ Commands Options --json Machine-readable output + --agents init: agents to wire, comma-separated (claude); default is + Claude Code plus any agent the project already uses --commit init: track the decoys in git instead of ignoring them --force init: replant over an existing setup --keep-alerts clean: keep .canaryhook/alerts.jsonl @@ -38,7 +41,7 @@ Docs: https://github.com/aiagentflow/canaryhook /** Flags each command accepts beyond --json, -h and -v. */ const FLAGS: Record = { - init: ["--commit", "--force"], + init: ["--commit", "--force", "--agents"], clean: ["--keep-alerts"], status: [], alerts: [], @@ -55,13 +58,31 @@ function unknownArg(command: string, args: string[]): string | null { const allowed = FLAGS[command]; if (allowed === undefined) return null; const known = new Set([...allowed, "--json", "-h", "--help", "-v", "--version"]); - return args.find((arg) => (arg.startsWith("-") ? !known.has(arg) : arg !== command)) ?? null; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + const flag = arg.split("=")[0]; + if (VALUE_FLAGS.has(flag) && known.has(flag)) { + if (!arg.includes("=")) i += 1; // the value is the next argument + continue; + } + if (arg.startsWith("-") ? !known.has(arg) : arg !== command) return arg; + } + return null; +} + +/** Flags that take a value, as `--flag value` or `--flag=value`. */ +const VALUE_FLAGS = new Set(["--agents", "--agent"]); + +function valueOf(args: string[], flag: string): string | undefined { + const at = args.findIndex((arg) => arg === flag || arg.startsWith(`${flag}=`)); + if (at < 0) return undefined; + return args[at].includes("=") ? args[at].slice(flag.length + 1) : (args[at + 1] ?? ""); } async function main(argv: string[]): Promise { const args = argv.slice(2); const has = (flag: string) => args.includes(flag); - const command = args.find((a) => !a.startsWith("-")); + const command = args.find((a, i) => !a.startsWith("-") && !(i > 0 && VALUE_FLAGS.has(args[i - 1]))); // init plants where it is run; every other command works on the nearest // initialized project, so running one from a subdirectory sees the real state. const root = command === "init" ? process.cwd() : (findRoot(process.cwd()) ?? process.cwd()); @@ -82,13 +103,27 @@ async function main(argv: string[]): Promise { return 1; } + // The hook commands never fail: an unknown --agent falls back to Claude Code. + const hookAgent = agentNamed(valueOf(args, "--agent") ?? claude.name) ?? claude; + switch (command) { case "hook": - return runHook(); + return runHook(hookAgent); case "session-start": - return sessionStart(root); - case "init": - return init(root, { json, commit: has("--commit"), force: has("--force") }); + return sessionStart(root, hookAgent); + case "init": { + const list = valueOf(args, "--agents"); + const parsed = list === undefined ? undefined : parseAgents(list); + if (parsed && "unknown" in parsed) { + process.stderr.write(`Unknown agent: ${parsed.unknown}. Known agents: ${AGENTS.map((a) => a.name).join(", ")}.\n`); + return 1; + } + if (parsed && parsed.agents.length === 0) { + process.stderr.write("--agents needs at least one agent.\n"); + return 1; + } + return init(root, { json, commit: has("--commit"), force: has("--force"), agents: parsed?.agents }); + } case "status": return status(root, { json }); case "alerts": diff --git a/src/state.ts b/src/state.ts index de2fc8a..b3d7b57 100644 --- a/src/state.ts +++ b/src/state.ts @@ -19,6 +19,8 @@ export interface State { created: string; decoys: Decoy[]; hookRegistered: boolean; + /** Names of the agents whose hooks init wired; absent in 0.3.0 and earlier, which meant Claude Code. */ + agents?: string[]; } export interface Alert { diff --git a/test/agents.test.js b/test/agents.test.js new file mode 100644 index 0000000..eed9697 --- /dev/null +++ b/test/agents.test.js @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { parseAgents, wiredAgents } from "../dist/agents/index.js"; + +const CLI = fileURLToPath(new URL("../dist/index.js", import.meta.url)); + +function run(args, cwd, input) { + return spawnSync(process.execPath, [CLI, ...args], { cwd, input, encoding: "utf8" }); +} + +const fresh = () => realpathSync(mkdtempSync(join(tmpdir(), "canaryhook-"))); +const state = (root) => JSON.parse(readFileSync(join(root, ".canaryhook", "canaries.json"), "utf8")); + +test("parses a comma-separated agent list and reports an unknown name", () => { + assert.deepEqual( + parseAgents("claude, claude").agents.map((a) => a.name), + ["claude"], + ); + assert.deepEqual(parseAgents("claude,vim"), { unknown: "vim" }); +}); + +test("a state file from 0.3.0 without an agents field means Claude Code", () => { + assert.deepEqual(wiredAgents({ decoys: [] }).map((a) => a.name), ["claude"]); +}); + +test("init records the agents it wired, in either flag form", () => { + for (const args of [["init", "--agents", "claude"], ["init", "--agents=claude"], ["init"]]) { + const root = fresh(); + assert.equal(run(args, root).status, 0, args.join(" ")); + assert.deepEqual(state(root).agents, ["claude"]); + } +}); + +test("init rejects an unknown or empty agent list", () => { + const root = fresh(); + const result = run(["init", "--agents", "vim"], root); + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown agent: vim/); + assert.equal(run(["init", "--agents="], root).status, 1); +}); + +test("status reads an old state file as Claude Code", () => { + const root = fresh(); + run(["init"], root); + const old = state(root); + delete old.agents; + writeFileSync(join(root, ".canaryhook", "canaries.json"), JSON.stringify(old)); + const result = JSON.parse(run(["status", "--json"], root).stdout); + assert.deepEqual(result.agents.map((a) => a.name), ["claude"]); + assert.equal(result.healthy, true); +}); + +test("the hook falls back to Claude Code for an unknown --agent", () => { + const root = fresh(); + run(["init"], root); + const payload = JSON.stringify({ cwd: root, tool_name: "Read", tool_input: { file_path: ".env.backup" } }); + assert.match(run(["hook", "--agent", "vim"], root, payload).stdout, /"permissionDecision":"deny"/); +});