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//! - Track which task an agent is working on via environment variables
6
7use anyhow::{Context, Result};
8use serde_json::{json, Value};
9use std::fs;
10use std::path::Path;
11
12/// Check if SCUD hooks are installed in the project's Claude settings
13pub fn hooks_installed(project_root: &Path) -> bool {
14    let settings_path = project_root.join(".claude").join("settings.local.json");
15
16    if !settings_path.exists() {
17        return false;
18    }
19
20    match fs::read_to_string(&settings_path) {
21        Ok(content) => {
22            if let Ok(settings) = serde_json::from_str::<Value>(&content) {
23                // Check for our Stop hook
24                settings
25                    .get("hooks")
26                    .and_then(|h| h.get("Stop"))
27                    .and_then(|s| s.as_array())
28                    .map(|arr| {
29                        arr.iter().any(|hook| {
30                            hook.get("hooks")
31                                .and_then(|h| h.as_array())
32                                .map(|cmds| {
33                                    cmds.iter().any(|cmd| {
34                                        cmd.get("command")
35                                            .and_then(|c| c.as_str())
36                                            .map(|s| s.contains("scud") && s.contains("set-status"))
37                                            .unwrap_or(false)
38                                    })
39                                })
40                                .unwrap_or(false)
41                        })
42                    })
43                    .unwrap_or(false)
44            } else {
45                false
46            }
47        }
48        Err(_) => false,
49    }
50}
51
52/// Install SCUD hooks into the project's Claude settings
53pub fn install_hooks(project_root: &Path) -> Result<()> {
54    let claude_dir = project_root.join(".claude");
55    let settings_path = claude_dir.join("settings.local.json");
56
57    // Ensure .claude directory exists
58    fs::create_dir_all(&claude_dir)
59        .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    // Merge with existing hooks (preserve other hooks)
89    let hooks = settings
90        .get("hooks")
91        .cloned()
92        .unwrap_or_else(|| json!({}));
93
94    let mut hooks_obj = hooks.as_object().cloned().unwrap_or_default();
95    hooks_obj.insert("Stop".to_string(), stop_hook);
96
97    settings["hooks"] = json!(hooks_obj);
98
99    // Write back
100    let content = serde_json::to_string_pretty(&settings)?;
101    fs::write(&settings_path, content)?;
102
103    Ok(())
104}
105
106/// Uninstall SCUD hooks from the project's Claude settings
107pub fn uninstall_hooks(project_root: &Path) -> Result<()> {
108    let settings_path = project_root.join(".claude").join("settings.local.json");
109
110    if !settings_path.exists() {
111        return Ok(());
112    }
113
114    let content = fs::read_to_string(&settings_path)?;
115    let mut settings: Value = serde_json::from_str(&content)?;
116
117    // Remove Stop hook if it's ours
118    if let Some(hooks) = settings.get_mut("hooks") {
119        if let Some(hooks_obj) = hooks.as_object_mut() {
120            // Check if Stop hook contains our scud command before removing
121            if let Some(stop) = hooks_obj.get("Stop") {
122                let is_ours = stop
123                    .as_array()
124                    .map(|arr| {
125                        arr.iter().any(|h| {
126                            h.get("hooks")
127                                .and_then(|cmds| cmds.as_array())
128                                .map(|cmds| {
129                                    cmds.iter().any(|cmd| {
130                                        cmd.get("command")
131                                            .and_then(|c| c.as_str())
132                                            .map(|s| s.contains("SCUD_TASK_ID"))
133                                            .unwrap_or(false)
134                                    })
135                                })
136                                .unwrap_or(false)
137                        })
138                    })
139                    .unwrap_or(false);
140
141                if is_ours {
142                    hooks_obj.remove("Stop");
143                }
144            }
145        }
146    }
147
148    let content = serde_json::to_string_pretty(&settings)?;
149    fs::write(&settings_path, content)?;
150
151    Ok(())
152}
153
154/// Generate environment setup for a spawned agent
155/// Returns the env var that should be set for the agent
156pub fn agent_env_setup(task_id: &str) -> String {
157    format!("export SCUD_TASK_ID=\"{}\"", task_id)
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use tempfile::TempDir;
164
165    #[test]
166    fn test_hooks_not_installed_missing_file() {
167        let tmp = TempDir::new().unwrap();
168        assert!(!hooks_installed(tmp.path()));
169    }
170
171    #[test]
172    fn test_install_hooks_creates_settings() {
173        let tmp = TempDir::new().unwrap();
174
175        install_hooks(tmp.path()).unwrap();
176
177        let settings_path = tmp.path().join(".claude").join("settings.local.json");
178        assert!(settings_path.exists());
179
180        let content = fs::read_to_string(&settings_path).unwrap();
181        assert!(content.contains("SCUD_TASK_ID"));
182        assert!(content.contains("scud"));
183    }
184
185    #[test]
186    fn test_hooks_installed_detects_our_hook() {
187        let tmp = TempDir::new().unwrap();
188
189        install_hooks(tmp.path()).unwrap();
190        assert!(hooks_installed(tmp.path()));
191    }
192
193    #[test]
194    fn test_uninstall_hooks() {
195        let tmp = TempDir::new().unwrap();
196
197        install_hooks(tmp.path()).unwrap();
198        assert!(hooks_installed(tmp.path()));
199
200        uninstall_hooks(tmp.path()).unwrap();
201        assert!(!hooks_installed(tmp.path()));
202    }
203
204    #[test]
205    fn test_agent_env_setup() {
206        let env = agent_env_setup("auth:5");
207        assert_eq!(env, "export SCUD_TASK_ID=\"auth:5\"");
208    }
209}