Skip to main content

atomcode_core/hook/
json_config.rs

1//! Hooks JSON configuration loading — mirrors the MCP config pattern.
2//!
3//! Hooks are configured in JSON files:
4//! - `$ATOMCODE_HOME/hooks.json` — global hooks
5//! - `<project>/.hooks.json`        — project-level hooks (override global by name)
6//!
7//! Project hooks override global hooks with the same name. Hooks with
8//! `"disabled": true` are skipped.
9
10use std::collections::BTreeMap;
11use std::path::Path;
12
13use anyhow::{Context, Result};
14use serde::Deserialize;
15
16use super::{HookConfig, HookEvent};
17
18/// Top-level JSON structure for a hooks config file.
19#[derive(Debug, Deserialize)]
20struct HooksFile {
21    #[serde(default)]
22    hooks: BTreeMap<String, HookEntry>,
23}
24
25/// A single hook entry in the JSON config.
26#[derive(Debug, Deserialize)]
27struct HookEntry {
28    pub event: String,
29    #[serde(default)]
30    pub matcher: Option<String>,
31    pub command: String,
32    #[serde(default = "default_timeout")]
33    pub timeout_ms: u64,
34    #[serde(default)]
35    pub disabled: bool,
36}
37
38fn default_timeout() -> u64 {
39    10_000
40}
41
42/// Load and merge hooks from global (`$ATOMCODE_HOME/hooks.json`) and project
43/// (`.hooks.json`) config files.
44///
45/// Project hooks override global hooks with the same name. Disabled hooks
46/// are filtered out.
47pub fn load_hooks_config(project_dir: &Path) -> Vec<HookConfig> {
48    let global_path = crate::config::Config::config_dir().join("hooks.json");
49    let project_path = project_dir.join(".hooks.json");
50
51    let mut merged: BTreeMap<String, HookConfig> = BTreeMap::new();
52
53    // Load global hooks first.
54    if let Ok(hooks) = load_hooks_file(&global_path) {
55        for (name, hook) in hooks {
56            merged.insert(name, hook);
57        }
58    }
59
60    // Plugin layer — two flavors per plugin:
61    //   1. CC-style inline hooks declared in plugin.json (priority).
62    //   2. Legacy atomcode hooks.json file (fallback).
63    // CC wins when both exist because plugin authors targeting CC are the
64    // common case, and a plugin shipping only a legacy hooks.json would not
65    // have a colliding plugin.json hooks block.
66    for assets in crate::plugin::loader::iter_installed_plugin_assets() {
67        if let Some(cc_map) = assets.manifest.inline_cc_hooks() {
68            for (name, hook) in cc_hooks_to_atomcode(cc_map, &assets.plugin_dir) {
69                let key = format!("{}:{}", assets.plugin, name);
70                merged.insert(key, hook);
71            }
72            continue;
73        }
74        let path = assets.hooks_file();
75        if let Ok(hooks) = load_hooks_file(&path) {
76            for (name, hook) in hooks {
77                let key = format!("{}:{}", assets.plugin, name);
78                merged.insert(key, hook);
79            }
80        }
81    }
82
83    // Load project hooks — override global by name.
84    if let Ok(hooks) = load_hooks_file(&project_path) {
85        for (name, hook) in hooks {
86            merged.insert(name, hook);
87        }
88    }
89
90    merged.into_values().collect()
91}
92
93/// Translate a Claude-Code-style nested hooks block (as found in
94/// `plugin.json`) into our flat `HookConfig` list. Each command spec
95/// becomes one named entry; the synthetic name lets multiple hooks under
96/// the same event coexist when later merged into the global hook table.
97///
98/// The plugin install directory is exported by the executor as the
99/// `CLAUDE_PLUGIN_ROOT` / `ATOMCODE_PLUGIN_ROOT` environment variables —
100/// hook authors reference it via `"${CLAUDE_PLUGIN_ROOT}/script.py"` in
101/// the command string. We deliberately do NOT substitute the path into
102/// the command at conversion time: paths containing spaces, quotes, `$`
103/// or `;` would break shell parsing or open injection. Env vars are
104/// also what Claude Code uses, so existing CC plugin scripts work as-is.
105pub(crate) fn cc_hooks_to_atomcode(
106    cc: &crate::plugin::manifest::CCHooksMap,
107    plugin_root: &Path,
108) -> Vec<(String, HookConfig)> {
109    use crate::plugin::manifest::CCHookGroup;
110
111    let mut out = Vec::new();
112
113    for (event_name, groups) in cc {
114        let event = match cc_event_name_to_event(event_name) {
115            Some(e) => e,
116            None => continue, // unsupported event — skip silently for now.
117        };
118        for (gi, CCHookGroup { matcher, hooks }) in groups.iter().enumerate() {
119            for (hi, spec) in hooks.iter().enumerate() {
120                if spec.kind != "command" {
121                    continue;
122                }
123                // CC encodes timeout in seconds; we store ms internally.
124                let timeout_ms = spec
125                    .timeout
126                    .map(|s| s.saturating_mul(1000))
127                    .unwrap_or(10_000);
128                let name = format!("{}-{}-{}", event_name, gi, hi);
129                out.push((
130                    name,
131                    HookConfig {
132                        event: event.clone(),
133                        matcher: matcher.clone(),
134                        command: spec.command.clone(),
135                        timeout_ms,
136                        plugin_root: Some(plugin_root.to_path_buf()),
137                    },
138                ));
139            }
140        }
141    }
142    out
143}
144
145/// Map CC's PascalCase event names to our `HookEvent` enum. Returns `None`
146/// for events we don't yet support (e.g. `Stop`, `PreCompact`,
147/// `SubagentStop`); callers skip those entries.
148fn cc_event_name_to_event(name: &str) -> Option<HookEvent> {
149    Some(match name {
150        "PreToolUse" => HookEvent::PreToolUse,
151        "PostToolUse" => HookEvent::PostToolUse,
152        "SessionStart" => HookEvent::SessionStart,
153        "SessionEnd" => HookEvent::SessionEnd,
154        "Notification" => HookEvent::Notification,
155        "UserPromptSubmit" => HookEvent::UserPromptSubmit,
156        _ => return None,
157    })
158}
159
160fn parse_hook_event(name: &str) -> Option<HookEvent> {
161    serde_json::from_value::<HookEvent>(serde_json::Value::String(name.to_string()))
162        .ok()
163        .or_else(|| cc_event_name_to_event(name))
164}
165
166/// Parse a single hooks JSON file and return named hook configs.
167///
168/// Disabled hooks are filtered out. Missing files return an empty vec
169/// (not an error).
170fn load_hooks_file(path: &Path) -> Result<Vec<(String, HookConfig)>> {
171    if !path.exists() {
172        return Ok(Vec::new());
173    }
174    let content = std::fs::read_to_string(path)
175        .with_context(|| format!("Failed to read hooks config from {}", path.display()))?;
176    let raw: HooksFile = serde_json::from_str(&content)
177        .with_context(|| format!("Failed to parse hooks config from {}", path.display()))?;
178
179    let mut configs = Vec::new();
180    for (name, entry) in raw.hooks {
181        if entry.disabled {
182            continue;
183        }
184        let Some(event) = parse_hook_event(&entry.event) else {
185            continue;
186        };
187        configs.push((
188            name,
189            HookConfig {
190                event,
191                matcher: entry.matcher,
192                command: entry.command,
193                timeout_ms: entry.timeout_ms,
194                // Legacy flat hooks.json doesn't know which plugin owns
195                // the hook (the hooks.json layout pre-dates the plugin
196                // system). Plugin-contributed hooks come through
197                // `cc_hooks_to_atomcode` which sets this.
198                plugin_root: None,
199            },
200        ));
201    }
202    Ok(configs)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn cc_hooks_to_atomcode_records_plugin_root_without_substitution() {
211        // Regression: we used to substitute `${CLAUDE_PLUGIN_ROOT}` into the
212        // command string, which broke under `sh -c` for paths containing
213        // spaces / quotes. The contract now is: command is passed through
214        // unchanged, plugin_root is set, executor exports the env var.
215        use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
216        let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
217        cc.insert(
218            "UserPromptSubmit".into(),
219            vec![CCHookGroup {
220                matcher: None,
221                hooks: vec![CCHookSpec {
222                    kind: "command".into(),
223                    command: "python \"${CLAUDE_PLUGIN_ROOT}/h.py\"".into(),
224                    timeout: Some(5),
225                }],
226            }],
227        );
228        let plugin_root = std::path::Path::new("/opt/x");
229        let out = cc_hooks_to_atomcode(&cc, plugin_root);
230        assert_eq!(out.len(), 1);
231        let (_, h) = &out[0];
232        assert_eq!(h.event, HookEvent::UserPromptSubmit);
233        // Command unchanged — substitution is the executor's job.
234        assert_eq!(h.command, "python \"${CLAUDE_PLUGIN_ROOT}/h.py\"");
235        assert_eq!(h.plugin_root.as_deref(), Some(plugin_root));
236        assert_eq!(h.timeout_ms, 5_000); // CC seconds → ms
237    }
238
239    #[test]
240    fn cc_hooks_to_atomcode_skips_unsupported_events() {
241        use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
242        let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
243        // Stop / SubagentStop / PreCompact are CC events we don't surface yet.
244        cc.insert(
245            "Stop".into(),
246            vec![CCHookGroup {
247                matcher: None,
248                hooks: vec![CCHookSpec {
249                    kind: "command".into(),
250                    command: "echo".into(),
251                    timeout: None,
252                }],
253            }],
254        );
255        assert!(cc_hooks_to_atomcode(&cc, std::path::Path::new("/")).is_empty());
256    }
257
258    #[test]
259    fn cc_hooks_to_atomcode_default_timeout_when_omitted() {
260        use crate::plugin::manifest::{CCHookGroup, CCHookSpec};
261        let mut cc: crate::plugin::manifest::CCHooksMap = std::collections::BTreeMap::new();
262        cc.insert(
263            "PreToolUse".into(),
264            vec![CCHookGroup {
265                matcher: Some("bash".into()),
266                hooks: vec![CCHookSpec {
267                    kind: "command".into(),
268                    command: "echo".into(),
269                    timeout: None,
270                }],
271            }],
272        );
273        let out = cc_hooks_to_atomcode(&cc, std::path::Path::new("/"));
274        assert_eq!(out[0].1.timeout_ms, 10_000);
275        assert_eq!(out[0].1.matcher.as_deref(), Some("bash"));
276    }
277
278    /// Parse a minimal hooks JSON with one entry.
279    #[test]
280    fn parse_single_hook() {
281        let json = r#"{
282            "hooks": {
283                "audit-all": {
284                    "event": "pre_tool_use",
285                    "command": "echo audit"
286                }
287            }
288        }"#;
289        let raw: HooksFile = serde_json::from_str(json).unwrap();
290        assert_eq!(raw.hooks.len(), 1);
291        let entry = &raw.hooks["audit-all"];
292        assert_eq!(entry.event, "pre_tool_use");
293        assert_eq!(entry.command, "echo audit");
294        assert_eq!(entry.timeout_ms, 10_000);
295        assert!(!entry.disabled);
296    }
297
298    /// Parse multiple hooks with matcher and timeout.
299    #[test]
300    fn parse_multiple_hooks() {
301        let json = r#"{
302            "hooks": {
303                "audit": {
304                    "event": "pre_tool_use",
305                    "command": "echo audit"
306                },
307                "block-rm": {
308                    "event": "pre_tool_use",
309                    "matcher": "bash",
310                    "command": "safety-check.sh",
311                    "timeout_ms": 5000
312                },
313                "auto-format": {
314                    "event": "post_tool_use",
315                    "matcher": "edit_*",
316                    "command": "cargo fmt"
317                }
318            }
319        }"#;
320        let raw: HooksFile = serde_json::from_str(json).unwrap();
321        assert_eq!(raw.hooks.len(), 3);
322        assert_eq!(raw.hooks["block-rm"].timeout_ms, 5000);
323        assert_eq!(
324            raw.hooks["block-rm"].matcher.as_deref(),
325            Some("bash")
326        );
327        assert_eq!(raw.hooks["auto-format"].event, "post_tool_use");
328    }
329
330    /// Disabled hooks are filtered out when loading.
331    #[test]
332    fn disabled_hooks_are_skipped() {
333        let dir = tempfile::tempdir().unwrap();
334        let path = dir.path().join("hooks.json");
335        let json = r#"{
336            "hooks": {
337                "active": {
338                    "event": "pre_tool_use",
339                    "command": "echo yes"
340                },
341                "inactive": {
342                    "event": "pre_tool_use",
343                    "command": "echo no",
344                    "disabled": true
345                }
346            }
347        }"#;
348        std::fs::write(&path, json).unwrap();
349        let hooks = load_hooks_file(&path).unwrap();
350        assert_eq!(hooks.len(), 1);
351        assert_eq!(hooks[0].0, "active");
352    }
353
354    /// Missing file returns empty vec, not error.
355    #[test]
356    fn missing_file_returns_empty() {
357        let path = std::path::Path::new("/nonexistent/hooks.json");
358        let hooks = load_hooks_file(path).unwrap();
359        assert!(hooks.is_empty());
360    }
361
362    /// Empty hooks object parses fine.
363    #[test]
364    fn empty_hooks_object() {
365        let json = r#"{ "hooks": {} }"#;
366        let raw: HooksFile = serde_json::from_str(json).unwrap();
367        assert!(raw.hooks.is_empty());
368    }
369
370    /// Project hooks override global hooks with the same name.
371    #[test]
372    fn project_overrides_global_by_name() {
373        let dir = tempfile::tempdir().unwrap();
374
375        // Simulate global config dir
376        let global_dir = dir.path().join("global");
377        std::fs::create_dir_all(&global_dir).unwrap();
378        let global_path = global_dir.join("hooks.json");
379        std::fs::write(
380            &global_path,
381            r#"{
382                "hooks": {
383                    "audit": {
384                        "event": "pre_tool_use",
385                        "command": "echo global-audit"
386                    },
387                    "global-only": {
388                        "event": "session_start",
389                        "command": "echo global-only"
390                    }
391                }
392            }"#,
393        )
394        .unwrap();
395
396        // Project hooks
397        let project_dir = dir.path().join("project");
398        std::fs::create_dir_all(&project_dir).unwrap();
399        let project_path = project_dir.join(".hooks.json");
400        std::fs::write(
401            &project_path,
402            r#"{
403                "hooks": {
404                    "audit": {
405                        "event": "pre_tool_use",
406                        "command": "echo project-audit"
407                    },
408                    "project-only": {
409                        "event": "post_tool_use",
410                        "command": "echo project-only"
411                    }
412                }
413            }"#,
414        )
415        .unwrap();
416
417        // Load and merge manually (since load_hooks_config uses hardcoded paths)
418        let mut merged: BTreeMap<String, HookConfig> = BTreeMap::new();
419        for (name, hook) in load_hooks_file(&global_path).unwrap() {
420            merged.insert(name, hook);
421        }
422        for (name, hook) in load_hooks_file(&project_path).unwrap() {
423            merged.insert(name, hook);
424        }
425
426        assert_eq!(merged.len(), 3);
427
428        // "audit" should be the project version
429        let audit = &merged["audit"];
430        assert_eq!(audit.command, "echo project-audit");
431
432        // "global-only" should survive
433        assert!(merged.contains_key("global-only"));
434
435        // "project-only" should be present
436        assert!(merged.contains_key("project-only"));
437    }
438
439    /// Event strings map correctly to HookEvent variants.
440    #[test]
441    fn event_string_mapping() {
442        let dir = tempfile::tempdir().unwrap();
443        let path = dir.path().join("hooks.json");
444        let json = r#"{
445            "hooks": {
446                "h1": { "event": "pre_tool_use", "command": "a" },
447                "h2": { "event": "post_tool_use", "command": "b" },
448                "h3": { "event": "session_start", "command": "c" },
449                "h4": { "event": "session_end", "command": "d" }
450            }
451        }"#;
452        std::fs::write(&path, json).unwrap();
453        let hooks = load_hooks_file(&path).unwrap();
454        let map: BTreeMap<String, HookConfig> = hooks.into_iter().collect();
455        assert_eq!(map["h1"].event, HookEvent::PreToolUse);
456        assert_eq!(map["h2"].event, HookEvent::PostToolUse);
457        assert_eq!(map["h3"].event, HookEvent::SessionStart);
458        assert_eq!(map["h4"].event, HookEvent::SessionEnd);
459    }
460
461    #[test]
462    fn pascal_case_event_names_are_accepted() {
463        let dir = tempfile::tempdir().unwrap();
464        let path = dir.path().join("hooks.json");
465        let json = r#"{
466            "hooks": {
467                "h1": { "event": "PreToolUse", "command": "a" },
468                "h2": { "event": "UserPromptSubmit", "command": "b" }
469            }
470        }"#;
471        std::fs::write(&path, json).unwrap();
472        let hooks = load_hooks_file(&path).unwrap();
473        let map: BTreeMap<String, HookConfig> = hooks.into_iter().collect();
474        assert_eq!(map["h1"].event, HookEvent::PreToolUse);
475        assert_eq!(map["h2"].event, HookEvent::UserPromptSubmit);
476    }
477
478    #[test]
479    fn invalid_event_name_is_skipped() {
480        let dir = tempfile::tempdir().unwrap();
481        let path = dir.path().join("hooks.json");
482        let json = r#"{
483            "hooks": {
484                "typo": { "event": "pre_tool", "command": "should-not-run" },
485                "valid": { "event": "post_tool_use", "command": "echo ok" }
486            }
487        }"#;
488        std::fs::write(&path, json).unwrap();
489        let hooks = load_hooks_file(&path).unwrap();
490        assert_eq!(hooks.len(), 1);
491        assert_eq!(hooks[0].0, "valid");
492        assert_eq!(hooks[0].1.event, HookEvent::PostToolUse);
493    }
494
495    /// Malformed JSON returns an error, not a panic.
496    #[test]
497    fn malformed_json_returns_error() {
498        let dir = tempfile::tempdir().unwrap();
499        let path = dir.path().join("hooks.json");
500        std::fs::write(&path, "not valid json").unwrap();
501        let result = load_hooks_file(&path);
502        assert!(result.is_err());
503    }
504
505    /// Default timeout is 10000 when not specified.
506    #[test]
507    fn default_timeout_is_10000() {
508        let json = r#"{
509            "hooks": {
510                "test": {
511                    "event": "pre_tool_use",
512                    "command": "echo test"
513                }
514            }
515        }"#;
516        let raw: HooksFile = serde_json::from_str(json).unwrap();
517        assert_eq!(raw.hooks["test"].timeout_ms, 10_000);
518    }
519
520    /// Custom timeout_ms is preserved.
521    #[test]
522    fn custom_timeout_is_preserved() {
523        let json = r#"{
524            "hooks": {
525                "fast": {
526                    "event": "pre_tool_use",
527                    "command": "echo fast",
528                    "timeout_ms": 500
529                }
530            }
531        }"#;
532        let raw: HooksFile = serde_json::from_str(json).unwrap();
533        assert_eq!(raw.hooks["fast"].timeout_ms, 500);
534    }
535
536    #[test]
537    #[serial_test::serial]
538    fn plugin_hooks_are_loaded_with_prefix() {
539        let tmp = tempfile::tempdir().unwrap();
540        std::env::set_var("ATOMCODE_HOME", tmp.path());
541
542        let plugin_dir = tmp.path().join("plugins/marketplaces/p");
543        std::fs::create_dir_all(&plugin_dir).unwrap();
544        std::fs::write(
545            plugin_dir.join("hooks.json"),
546            r#"{"hooks":{"on_pre":{"event":"PreToolUse","command":"echo hi"}}}"#,
547        )
548        .unwrap();
549        std::fs::write(
550            tmp.path().join("plugins/installed_plugins.json"),
551            r#"{"version":1,"plugins":{"p@p":{"marketplace":"p","plugin":"p","plugin_dir":"marketplaces/p","installed_at":"x"}}}"#,
552        )
553        .unwrap();
554
555        let working = tempfile::tempdir().unwrap();
556        let hooks = load_hooks_config(working.path());
557        assert!(hooks.iter().any(|h| h.command == "echo hi"));
558
559        std::env::remove_var("ATOMCODE_HOME");
560    }
561}