1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::Subcommand;
7use serde_json::json;
8
9use crate::config::Config;
10use crate::error::ExitError;
11use crate::hooks::HookRegistry;
12use crate::subprocess::run_command;
13
14pub(crate) const PI_EDICT_HOOKS_EXTENSION: &str =
15 include_str!("../templates/extensions/edict-hooks.ts");
16
17#[derive(Debug, Subcommand)]
18pub enum HooksCommand {
19 Install {
21 #[arg(long)]
23 project_root: Option<PathBuf>,
24 },
25 Uninstall,
27 Audit {
29 #[arg(long)]
31 project_root: Option<PathBuf>,
32 #[arg(long, value_enum, default_value_t = super::doctor::OutputFormat::Pretty)]
34 format: super::doctor::OutputFormat,
35 },
36 Run {
38 hook_name: String,
40 #[arg(long)]
42 project_root: Option<PathBuf>,
43 #[arg(long)]
45 release: bool,
46 },
47}
48
49impl HooksCommand {
50 pub fn execute(&self) -> anyhow::Result<()> {
51 match self {
52 HooksCommand::Install { project_root } => install_hooks(project_root.as_deref()),
53 HooksCommand::Uninstall => uninstall_hooks(),
54 HooksCommand::Audit {
55 project_root,
56 format,
57 } => audit_hooks(project_root.as_deref(), *format),
58 HooksCommand::Run {
59 hook_name,
60 release,
61 ..
62 } => run_hook(hook_name, *release),
63 }
64 }
65}
66
67fn install_hooks(project_root: Option<&Path>) -> Result<()> {
71 let home = dirs::home_dir().context("could not determine home directory")?;
73 let settings_path = home.join(".claude/settings.json");
74 install_global_claude_hooks(&settings_path)?;
75 println!("Installed global hooks in {}", settings_path.display());
76
77 let pi_ext_path = home.join(".pi/agent/extensions/edict-hooks.ts");
79 install_pi_extension(&pi_ext_path)?;
80 println!("Installed Pi extension at {}", pi_ext_path.display());
81
82 if let Some(root) = project_root {
84 let root = resolve_project_root(Some(root))?;
85 let config = load_config(&root)?;
86 register_botbus_hooks(&root, &config)?;
87 } else if let Ok(root) = resolve_project_root(None) {
88 if let Ok(config) = load_config(&root) {
89 register_botbus_hooks(&root, &config)?;
90 }
91 }
92
93 println!("Hooks installed successfully");
94 Ok(())
95}
96
97fn uninstall_hooks() -> Result<()> {
99 let home = dirs::home_dir().context("could not determine home directory")?;
100
101 let settings_path = home.join(".claude/settings.json");
103 if settings_path.exists() {
104 let content = fs::read_to_string(&settings_path)
105 .with_context(|| format!("reading {}", settings_path.display()))?;
106 let mut settings: serde_json::Value =
107 serde_json::from_str(&content).unwrap_or_else(|_| json!({}));
108
109 if let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
110 for (_event, entries) in hooks.iter_mut() {
111 if let Some(arr) = entries.as_array_mut() {
112 arr.retain(|entry| !is_botbox_hook_entry(entry));
113 }
114 }
115 hooks.retain(|_, v| {
117 v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
118 });
119 }
120
121 if settings
123 .get("hooks")
124 .and_then(|h| h.as_object())
125 .is_some_and(|h| h.is_empty())
126 {
127 settings.as_object_mut().unwrap().remove("hooks");
128 }
129
130 fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
131 println!("Removed botbox hooks from {}", settings_path.display());
132 }
133
134 let pi_ext_path = home.join(".pi/agent/extensions/edict-hooks.ts");
136 if pi_ext_path.exists() {
137 fs::remove_file(&pi_ext_path)?;
138 println!("Removed {}", pi_ext_path.display());
139 }
140
141 println!("Hooks uninstalled successfully");
142 Ok(())
143}
144
145fn audit_hooks(project_root: Option<&Path>, format: super::doctor::OutputFormat) -> Result<()> {
146 let home = dirs::home_dir().context("could not determine home directory")?;
147 let mut issues = Vec::new();
148
149 let settings_path = home.join(".claude/settings.json");
151 if !settings_path.exists() {
152 issues.push("Missing ~/.claude/settings.json".to_string());
153 } else {
154 let content = fs::read_to_string(&settings_path)
155 .with_context(|| format!("reading {}", settings_path.display()))?;
156 let settings: serde_json::Value = serde_json::from_str(&content)
157 .with_context(|| format!("parsing {}", settings_path.display()))?;
158
159 for hook_entry in &HookRegistry::all() {
160 let found = hook_entry.events.iter().any(|event| {
161 settings["hooks"][event.as_str()]
162 .as_array()
163 .is_some_and(|arr| {
164 arr.iter().any(|entry| {
165 entry["hooks"].as_array().is_some_and(|hooks| {
166 hooks.iter().any(|h| is_botbox_hook_command(h, hook_entry.name))
167 })
168 })
169 })
170 });
171
172 if !found {
173 issues.push(format!(
174 "Hook '{}' not registered in ~/.claude/settings.json",
175 hook_entry.name
176 ));
177 }
178 }
179 }
180
181 if let Some(root) = project_root
183 .and_then(|p| resolve_project_root(Some(p)).ok())
184 .or_else(|| resolve_project_root(None).ok())
185 {
186 if let Ok(config) = load_config(&root) {
187 if config.tools.botbus {
188 check_botbus_hooks(&root, &config, &mut issues)?;
189 }
190 }
191 }
192
193 match format {
194 super::doctor::OutputFormat::Json => {
195 let result = json!({
196 "issues": issues,
197 "status": if issues.is_empty() { "ok" } else { "issues_found" }
198 });
199 println!("{}", serde_json::to_string_pretty(&result)?);
200 }
201 super::doctor::OutputFormat::Pretty | super::doctor::OutputFormat::Text => {
202 if issues.is_empty() {
203 println!("✓ All hooks configured correctly");
204 } else {
205 eprintln!("Hook audit found {} issue(s):", issues.len());
206 for issue in &issues {
207 eprintln!(" - {issue}");
208 }
209 return Err(ExitError::AuditFailed.into());
210 }
211 }
212 }
213
214 Ok(())
215}
216
217fn run_hook(hook_name: &str, release: bool) -> Result<()> {
218 let stdin_input = {
220 use std::io::Read;
221 let mut buf = String::new();
222 let mut handle = std::io::stdin().take(64 * 1024);
223 handle.read_to_string(&mut buf).ok();
224 if buf.is_empty() { None } else { Some(buf) }
225 };
226
227 match hook_name {
228 "session-start" => crate::hooks::run_session_start(),
229 "post-tool-call" => crate::hooks::run_post_tool_call(stdin_input.as_deref()),
230 "session-end" => crate::hooks::run_session_end(),
231 "init-agent" | "check-jj" => crate::hooks::run_session_start(),
233 "check-bus-inbox" => crate::hooks::run_post_tool_call(stdin_input.as_deref()),
234 "claim-agent" => {
235 if release {
236 crate::hooks::run_session_end()
237 } else {
238 crate::hooks::run_session_start()
240 }
241 }
242 _ => Err(ExitError::Config(format!("unknown hook: {hook_name}")).into()),
243 }
244}
245
246fn resolve_project_root(project_root: Option<&Path>) -> Result<PathBuf> {
249 let path = project_root
250 .map(|p| p.to_path_buf())
251 .unwrap_or_else(|| std::env::current_dir().expect("get cwd"));
252 let canonical = path
253 .canonicalize()
254 .with_context(|| format!("resolving project root: {}", path.display()))?;
255 match crate::config::find_config_in_project(&canonical) {
256 Ok((_config_path, config_dir)) => Ok(config_dir),
257 Err(_) => anyhow::bail!(
258 "no .edict.toml or .botbox.toml found at {} or ws/default/ — is this an edict project?",
259 canonical.display()
260 ),
261 }
262}
263
264fn load_config(root: &Path) -> Result<Config> {
265 let (config_path, _config_dir) = crate::config::find_config_in_project(root)
266 .map_err(|_| ExitError::Config("no .edict.toml or .botbox.toml found".into()))?;
267 Config::load(&config_path)
268}
269
270fn install_global_claude_hooks(settings_path: &Path) -> Result<()> {
272 let hooks = HookRegistry::all();
273
274 let mut hooks_config: HashMap<String, Vec<serde_json::Value>> = HashMap::new();
275 for hook_entry in &hooks {
276 for event in hook_entry.events.iter() {
277 let entry = json!({
278 "matcher": "",
279 "hooks": [{
280 "type": "command",
281 "command": format!("edict hooks run {}", hook_entry.name)
282 }]
283 });
284 hooks_config
285 .entry(event.as_str().to_string())
286 .or_default()
287 .push(entry);
288 }
289 }
290
291 let mut settings = if settings_path.exists() {
293 let content = fs::read_to_string(settings_path)
294 .with_context(|| format!("reading {}", settings_path.display()))?;
295 serde_json::from_str::<serde_json::Value>(&content).unwrap_or_else(|_| json!({}))
296 } else {
297 json!({})
298 };
299
300 let existing_hooks = settings.get("hooks").cloned().unwrap_or_else(|| json!({}));
302 let mut merged_hooks = existing_hooks.as_object().cloned().unwrap_or_default();
303
304 for (event, new_entries) in &hooks_config {
305 let existing_entries: Vec<serde_json::Value> = merged_hooks
306 .get(event)
307 .and_then(|v| v.as_array())
308 .map(|arr| {
309 arr.iter()
310 .filter(|entry| !is_botbox_hook_entry(entry))
311 .cloned()
312 .collect()
313 })
314 .unwrap_or_default();
315
316 let mut combined = existing_entries;
317 combined.extend(new_entries.iter().cloned());
318 merged_hooks.insert(event.clone(), serde_json::Value::Array(combined));
319 }
320
321 settings["hooks"] = serde_json::Value::Object(merged_hooks);
322
323 if let Some(parent) = settings_path.parent() {
324 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
325 }
326
327 fs::write(settings_path, serde_json::to_string_pretty(&settings)?)
328 .with_context(|| format!("writing {}", settings_path.display()))?;
329
330 Ok(())
331}
332
333fn install_pi_extension(path: &Path) -> Result<()> {
334 if let Some(parent) = path.parent() {
335 fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
336 }
337 fs::write(path, PI_EDICT_HOOKS_EXTENSION)
338 .with_context(|| format!("writing {}", path.display()))?;
339 Ok(())
340}
341
342fn is_botbox_hook_entry(entry: &serde_json::Value) -> bool {
344 entry["hooks"]
345 .as_array()
346 .is_some_and(|hooks| {
347 hooks.iter().any(|h| {
348 let cmd = &h["command"];
349 if let Some(cmd_str) = cmd.as_str() {
350 cmd_str.contains("edict hooks run") || cmd_str.contains("botbox hooks run")
351 } else if let Some(cmd_arr) = cmd.as_array() {
352 cmd_arr.len() >= 3
353 && (cmd_arr[0].as_str() == Some("edict")
354 || cmd_arr[0].as_str() == Some("botbox"))
355 && cmd_arr[1].as_str() == Some("hooks")
356 && cmd_arr[2].as_str() == Some("run")
357 } else {
358 false
359 }
360 })
361 })
362}
363
364fn is_botbox_hook_command(h: &serde_json::Value, name: &str) -> bool {
366 let cmd = &h["command"];
367 if let Some(cmd_str) = cmd.as_str() {
368 cmd_str.contains(&format!("run {name}"))
369 } else if let Some(cmd_arr) = cmd.as_array() {
370 cmd_arr.len() >= 4
371 && (cmd_arr[0].as_str() == Some("edict") || cmd_arr[0].as_str() == Some("botbox"))
372 && cmd_arr[1].as_str() == Some("hooks")
373 && cmd_arr[2].as_str() == Some("run")
374 && cmd_arr[3].as_str() == Some(name)
375 } else {
376 false
377 }
378}
379
380fn validate_name(name: &str, label: &str) -> Result<()> {
382 if name.is_empty()
383 || !name
384 .bytes()
385 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
386 || name.starts_with('-')
387 {
388 anyhow::bail!("invalid {label} {name:?}: must match [a-z0-9][a-z0-9-]*");
389 }
390 Ok(())
391}
392
393fn register_botbus_hooks(root: &Path, config: &Config) -> Result<()> {
394 if !config.tools.botbus {
395 return Ok(());
396 }
397
398 let channel = config.channel();
399 let project_name = &config.project.name;
400 let agent = config.default_agent();
401
402 validate_name(project_name, "project name")?;
403 validate_name(&channel, "channel name")?;
404 for reviewer in &config.review.reviewers {
405 validate_name(reviewer, "reviewer name")?;
406 }
407
408 let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
409 let root_str = root.display().to_string();
410
411 let router_claim = format!("agent://{project_name}-router");
413 let spawn_name = format!("{project_name}-router");
414 let description = format!("edict:{project_name}:responder");
415
416 let responder_memory_limit = config
417 .agents
418 .responder
419 .as_ref()
420 .and_then(|r| r.memory_limit.as_deref());
421
422 let mut router_args: Vec<&str> = vec![
423 "--agent",
424 &agent,
425 "--channel",
426 &channel,
427 "--claim",
428 &router_claim,
429 "--claim-owner",
430 &agent,
431 "--cwd",
432 &root_str,
433 "--ttl",
434 "600",
435 "--",
436 "vessel",
437 "spawn",
438 "--env-inherit",
439 env_inherit,
440 ];
441 if let Some(limit) = responder_memory_limit {
442 router_args.push("--memory-limit");
443 router_args.push(limit);
444 }
445 router_args.extend_from_slice(&[
446 "--name",
447 &spawn_name,
448 "--cwd",
449 &root_str,
450 "--",
451 "edict",
452 "run",
453 "responder",
454 ]);
455
456 match crate::subprocess::ensure_bus_hook(&description, &router_args) {
457 Ok((action, _)) => println!("Router hook {action} for #{channel}"),
458 Err(e) => eprintln!("Warning: failed to register router hook: {e}"),
459 }
460
461 let reviewer_memory_limit = config
463 .agents
464 .reviewer
465 .as_ref()
466 .and_then(|r| r.memory_limit.as_deref());
467
468 for reviewer in &config.review.reviewers {
469 let reviewer_agent = format!("{project_name}-{reviewer}");
470 let claim_uri = format!("agent://{reviewer_agent}");
471 let desc = format!("edict:{project_name}:reviewer-{reviewer}");
472
473 let mut reviewer_args: Vec<&str> = vec![
474 "--agent",
475 &agent,
476 "--channel",
477 &channel,
478 "--mention",
479 &reviewer_agent,
480 "--claim",
481 &claim_uri,
482 "--claim-owner",
483 &reviewer_agent,
484 "--ttl",
485 "600",
486 "--priority",
487 "1",
488 "--cwd",
489 &root_str,
490 "--",
491 "vessel",
492 "spawn",
493 "--env-inherit",
494 env_inherit,
495 ];
496 if let Some(limit) = reviewer_memory_limit {
497 reviewer_args.push("--memory-limit");
498 reviewer_args.push(limit);
499 }
500 reviewer_args.extend_from_slice(&[
501 "--name",
502 &reviewer_agent,
503 "--cwd",
504 &root_str,
505 "--",
506 "edict",
507 "run",
508 "reviewer-loop",
509 "--agent",
510 &reviewer_agent,
511 ]);
512
513 match crate::subprocess::ensure_bus_hook(&desc, &reviewer_args) {
514 Ok((action, _)) => println!("Reviewer hook for @{reviewer_agent} {action}"),
515 Err(e) => {
516 eprintln!("Warning: failed to register reviewer hook for @{reviewer_agent}: {e}")
517 }
518 }
519 }
520
521 Ok(())
522}
523
524fn check_botbus_hooks(root: &Path, config: &Config, issues: &mut Vec<String>) -> Result<()> {
525 let output = run_command("bus", &["hooks", "list", "--format", "json"], Some(root));
526
527 let hooks_data = match output {
528 Ok(json) => serde_json::from_str::<serde_json::Value>(&json).ok(),
529 Err(_) => None,
530 };
531
532 if hooks_data.is_none() {
533 issues.push("Failed to fetch botbus hooks".to_string());
534 return Ok(());
535 }
536
537 let hooks_data = hooks_data.unwrap();
538 let empty_vec = vec![];
539 let hooks = hooks_data["hooks"].as_array().unwrap_or(&empty_vec);
540
541 let router_claim = format!("agent://{}-router", config.project.name);
542 let has_router = hooks.iter().any(|h| {
543 h["condition"]["claim"]
544 .as_str()
545 .map(|c| c == router_claim)
546 .unwrap_or(false)
547 });
548
549 if !has_router {
550 issues.push(format!(
551 "Missing botbus router hook (claim: {router_claim})"
552 ));
553 }
554
555 for reviewer in &config.review.reviewers {
556 let mention_name = format!("{}-{reviewer}", config.project.name);
557 let has_reviewer = hooks.iter().any(|h| {
558 h["condition"]["mention"]
559 .as_str()
560 .map(|m| m == mention_name)
561 .unwrap_or(false)
562 });
563
564 if !has_reviewer {
565 issues.push(format!("Missing botbus reviewer hook for @{mention_name}"));
566 }
567 }
568
569 Ok(())
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 #[test]
577 fn validate_name_accepts_valid() {
578 assert!(validate_name("botbox", "test").is_ok());
579 assert!(validate_name("my-project", "test").is_ok());
580 assert!(validate_name("a", "test").is_ok());
581 assert!(validate_name("project123", "test").is_ok());
582 }
583
584 #[test]
585 fn validate_name_rejects_invalid() {
586 assert!(validate_name("", "test").is_err());
587 assert!(validate_name("-starts-dash", "test").is_err());
588 assert!(validate_name("Has Uppercase", "test").is_err());
589 assert!(validate_name("has space", "test").is_err());
590 assert!(validate_name("$(inject)", "test").is_err());
591 assert!(validate_name("; rm -rf /", "test").is_err());
592 assert!(validate_name("name\nwith\nnewlines", "test").is_err());
593 }
594
595 #[test]
596 fn is_botbox_hook_entry_detects_edict_string_command() {
597 let entry = json!({
598 "matcher": "",
599 "hooks": [{"type": "command", "command": "edict hooks run session-start"}]
600 });
601 assert!(is_botbox_hook_entry(&entry));
602 }
603
604 #[test]
605 fn is_botbox_hook_entry_detects_edict_array_command() {
606 let entry = json!({
607 "matcher": "",
608 "hooks": [{"type": "command", "command": ["edict", "hooks", "run", "session-start"]}]
609 });
610 assert!(is_botbox_hook_entry(&entry));
611 }
612
613 #[test]
614 fn is_botbox_hook_entry_detects_legacy_botbox_string_command() {
615 let entry = json!({
616 "matcher": "",
617 "hooks": [{"type": "command", "command": "botbox hooks run session-start"}]
618 });
619 assert!(is_botbox_hook_entry(&entry));
620 }
621
622 #[test]
623 fn is_botbox_hook_entry_detects_legacy_botbox_array_command() {
624 let entry = json!({
625 "matcher": "",
626 "hooks": [{"type": "command", "command": ["botbox", "hooks", "run", "session-start"]}]
627 });
628 assert!(is_botbox_hook_entry(&entry));
629 }
630
631 #[test]
632 fn is_botbox_hook_entry_preserves_non_botbox() {
633 let entry = json!({
634 "matcher": "",
635 "hooks": [{"type": "command", "command": "my-custom-hook"}]
636 });
637 assert!(!is_botbox_hook_entry(&entry));
638 }
639
640 #[test]
641 fn is_botbox_hook_entry_detects_old_format() {
642 let entry = json!({
643 "matcher": "",
644 "hooks": [{"type": "command", "command": "botbox hooks run init-agent --project-root /tmp"}]
645 });
646 assert!(is_botbox_hook_entry(&entry));
647 }
648}