Skip to main content

safe_chains/
setup.rs

1use std::path::{Path, PathBuf};
2use std::process;
3
4use serde_json::{Map, Value, json};
5
6fn claude_dir(home: &Path) -> PathBuf {
7    home.join(".claude")
8}
9
10fn settings_path(home: &Path) -> PathBuf {
11    claude_dir(home).join("settings.json")
12}
13
14fn hook_entry(binary: &str) -> Value {
15    json!({
16        "matcher": "Bash",
17        "hooks": [{
18            "type": "command",
19            "command": binary,
20        }]
21    })
22}
23
24fn has_safe_chains_hook(settings: &Value) -> bool {
25    settings
26        .get("hooks")
27        .and_then(|h| h.get("PreToolUse"))
28        .and_then(|arr| arr.as_array())
29        .is_some_and(|entries| {
30            entries.iter().any(|entry| {
31                entry
32                    .get("hooks")
33                    .and_then(|h| h.as_array())
34                    .is_some_and(|hooks| {
35                        hooks.iter().any(|hook| {
36                            hook.get("command")
37                                .and_then(|c| c.as_str())
38                                .is_some_and(|cmd| cmd.contains("safe-chains"))
39                        })
40                    })
41            })
42        })
43}
44
45fn add_hook(settings: &mut Value, binary: &str) {
46    if !settings.is_object() {
47        *settings = json!({});
48    }
49    let Some(obj) = settings.as_object_mut() else {
50        unreachable!("settings was just set to an object");
51    };
52    let hooks = obj
53        .entry("hooks")
54        .or_insert_with(|| json!({}))
55        .as_object_mut()
56        .unwrap_or_else(|| {
57            eprintln!("Error: \"hooks\" key in settings.json is not an object");
58            process::exit(1);
59        });
60    let pre_tool_use = hooks
61        .entry("PreToolUse")
62        .or_insert_with(|| json!([]))
63        .as_array_mut()
64        .unwrap_or_else(|| {
65            eprintln!("Error: \"hooks.PreToolUse\" in settings.json is not an array");
66            process::exit(1);
67        });
68    pre_tool_use.push(hook_entry(binary));
69}
70
71pub fn run_setup_with_home(home: &Path) -> Result<String, String> {
72    let dir = claude_dir(home);
73    if !dir.exists() {
74        return Err(format!(
75            "~/.claude directory not found at {}. Install Claude Code first.",
76            dir.display()
77        ));
78    }
79
80    let binary = "safe-chains";
81    let path = settings_path(home);
82
83    if path.exists() {
84        let contents = std::fs::read_to_string(&path)
85            .map_err(|e| format!("Could not read {}: {e}", path.display()))?;
86        let mut settings: Value = serde_json::from_str(&contents)
87            .map_err(|e| format!("Could not parse {}: {e}", path.display()))?;
88
89        if has_safe_chains_hook(&settings) {
90            return Ok(format!(
91                "safe-chains hook already configured in {}",
92                path.display()
93            ));
94        }
95
96        add_hook(&mut settings, binary);
97        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
98        std::fs::write(&path, format!("{output}\n"))
99            .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
100        Ok(format!("safe-chains hook added to {}", path.display()))
101    } else {
102        let mut settings = Value::Object(Map::new());
103        add_hook(&mut settings, binary);
104        let output = serde_json::to_string_pretty(&settings).expect("serializing valid JSON");
105        std::fs::write(&path, format!("{output}\n"))
106            .map_err(|e| format!("Could not write {}: {e}", path.display()))?;
107        Ok(format!("Created {} with safe-chains hook", path.display()))
108    }
109}
110
111pub fn run_setup() {
112    let Some(home) = std::env::var_os("HOME") else {
113        eprintln!("Error: HOME environment variable not set");
114        process::exit(1);
115    };
116    match run_setup_with_home(Path::new(&home)) {
117        Ok(msg) => println!("{msg}"),
118        Err(msg) => {
119            eprintln!("Error: {msg}");
120            process::exit(1);
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn no_claude_dir() {
131        let dir = tempfile::tempdir().unwrap();
132        let result = run_setup_with_home(dir.path());
133        assert!(result.is_err());
134        assert!(result.unwrap_err().contains("not found"));
135    }
136
137    #[test]
138    fn no_settings_file_creates_it() {
139        let dir = tempfile::tempdir().unwrap();
140        std::fs::create_dir(dir.path().join(".claude")).unwrap();
141        let result = run_setup_with_home(dir.path());
142        assert!(result.is_ok());
143        assert!(result.unwrap().contains("Created"));
144
145        let contents = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
146        let settings: Value = serde_json::from_str(&contents).unwrap();
147        assert!(has_safe_chains_hook(&settings));
148    }
149
150    #[test]
151    fn existing_settings_without_hook() {
152        let dir = tempfile::tempdir().unwrap();
153        let claude_dir = dir.path().join(".claude");
154        std::fs::create_dir(&claude_dir).unwrap();
155        std::fs::write(
156            claude_dir.join("settings.json"),
157            r#"{"permissions": {"allow": ["Bash(cargo test *)"]}}"#,
158        )
159        .unwrap();
160
161        let result = run_setup_with_home(dir.path());
162        assert!(result.is_ok());
163        assert!(result.unwrap().contains("added"));
164
165        let contents = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
166        let settings: Value = serde_json::from_str(&contents).unwrap();
167        assert!(has_safe_chains_hook(&settings));
168        assert!(
169            settings
170                .get("permissions")
171                .and_then(|p| p.get("allow"))
172                .is_some(),
173            "existing content should be preserved"
174        );
175    }
176
177    #[test]
178    fn already_configured() {
179        let dir = tempfile::tempdir().unwrap();
180        let claude_dir = dir.path().join(".claude");
181        std::fs::create_dir(&claude_dir).unwrap();
182        std::fs::write(
183            claude_dir.join("settings.json"),
184            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"safe-chains"}]}]}}"#,
185        )
186        .unwrap();
187
188        let result = run_setup_with_home(dir.path());
189        assert!(result.is_ok());
190        assert!(result.unwrap().contains("already configured"));
191    }
192
193    #[test]
194    fn already_configured_with_full_path() {
195        let dir = tempfile::tempdir().unwrap();
196        let claude_dir = dir.path().join(".claude");
197        std::fs::create_dir(&claude_dir).unwrap();
198        std::fs::write(
199            claude_dir.join("settings.json"),
200            r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"/opt/homebrew/bin/safe-chains"}]}]}}"#,
201        )
202        .unwrap();
203
204        let result = run_setup_with_home(dir.path());
205        assert!(result.is_ok());
206        assert!(result.unwrap().contains("already configured"));
207    }
208
209    #[test]
210    fn malformed_json() {
211        let dir = tempfile::tempdir().unwrap();
212        let claude_dir = dir.path().join(".claude");
213        std::fs::create_dir(&claude_dir).unwrap();
214        std::fs::write(claude_dir.join("settings.json"), "not json{{{").unwrap();
215
216        let result = run_setup_with_home(dir.path());
217        assert!(result.is_err());
218        assert!(result.unwrap_err().contains("Could not parse"));
219
220        let contents = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
221        assert_eq!(contents, "not json{{{", "should not clobber malformed file");
222    }
223
224    #[test]
225    fn idempotent() {
226        let dir = tempfile::tempdir().unwrap();
227        let claude_dir = dir.path().join(".claude");
228        std::fs::create_dir(&claude_dir).unwrap();
229
230        let result1 = run_setup_with_home(dir.path());
231        assert!(result1.is_ok());
232        assert!(result1.unwrap().contains("Created"));
233
234        let result2 = run_setup_with_home(dir.path());
235        assert!(result2.is_ok());
236        assert!(result2.unwrap().contains("already configured"));
237
238        let contents = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
239        let settings: Value = serde_json::from_str(&contents).unwrap();
240        let hooks = settings["hooks"]["PreToolUse"].as_array().unwrap();
241        assert_eq!(hooks.len(), 1, "should not duplicate hook entry");
242    }
243}