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}