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).context("Failed to create .claude directory")?;
59
60    // Load existing settings or create new
61    let mut settings: Value = if settings_path.exists() {
62        let content = fs::read_to_string(&settings_path)?;
63        serde_json::from_str(&content).unwrap_or_else(|_| json!({}))
64    } else {
65        json!({})
66    };
67
68    // Build the Stop hook that reads SCUD_TASK_ID and marks task done
69    // The hook command:
70    // 1. Checks if SCUD_TASK_ID is set
71    // 2. If set, marks the task as done
72    let stop_hook = json!([
73        {
74            "matcher": "",
75            "hooks": [
76                {
77                    "type": "command",
78                    // Read task ID from env and mark done
79                    // Uses bash to check env var and conditionally run scud
80                    "command": "bash -c 'if [ -n \"$SCUD_TASK_ID\" ]; then scud set-status \"$SCUD_TASK_ID\" done 2>/dev/null || true; fi'",
81                    "timeout": 10
82                }
83            ]
84        }
85    ]);
86
87    // Merge with existing hooks (preserve other hooks)
88    let hooks = settings.get("hooks").cloned().unwrap_or_else(|| json!({}));
89
90    let mut hooks_obj = hooks.as_object().cloned().unwrap_or_default();
91    hooks_obj.insert("Stop".to_string(), stop_hook);
92
93    settings["hooks"] = json!(hooks_obj);
94
95    // Write back
96    let content = serde_json::to_string_pretty(&settings)?;
97    fs::write(&settings_path, content)?;
98
99    Ok(())
100}
101
102/// Uninstall SCUD hooks from the project's Claude settings
103pub fn uninstall_hooks(project_root: &Path) -> Result<()> {
104    let settings_path = project_root.join(".claude").join("settings.local.json");
105
106    if !settings_path.exists() {
107        return Ok(());
108    }
109
110    let content = fs::read_to_string(&settings_path)?;
111    let mut settings: Value = serde_json::from_str(&content)?;
112
113    // Remove Stop hook if it's ours
114    if let Some(hooks) = settings.get_mut("hooks") {
115        if let Some(hooks_obj) = hooks.as_object_mut() {
116            // Check if Stop hook contains our scud command before removing
117            if let Some(stop) = hooks_obj.get("Stop") {
118                let is_ours = stop
119                    .as_array()
120                    .map(|arr| {
121                        arr.iter().any(|h| {
122                            h.get("hooks")
123                                .and_then(|cmds| cmds.as_array())
124                                .map(|cmds| {
125                                    cmds.iter().any(|cmd| {
126                                        cmd.get("command")
127                                            .and_then(|c| c.as_str())
128                                            .map(|s| s.contains("SCUD_TASK_ID"))
129                                            .unwrap_or(false)
130                                    })
131                                })
132                                .unwrap_or(false)
133                        })
134                    })
135                    .unwrap_or(false);
136
137                if is_ours {
138                    hooks_obj.remove("Stop");
139                }
140            }
141        }
142    }
143
144    let content = serde_json::to_string_pretty(&settings)?;
145    fs::write(&settings_path, content)?;
146
147    Ok(())
148}
149
150/// Generate environment setup for a spawned agent
151/// Returns the env var that should be set for the agent
152pub fn agent_env_setup(task_id: &str) -> String {
153    format!("export SCUD_TASK_ID=\"{}\"", task_id)
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use tempfile::TempDir;
160
161    #[test]
162    fn test_hooks_not_installed_missing_file() {
163        let tmp = TempDir::new().unwrap();
164        assert!(!hooks_installed(tmp.path()));
165    }
166
167    #[test]
168    fn test_install_hooks_creates_settings() {
169        let tmp = TempDir::new().unwrap();
170
171        install_hooks(tmp.path()).unwrap();
172
173        let settings_path = tmp.path().join(".claude").join("settings.local.json");
174        assert!(settings_path.exists());
175
176        let content = fs::read_to_string(&settings_path).unwrap();
177        assert!(content.contains("SCUD_TASK_ID"));
178        assert!(content.contains("scud"));
179    }
180
181    #[test]
182    fn test_hooks_installed_detects_our_hook() {
183        let tmp = TempDir::new().unwrap();
184
185        install_hooks(tmp.path()).unwrap();
186        assert!(hooks_installed(tmp.path()));
187    }
188
189    #[test]
190    fn test_uninstall_hooks() {
191        let tmp = TempDir::new().unwrap();
192
193        install_hooks(tmp.path()).unwrap();
194        assert!(hooks_installed(tmp.path()));
195
196        uninstall_hooks(tmp.path()).unwrap();
197        assert!(!hooks_installed(tmp.path()));
198    }
199
200    #[test]
201    fn test_agent_env_setup() {
202        let env = agent_env_setup("auth:5");
203        assert_eq!(env, "export SCUD_TASK_ID=\"auth:5\"");
204    }
205}