scud/commands/spawn/
hooks.rs1use anyhow::{Context, Result};
9use serde_json::{json, Value};
10use std::fs;
11use std::path::Path;
12
13pub fn hooks_installed(project_root: &Path) -> bool {
15 let settings_path = project_root.join(".claude").join("settings.local.json");
16
17 if !settings_path.exists() {
18 return false;
19 }
20
21 match fs::read_to_string(&settings_path) {
22 Ok(content) => {
23 if let Ok(settings) = serde_json::from_str::<Value>(&content) {
24 settings
26 .get("hooks")
27 .and_then(|h| h.get("Stop"))
28 .and_then(|s| s.as_array())
29 .map(|arr| {
30 arr.iter().any(|hook| {
31 hook.get("hooks")
32 .and_then(|h| h.as_array())
33 .map(|cmds| {
34 cmds.iter().any(|cmd| {
35 cmd.get("command")
36 .and_then(|c| c.as_str())
37 .map(|s| s.contains("scud") && s.contains("set-status"))
38 .unwrap_or(false)
39 })
40 })
41 .unwrap_or(false)
42 })
43 })
44 .unwrap_or(false)
45 } else {
46 false
47 }
48 }
49 Err(_) => false,
50 }
51}
52
53pub fn install_hooks(project_root: &Path) -> Result<()> {
55 let claude_dir = project_root.join(".claude");
56 let settings_path = claude_dir.join("settings.local.json");
57
58 fs::create_dir_all(&claude_dir).context("Failed to create .claude directory")?;
60
61 let mut settings: Value = if settings_path.exists() {
63 let content = fs::read_to_string(&settings_path)?;
64 serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
65 } else {
66 json!({})
67 };
68
69 let stop_hook = json!([
74 {
75 "matcher": "",
76 "hooks": [
77 {
78 "type": "command",
79 "command": "bash -c 'if [ -n \"$SCUD_TASK_ID\" ]; then scud set-status \"$SCUD_TASK_ID\" done 2>/dev/null || true; fi'",
82 "timeout": 10
83 }
84 ]
85 }
86 ]);
87
88 let post_tool_hook = json!([
90 {
91 "matcher": "TaskUpdate|TaskCreate",
92 "hooks": [
93 {
94 "type": "command",
95 "command": "bash -c 'scud sync-from-claude 2>/dev/null || true'",
97 "timeout": 10
98 }
99 ]
100 }
101 ]);
102
103 let hooks = settings.get("hooks").cloned().unwrap_or_else(|| json!({}));
105
106 let mut hooks_obj = hooks.as_object().cloned().unwrap_or_default();
107 hooks_obj.insert("Stop".to_string(), stop_hook);
108 hooks_obj.insert("PostToolUse".to_string(), post_tool_hook);
109
110 settings["hooks"] = json!(hooks_obj);
111
112 let content = serde_json::to_string_pretty(&settings)?;
114 fs::write(&settings_path, content)?;
115
116 Ok(())
117}
118
119pub fn uninstall_hooks(project_root: &Path) -> Result<()> {
121 let settings_path = project_root.join(".claude").join("settings.local.json");
122
123 if !settings_path.exists() {
124 return Ok(());
125 }
126
127 let content = fs::read_to_string(&settings_path)?;
128 let mut settings: Value = serde_json::from_str(&content)?;
129
130 if let Some(hooks) = settings.get_mut("hooks") {
132 if let Some(hooks_obj) = hooks.as_object_mut() {
133 if let Some(stop) = hooks_obj.get("Stop") {
135 let is_ours = stop
136 .as_array()
137 .map(|arr| {
138 arr.iter().any(|h| {
139 h.get("hooks")
140 .and_then(|cmds| cmds.as_array())
141 .map(|cmds| {
142 cmds.iter().any(|cmd| {
143 cmd.get("command")
144 .and_then(|c| c.as_str())
145 .map(|s| s.contains("SCUD_TASK_ID"))
146 .unwrap_or(false)
147 })
148 })
149 .unwrap_or(false)
150 })
151 })
152 .unwrap_or(false);
153
154 if is_ours {
155 hooks_obj.remove("Stop");
156 }
157 }
158
159 if let Some(post_tool) = hooks_obj.get("PostToolUse") {
161 let is_ours = post_tool
162 .as_array()
163 .map(|arr| {
164 arr.iter().any(|h| {
165 h.get("hooks")
166 .and_then(|cmds| cmds.as_array())
167 .map(|cmds| {
168 cmds.iter().any(|cmd| {
169 cmd.get("command")
170 .and_then(|c| c.as_str())
171 .map(|s| s.contains("scud sync-from-claude"))
172 .unwrap_or(false)
173 })
174 })
175 .unwrap_or(false)
176 })
177 })
178 .unwrap_or(false);
179
180 if is_ours {
181 hooks_obj.remove("PostToolUse");
182 }
183 }
184 }
185 }
186
187 let content = serde_json::to_string_pretty(&settings)?;
188 fs::write(&settings_path, content)?;
189
190 Ok(())
191}
192
193pub fn agent_env_setup(task_id: &str) -> String {
196 format!("export SCUD_TASK_ID=\"{}\"", task_id)
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use tempfile::TempDir;
203
204 #[test]
205 fn test_hooks_not_installed_missing_file() {
206 let tmp = TempDir::new().unwrap();
207 assert!(!hooks_installed(tmp.path()));
208 }
209
210 #[test]
211 fn test_install_hooks_creates_settings() {
212 let tmp = TempDir::new().unwrap();
213
214 install_hooks(tmp.path()).unwrap();
215
216 let settings_path = tmp.path().join(".claude").join("settings.local.json");
217 assert!(settings_path.exists());
218
219 let content = fs::read_to_string(&settings_path).unwrap();
220 assert!(content.contains("SCUD_TASK_ID"));
221 assert!(content.contains("scud"));
222 }
223
224 #[test]
225 fn test_hooks_installed_detects_our_hook() {
226 let tmp = TempDir::new().unwrap();
227
228 install_hooks(tmp.path()).unwrap();
229 assert!(hooks_installed(tmp.path()));
230 }
231
232 #[test]
233 fn test_uninstall_hooks() {
234 let tmp = TempDir::new().unwrap();
235
236 install_hooks(tmp.path()).unwrap();
237 assert!(hooks_installed(tmp.path()));
238
239 uninstall_hooks(tmp.path()).unwrap();
240 assert!(!hooks_installed(tmp.path()));
241 }
242
243 #[test]
244 fn test_agent_env_setup() {
245 let env = agent_env_setup("auth:5");
246 assert_eq!(env, "export SCUD_TASK_ID=\"auth:5\"");
247 }
248
249 #[test]
250 fn test_install_hooks_includes_post_tool_use() {
251 let tmp = TempDir::new().unwrap();
252
253 install_hooks(tmp.path()).unwrap();
254
255 let settings_path = tmp.path().join(".claude").join("settings.local.json");
256 let content = fs::read_to_string(&settings_path).unwrap();
257
258 assert!(content.contains("PostToolUse"));
260 assert!(content.contains("TaskUpdate|TaskCreate"));
261 assert!(content.contains("sync-from-claude"));
262 }
263
264 #[test]
265 fn test_uninstall_removes_post_tool_use_hook() {
266 let tmp = TempDir::new().unwrap();
267
268 install_hooks(tmp.path()).unwrap();
269
270 let settings_path = tmp.path().join(".claude").join("settings.local.json");
271 let content = fs::read_to_string(&settings_path).unwrap();
272 assert!(content.contains("PostToolUse"));
273
274 uninstall_hooks(tmp.path()).unwrap();
275
276 let content = fs::read_to_string(&settings_path).unwrap();
277 assert!(!content.contains("PostToolUse"));
278 }
279}