Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -20,8 +21,8 @@ init takes --agents claude,cursor,... and records the chosen agents in canaries.

## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #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
<!-- AC:END -->
2 changes: 2 additions & 0 deletions src/agents/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 17 additions & 0 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 };
2 changes: 2 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
18 changes: 11 additions & 7 deletions src/commands/clean.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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 });

Expand All @@ -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`,
);
Expand Down
40 changes: 27 additions & 13 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
44 changes: 34 additions & 10 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -23,16 +23,24 @@ 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);
}

// 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;
Expand Down Expand Up @@ -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);
Expand All @@ -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`,
);
Expand All @@ -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(
Expand Down
48 changes: 30 additions & 18 deletions src/commands/status.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
Expand All @@ -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`);
Expand Down
Loading
Loading