chronicle/hooks/
installer.rs1use 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 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
151fn 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 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}