From 57bc0fabab7586444a9d7552c3719d2c1d4105e3 Mon Sep 17 00:00:00 2001 From: raj-khan Date: Sat, 24 Oct 2026 13:47:32 +0800 Subject: [PATCH] Add --agents selection to init, status, doctor and clean init records the agents it wires in canaries.json. status and doctor report each one, clean unregisters every agent, and the hook commands take --agent. A state file without the field still means Claude Code. --- ...lection-to-init-status-doctor-and-clean.md | 11 ++-- src/agents/claude.ts | 2 + src/agents/index.ts | 17 +++++ src/agents/types.ts | 2 + src/commands/clean.ts | 18 +++--- src/commands/doctor.ts | 40 ++++++++---- src/commands/init.ts | 44 ++++++++++--- src/commands/status.ts | 48 ++++++++------ src/index.ts | 49 ++++++++++++--- src/state.ts | 2 + test/agents.test.js | 63 +++++++++++++++++++ 11 files changed, 236 insertions(+), 60 deletions(-) create mode 100644 test/agents.test.js 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"/); +});