Skip to main content

chronicle/hooks/
installer.rs

1use anyhow::{Context, Result};
2use serde_json::{json, Value};
3use std::fs;
4use std::path::Path;
5
6const HOOK_EVENTS: &[&str] = &[
7    "PreToolUse",
8    "PostToolUse",
9    "PostToolUseFailure",
10    "UserPromptSubmit",
11    "SessionStart",
12    "SessionEnd",
13    "SubagentStart",
14    "SubagentStop",
15    "Stop",
16];
17
18const CHRONICLE_MARKER: &str = ".chronicle/hooks/";
19
20pub fn install(project_dir: &Path) -> Result<()> {
21    let chronicle_dir = project_dir.join(".chronicle");
22    let hooks_dir = chronicle_dir.join("hooks");
23    fs::create_dir_all(&hooks_dir)?;
24
25    let script_names: std::collections::HashMap<&str, &str> = [
26        ("PreToolUse", "pre_tool_use"),
27        ("PostToolUse", "post_tool_use"),
28        ("PostToolUseFailure", "post_tool_use_failure"),
29        ("UserPromptSubmit", "user_prompt_submit"),
30        ("SessionStart", "session_start"),
31        ("SessionEnd", "session_end"),
32        ("SubagentStart", "subagent_start"),
33        ("SubagentStop", "subagent_stop"),
34        ("Stop", "stop"),
35    ]
36    .into_iter()
37    .collect();
38
39    for event in HOOK_EVENTS {
40        let base = script_names
41            .get(event)
42            .expect("missing script name mapping");
43        let script_name = format!("{base}.sh");
44        let script_path = hooks_dir.join(&script_name);
45        let script = "#!/bin/bash\nchronicle hook-relay\n";
46        fs::write(&script_path, script)?;
47        #[cfg(unix)]
48        {
49            use std::os::unix::fs::PermissionsExt;
50            fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
51        }
52    }
53
54    let claude_dir = project_dir.join(".claude");
55    fs::create_dir_all(&claude_dir)?;
56    let settings_path = claude_dir.join("settings.local.json");
57
58    let mut settings: Value = if settings_path.exists() {
59        let content = fs::read_to_string(&settings_path)?;
60        serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
61    } else {
62        json!({})
63    };
64
65    let hooks = settings
66        .as_object_mut()
67        .context("settings is not an object")?
68        .entry("hooks")
69        .or_insert_with(|| json!({}))
70        .as_object_mut()
71        .context("hooks is not an object")?;
72
73    for event in HOOK_EVENTS {
74        let base = script_names
75            .get(event)
76            .expect("missing script name mapping");
77        let command = format!(".chronicle/hooks/{base}.sh");
78        let hook_entry = json!({ "hooks": [{ "type": "command", "command": command }] });
79
80        let arr = hooks
81            .entry(*event)
82            .or_insert_with(|| json!([]))
83            .as_array_mut()
84            .context("hook event is not an array")?;
85
86        // Remove existing chronicle entries (idempotent)
87        arr.retain(|entry| {
88            !entry
89                .get("hooks")
90                .and_then(|h| h.as_array())
91                .map(|hooks| {
92                    hooks.iter().any(|h| {
93                        h.get("command")
94                            .and_then(|c| c.as_str())
95                            .is_some_and(|c| c.contains(CHRONICLE_MARKER))
96                    })
97                })
98                .unwrap_or(false)
99        });
100        arr.push(hook_entry);
101    }
102
103    fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
104
105    let gitignore_path = project_dir.join(".gitignore");
106    let gitignore_entry = ".chronicle/";
107    if gitignore_path.exists() {
108        let content = fs::read_to_string(&gitignore_path)?;
109        if !content.lines().any(|l| l.trim() == gitignore_entry) {
110            fs::write(&gitignore_path, format!("{content}\n{gitignore_entry}\n"))?;
111        }
112    } else {
113        fs::write(&gitignore_path, format!("{gitignore_entry}\n"))?;
114    }
115
116    println!("Chronicle initialized in {}", chronicle_dir.display());
117    Ok(())
118}
119
120pub fn show(project_dir: &Path) -> Result<()> {
121    let settings_path = project_dir.join(".claude/settings.local.json");
122    if !settings_path.exists() {
123        println!("No chronicle hooks installed.");
124        return Ok(());
125    }
126    let content = fs::read_to_string(&settings_path)?;
127    let settings: Value = serde_json::from_str(&content)?;
128    if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
129        println!("Chronicle hooks in {}:", settings_path.display());
130        for (event, config) in hooks {
131            if let Some(arr) = config.as_array() {
132                for entry in arr {
133                    if let Some(hooks_arr) = entry.get("hooks").and_then(|h| h.as_array()) {
134                        for h in hooks_arr {
135                            if let Some(cmd) = h.get("command").and_then(|c| c.as_str()) {
136                                if cmd.contains(CHRONICLE_MARKER) {
137                                    println!("  {event}: {cmd}");
138                                }
139                            }
140                        }
141                    }
142                }
143            }
144        }
145    } else {
146        println!("No hooks configured.");
147    }
148    Ok(())
149}
150
151/// Check if a PID is alive AND belongs to a chronicle process.
152fn is_chronicle_process(pid: i32) -> bool {
153    let alive = unsafe { libc::kill(pid, 0) } == 0;
154    if !alive {
155        return false;
156    }
157    std::process::Command::new("ps")
158        .args(["-p", &pid.to_string(), "-o", "comm="])
159        .output()
160        .map(|o| {
161            let comm = String::from_utf8_lossy(&o.stdout);
162            comm.trim().contains("chronicle")
163        })
164        .unwrap_or(false)
165}
166
167pub fn remove(project_dir: &Path) -> Result<()> {
168    let settings_path = project_dir.join(".claude/settings.local.json");
169    if !settings_path.exists() {
170        println!("No settings file found.");
171        return Ok(());
172    }
173    let content = fs::read_to_string(&settings_path)?;
174    let mut settings: Value = serde_json::from_str(&content)?;
175    if let Some(hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) {
176        for (_event, config) in hooks.iter_mut() {
177            if let Some(arr) = config.as_array_mut() {
178                arr.retain(|entry| {
179                    !entry
180                        .get("hooks")
181                        .and_then(|h| h.as_array())
182                        .map(|hooks| {
183                            hooks.iter().any(|h| {
184                                h.get("command")
185                                    .and_then(|c| c.as_str())
186                                    .is_some_and(|c| c.contains(CHRONICLE_MARKER))
187                            })
188                        })
189                        .unwrap_or(false)
190                });
191            }
192        }
193        let empty_events: Vec<String> = hooks
194            .iter()
195            .filter(|(_, v)| v.as_array().is_some_and(|a| a.is_empty()))
196            .map(|(k, _)| k.clone())
197            .collect();
198        for key in empty_events {
199            hooks.remove(&key);
200        }
201    }
202    fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?;
203
204    // Stop daemon if running
205    let pid_path = project_dir.join(".chronicle/daemon.pid");
206    if pid_path.exists() {
207        if let Ok(pid_str) = fs::read_to_string(&pid_path) {
208            if let Ok(pid) = pid_str.trim().parse::<i32>() {
209                if is_chronicle_process(pid) {
210                    let ret = unsafe { libc::kill(pid, libc::SIGTERM) };
211                    if ret == 0 {
212                        println!("Daemon stopped (PID {pid}).");
213                    } else {
214                        let err = std::io::Error::last_os_error();
215                        eprintln!("Failed to stop daemon (PID {pid}): {err}");
216                    }
217                }
218            }
219        }
220        let _ = fs::remove_file(&pid_path);
221    }
222
223    println!("Chronicle hooks removed.");
224    Ok(())
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use tempfile::tempdir;
231
232    #[test]
233    fn test_install_creates_hooks_and_settings() {
234        let dir = tempdir().unwrap();
235        install(dir.path()).unwrap();
236        assert!(dir.path().join(".chronicle/hooks/pre_tool_use.sh").exists());
237        let settings_path = dir.path().join(".claude/settings.local.json");
238        assert!(settings_path.exists());
239        let content = fs::read_to_string(&settings_path).unwrap();
240        let settings: Value = serde_json::from_str(&content).unwrap();
241        assert!(settings.get("hooks").is_some());
242        let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
243        assert!(gitignore.contains(".chronicle/"));
244    }
245
246    #[test]
247    fn test_install_is_idempotent() {
248        let dir = tempdir().unwrap();
249        install(dir.path()).unwrap();
250        install(dir.path()).unwrap();
251        let content =
252            fs::read_to_string(dir.path().join(".claude/settings.local.json")).unwrap();
253        let settings: Value = serde_json::from_str(&content).unwrap();
254        let pre = settings["hooks"]["PreToolUse"].as_array().unwrap();
255        let chronicle_entries: Vec<_> = pre
256            .iter()
257            .filter(|e| {
258                e.get("hooks")
259                    .and_then(|h| h.as_array())
260                    .is_some_and(|arr| {
261                        arr.iter().any(|h| {
262                            h.get("command")
263                                .and_then(|c| c.as_str())
264                                .is_some_and(|c| c.contains(".chronicle/"))
265                        })
266                    })
267            })
268            .collect();
269        assert_eq!(chronicle_entries.len(), 1);
270    }
271
272    #[test]
273    fn test_remove_cleans_up() {
274        let dir = tempdir().unwrap();
275        install(dir.path()).unwrap();
276        remove(dir.path()).unwrap();
277        let content =
278            fs::read_to_string(dir.path().join(".claude/settings.local.json")).unwrap();
279        let settings: Value = serde_json::from_str(&content).unwrap();
280        let hooks = settings["hooks"].as_object().unwrap();
281        for (_event, config) in hooks {
282            if let Some(arr) = config.as_array() {
283                assert!(arr.is_empty());
284            }
285        }
286    }
287}