Skip to main content

edict/commands/
hooks.rs

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/update global agent hooks in ~/.claude/settings.json
20    Install {
21        /// Project root directory (for botbus hook registration only)
22        #[arg(long)]
23        project_root: Option<PathBuf>,
24    },
25    /// Remove global agent hooks from ~/.claude/settings.json
26    Uninstall,
27    /// Audit hook registrations and report issues
28    Audit {
29        /// Project root directory
30        #[arg(long)]
31        project_root: Option<PathBuf>,
32        /// Output format
33        #[arg(long, value_enum, default_value_t = super::doctor::OutputFormat::Pretty)]
34        format: super::doctor::OutputFormat,
35    },
36    /// Run a hook directly (called by Claude Code / Pi hooks infrastructure)
37    Run {
38        /// Hook name (session-start, post-tool-call, session-end)
39        hook_name: String,
40        /// Project root directory (deprecated, ignored — hooks auto-detect context)
41        #[arg(long)]
42        project_root: Option<PathBuf>,
43        /// Release claims (for Pi session shutdown)
44        #[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
67/// Install global agent hooks into ~/.claude/settings.json (and Pi extensions).
68///
69/// If project_root is provided, also registers botbus hooks (router + reviewers).
70fn install_hooks(project_root: Option<&Path>) -> Result<()> {
71    // Install global Claude Code hooks
72    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    // Install Pi extension globally
78    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 in a botbox project, also register botbus hooks (router + reviewers)
83    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
97/// Remove global agent hooks from ~/.claude/settings.json and Pi extensions.
98fn uninstall_hooks() -> Result<()> {
99    let home = dirs::home_dir().context("could not determine home directory")?;
100
101    // Remove from ~/.claude/settings.json
102    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            // Remove empty event arrays
116            hooks.retain(|_, v| {
117                v.as_array().map(|a| !a.is_empty()).unwrap_or(true)
118            });
119        }
120
121        // Remove hooks key entirely if empty
122        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    // Remove Pi extension
135    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    // Check global settings.json
150    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    // Check botbus hooks (if in a botbox project)
182    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    // Read stdin with a size limit (64KB) for defense-in-depth
219    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        // Backwards compat: old hook names map to new ones
232        "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                // claim-agent on SessionStart/PostToolUse — handled by session-start/post-tool-call
239                crate::hooks::run_session_start()
240            }
241        }
242        _ => Err(ExitError::Config(format!("unknown hook: {hook_name}")).into()),
243    }
244}
245
246// --- Helper functions ---
247
248fn 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
270/// Install global Claude Code hooks into ~/.claude/settings.json
271fn 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    // Load existing settings or create new
292    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    // Merge: preserve non-botbox hooks, replace botbox hooks
301    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
342/// Check if a hook entry is edict-managed (current or legacy botbox)
343fn 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
364/// Check if a specific hook command matches a hook name (edict or legacy botbox)
365fn 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
380/// Validates a name against `[a-z0-9][a-z0-9-]*` to prevent shell injection.
381fn 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    // Register router hook (claim-based)
412    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    // Register reviewer hooks (mention-based)
462    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}