scud/commands/spawn/
hooks.rs

1//! Claude Code hook integration for spawn sessions
2//!
3//! Installs and manages hooks that automatically:
4//! - Mark tasks as done when Claude Code finishes (Stop hook)
5//! - Sync TaskUpdate/TaskCreate tool calls back to SCUD (PostToolUse hook)
6//! - Track which task an agent is working on via environment variables
7
8use anyhow::{Context, Result};
9use serde_json::{json, Value};
10use std::fs;
11use std::path::Path;
12
13/// Check if SCUD hooks are installed in the project's Claude settings
14pub 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                // Check for our Stop hook
25                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
53/// Install SCUD hooks into the project's Claude settings
54pub 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    // Ensure .claude directory exists
59    fs::create_dir_all(&claude_dir).context("Failed to create .claude directory")?;
60
61    // Load existing settings or create new
62    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    // Build the Stop hook that reads SCUD_TASK_ID and marks task done
70    // The hook command:
71    // 1. Checks if SCUD_TASK_ID is set
72    // 2. If set, marks the task as done
73    let stop_hook = json!([
74        {
75            "matcher": "",
76            "hooks": [
77                {
78                    "type": "command",
79                    // Read task ID from env and mark done
80                    // Uses bash to check env var and conditionally run scud
81                    "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    // PostToolUse hook - sync TaskUpdate/TaskCreate back to SCUD
89    let post_tool_hook = json!([
90        {
91            "matcher": "TaskUpdate|TaskCreate",
92            "hooks": [
93                {
94                    "type": "command",
95                    // Sync Claude task changes back to SCUD
96                    "command": "bash -c 'scud sync-from-claude 2>/dev/null || true'",
97                    "timeout": 10
98                }
99            ]
100        }
101    ]);
102
103    // Merge with existing hooks (preserve other hooks)
104    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    // Write back
113    let content = serde_json::to_string_pretty(&settings)?;
114    fs::write(&settings_path, content)?;
115
116    Ok(())
117}
118
119/// Uninstall SCUD hooks from the project's Claude settings
120pub 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    // Remove SCUD hooks if they're ours
131    if let Some(hooks) = settings.get_mut("hooks") {
132        if let Some(hooks_obj) = hooks.as_object_mut() {
133            // Check if Stop hook contains our scud command before removing
134            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            // Check if PostToolUse hook contains our scud sync command before removing
160            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
193/// Generate environment setup for a spawned agent
194/// Returns the env var that should be set for the agent
195pub 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        // Should have PostToolUse hook for TaskUpdate/TaskCreate
259        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}