1use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde_json::Value;
13
14const SETTINGS_JSON: &str = r#"{
20 "hooks": {
21 "PreToolUse": [
22 {
23 "matcher": "Read|Glob|Grep",
24 "hooks": [
25 {
26 "type": "command",
27 "command": ".claude/hooks/pre-read.sh",
28 "timeout": 3000
29 }
30 ]
31 },
32 {
33 "matcher": "Bash",
34 "hooks": [
35 {
36 "type": "command",
37 "command": ".claude/hooks/pre-bash.sh",
38 "timeout": 3000
39 }
40 ]
41 }
42 ],
43 "PostToolUse": [
44 {
45 "matcher": "Read|Glob|Grep",
46 "hooks": [
47 {
48 "type": "command",
49 "command": ".claude/hooks/post-read-compliance.sh"
50 }
51 ]
52 },
53 {
54 "matcher": "Edit|Write|MultiEdit",
55 "hooks": [
56 {
57 "type": "command",
58 "command": ".claude/hooks/post-edit.sh"
59 }
60 ]
61 }
62 ],
63 "PreCompact": [
64 {
65 "hooks": [
66 {
67 "type": "command",
68 "command": ".claude/hooks/pre-compact.sh"
69 }
70 ]
71 }
72 ],
73 "SessionEnd": [
74 {
75 "hooks": [
76 {
77 "type": "command",
78 "command": ".claude/hooks/session-end.sh"
79 }
80 ]
81 }
82 ]
83 },
84 "mcpServers": {
85 "mati": {
86 "command": "mati",
87 "args": ["serve"]
88 }
89 }
90}
91"#;
92
93pub const HOOK_SCRIPTS: &[(&str, &str)] = &[
98 ("pre-read.sh", crate::hooks::pre_read::SCRIPT),
99 ("pre-bash.sh", crate::hooks::pre_bash::SCRIPT),
100 (
101 "post-read-compliance.sh",
102 crate::hooks::post_compliance::SCRIPT,
103 ),
104 ("post-edit.sh", crate::hooks::post_edit::SCRIPT),
105 ("pre-compact.sh", crate::hooks::pre_compact::SCRIPT),
106 ("session-end.sh", crate::hooks::session_end::SCRIPT),
107];
108
109#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum InstallResult {
112 Installed {
114 scripts: usize,
116 missing_deps: Vec<&'static str>,
118 },
119 NoClaude,
121}
122
123pub fn install_hooks(project_root: &Path) -> Result<InstallResult> {
134 let claude_dir = project_root.join(".claude");
135 if !claude_dir.is_dir() {
136 return Ok(InstallResult::NoClaude);
137 }
138
139 let settings_path = claude_dir.join("settings.json");
141 merge_hooks_into_settings(&settings_path)
142 .with_context(|| format!("failed to update {}", settings_path.display()))?;
143
144 let mcp_json_path = project_root.join(".mcp.json");
146 write_mcp_json(&mcp_json_path, project_root)
147 .with_context(|| format!("failed to write {}", mcp_json_path.display()))?;
148
149 let hooks_dir = claude_dir.join("hooks");
151 std::fs::create_dir_all(&hooks_dir)
152 .with_context(|| format!("failed to create {}", hooks_dir.display()))?;
153
154 for (name, content) in HOOK_SCRIPTS {
155 let path = hooks_dir.join(name);
156 write_if_changed(&path, content)
157 .with_context(|| format!("failed to write {}", path.display()))?;
158 make_executable(&path)?;
159 }
160
161 super::write_mati_wrapper(&hooks_dir)?;
163
164 let missing_deps = missing_hook_dependencies();
165
166 Ok(InstallResult::Installed {
167 scripts: HOOK_SCRIPTS.len(),
168 missing_deps,
169 })
170}
171
172fn merge_hooks_into_settings(path: &Path) -> Result<()> {
178 let mut mati_settings: Value = serde_json::from_str(SETTINGS_JSON)?;
179 mati_settings["mcpServers"]["mati"]["command"] = serde_json::Value::String("mati".to_owned());
181
182 let merged = if path.exists() {
183 let existing_str = std::fs::read_to_string(path)?;
184 let mut existing: Value = serde_json::from_str(&existing_str)
185 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
186
187 if let Value::Object(ref mut map) = existing {
188 merge_hooks(map, &mati_settings["hooks"]);
189 let mati_server = mati_settings["mcpServers"]["mati"].clone();
191 if let Some(Value::Object(ref mut servers)) = map.get_mut("mcpServers") {
192 servers.insert("mati".to_string(), mati_server);
193 } else {
194 map.insert(
195 "mcpServers".to_string(),
196 mati_settings["mcpServers"].clone(),
197 );
198 }
199 }
200 existing
201 } else {
202 mati_settings
203 };
204
205 let output = serde_json::to_string_pretty(&merged)?;
206 write_if_changed(path, &output)?;
207 Ok(())
208}
209
210fn merge_hooks(root: &mut serde_json::Map<String, Value>, mati_hooks: &Value) {
211 let Some(mati_events) = mati_hooks.as_object() else {
212 root.insert("hooks".to_string(), mati_hooks.clone());
213 return;
214 };
215
216 let hooks_value = root
217 .entry("hooks".to_string())
218 .or_insert_with(|| Value::Object(serde_json::Map::new()));
219
220 let Value::Object(existing_events) = hooks_value else {
221 *hooks_value = mati_hooks.clone();
222 return;
223 };
224
225 for (event_name, mati_entries_value) in mati_events {
226 let Some(mati_entries) = mati_entries_value.as_array() else {
227 existing_events.insert(event_name.clone(), mati_entries_value.clone());
228 continue;
229 };
230
231 let owned_commands = mati_hook_commands(mati_entries);
232 let existing_entries = existing_events
233 .entry(event_name.clone())
234 .or_insert_with(|| Value::Array(Vec::new()));
235
236 let Value::Array(existing_entries) = existing_entries else {
237 *existing_entries = Value::Array(mati_entries.clone());
238 continue;
239 };
240
241 existing_entries.retain(|entry| !entry_contains_owned_command(entry, &owned_commands));
242 existing_entries.extend(mati_entries.clone());
243 }
244}
245
246fn mati_hook_commands(entries: &[Value]) -> Vec<String> {
247 entries.iter().flat_map(entry_hook_commands).collect()
248}
249
250fn entry_hook_commands(entry: &Value) -> Vec<String> {
251 entry
252 .get("hooks")
253 .and_then(Value::as_array)
254 .into_iter()
255 .flatten()
256 .filter_map(|hook| hook.get("command").and_then(Value::as_str))
257 .map(ToOwned::to_owned)
258 .collect()
259}
260
261fn entry_contains_owned_command(entry: &Value, owned_commands: &[String]) -> bool {
262 entry_hook_commands(entry)
263 .iter()
264 .any(|command| owned_commands.iter().any(|owned| owned == command))
265}
266
267fn write_mcp_json(path: &Path, _project_root: &Path) -> Result<()> {
277 let mati_server = serde_json::json!({
278 "command": "mati",
279 "args": ["serve"]
280 });
281
282 let mut mcp_config = if path.exists() {
283 let existing_str = std::fs::read_to_string(path)?;
284 serde_json::from_str(&existing_str)
285 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
286 } else {
287 Value::Object(serde_json::Map::new())
288 };
289
290 if let Value::Object(ref mut map) = mcp_config {
291 if let Some(Value::Object(ref mut servers)) = map.get_mut("mcpServers") {
292 servers.insert("mati".to_string(), mati_server);
293 } else {
294 map.insert(
295 "mcpServers".to_string(),
296 serde_json::json!({ "mati": mati_server }),
297 );
298 }
299 } else {
300 mcp_config = serde_json::json!({
301 "mcpServers": {
302 "mati": mati_server
303 }
304 });
305 }
306
307 let output = serde_json::to_string_pretty(&mcp_config)?;
308 write_if_changed(path, &output)?;
309 Ok(())
310}
311
312fn command_available(cmd: &str) -> bool {
313 std::process::Command::new(cmd)
314 .arg("--version")
315 .stdout(std::process::Stdio::null())
316 .stderr(std::process::Stdio::null())
317 .status()
318 .map(|s| s.success())
319 .unwrap_or(false)
320}
321
322fn missing_hook_dependencies() -> Vec<&'static str> {
323 missing_hook_dependencies_with(command_available)
324}
325
326fn missing_hook_dependencies_with<F>(mut has_cmd: F) -> Vec<&'static str>
327where
328 F: FnMut(&str) -> bool,
329{
330 let mut missing = Vec::new();
331 if !has_cmd("jq") {
332 missing.push("jq");
333 }
334 if !has_cmd("awk") {
335 missing.push("awk");
336 }
337 missing
338}
339
340use super::{make_executable, write_if_changed};
341
342#[cfg(test)]
345mod tests {
346 use super::*;
347 use tempfile::TempDir;
348
349 #[test]
350 fn skips_when_no_claude_dir() {
351 let dir = TempDir::new().unwrap();
352 let result = install_hooks(dir.path()).unwrap();
353 assert_eq!(result, InstallResult::NoClaude);
354 }
355
356 #[test]
357 fn installs_settings_and_scripts() {
358 let dir = TempDir::new().unwrap();
359 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
360
361 let result = install_hooks(dir.path()).unwrap();
362 match result {
363 InstallResult::Installed { scripts, .. } => assert_eq!(scripts, 6),
364 other => panic!("expected Installed, got {other:?}"),
365 }
366
367 let settings = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
369 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
370 assert!(parsed["hooks"]["PreToolUse"].is_array());
371 assert!(parsed["hooks"]["PostToolUse"].is_array());
372 assert!(parsed["hooks"]["PreCompact"].is_array());
373 assert!(parsed["hooks"]["SessionEnd"].is_array());
374 let cmd = parsed["mcpServers"]["mati"]["command"].as_str().unwrap();
376 assert_eq!(cmd, "mati", "command must be bare 'mati' for portability");
377 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
378 }
379
380 #[test]
381 fn merges_into_existing_settings_without_clobbering() {
382 let dir = TempDir::new().unwrap();
383 let claude_dir = dir.path().join(".claude");
384 std::fs::create_dir_all(&claude_dir).unwrap();
385
386 let existing = r#"{"permissions": {"allow": ["npm test"]}, "env": {"DEBUG": "true"}}"#;
388 std::fs::write(claude_dir.join("settings.json"), existing).unwrap();
389
390 install_hooks(dir.path()).unwrap();
391
392 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
393 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
394
395 assert_eq!(parsed["permissions"]["allow"][0], "npm test");
397 assert_eq!(parsed["env"]["DEBUG"], "true");
398 assert!(parsed["hooks"]["PreToolUse"].is_array());
400 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
402 }
403
404 #[test]
405 fn merges_mcp_servers_without_clobbering_existing_servers() {
406 let dir = TempDir::new().unwrap();
407 let claude_dir = dir.path().join(".claude");
408 std::fs::create_dir_all(&claude_dir).unwrap();
409
410 let existing = r#"{"mcpServers": {"other-tool": {"command": "other", "args": ["run"]}}}"#;
412 std::fs::write(claude_dir.join("settings.json"), existing).unwrap();
413
414 install_hooks(dir.path()).unwrap();
415
416 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
417 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
418
419 assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
421 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
423 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
424 }
425
426 #[test]
427 fn merges_hooks_without_clobbering_unrelated_existing_hooks() {
428 let dir = TempDir::new().unwrap();
429 let claude_dir = dir.path().join(".claude");
430 std::fs::create_dir_all(&claude_dir).unwrap();
431
432 let existing = serde_json::json!({
433 "hooks": {
434 "PreToolUse": [
435 {
436 "matcher": "Write",
437 "hooks": [
438 {
439 "type": "command",
440 "command": ".claude/hooks/custom-pre-write.sh"
441 }
442 ]
443 }
444 ]
445 }
446 });
447 std::fs::write(
448 claude_dir.join("settings.json"),
449 serde_json::to_string_pretty(&existing).unwrap(),
450 )
451 .unwrap();
452
453 install_hooks(dir.path()).unwrap();
454
455 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
456 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
457 let pre_tool_use = parsed["hooks"]["PreToolUse"].as_array().unwrap();
458
459 assert!(
460 pre_tool_use.iter().any(|entry| {
461 entry["hooks"]
462 .as_array()
463 .into_iter()
464 .flatten()
465 .any(|hook| hook["command"] == ".claude/hooks/custom-pre-write.sh")
466 }),
467 "custom existing hook should be preserved"
468 );
469 assert!(
470 pre_tool_use.iter().any(|entry| {
471 entry["hooks"]
472 .as_array()
473 .into_iter()
474 .flatten()
475 .any(|hook| hook["command"] == ".claude/hooks/pre-read.sh")
476 }),
477 "mati pre-read hook should be present"
478 );
479 }
480
481 #[test]
482 fn all_hook_scripts_exist_and_are_executable() {
483 let dir = TempDir::new().unwrap();
484 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
485
486 install_hooks(dir.path()).unwrap();
487
488 let hooks_dir = dir.path().join(".claude/hooks");
489 for (name, _) in HOOK_SCRIPTS {
490 let path = hooks_dir.join(name);
491 assert!(path.exists(), "missing hook script: {name}");
492
493 #[cfg(unix)]
494 {
495 use std::os::unix::fs::PermissionsExt;
496 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
497 assert_eq!(mode & 0o111, 0o111, "{name} should be executable");
498 }
499 }
500 }
501
502 #[test]
503 fn pre_hooks_delegate_to_hook_decide() {
504 assert!(crate::hooks::pre_read::SCRIPT.contains("exec mati hook-decide claude-pre-read"));
507 assert!(crate::hooks::pre_bash::SCRIPT.contains("exec mati hook-decide claude-pre-bash"));
508 }
509
510 #[test]
511 fn writes_mcp_json_to_project_root() {
512 let dir = TempDir::new().unwrap();
513 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
514
515 install_hooks(dir.path()).unwrap();
516
517 let mcp_json_path = dir.path().join(".mcp.json");
518 assert!(
519 mcp_json_path.exists(),
520 ".mcp.json should be written to project root"
521 );
522
523 let content = std::fs::read_to_string(&mcp_json_path).unwrap();
524 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
525 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
526 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
527 assert!(
529 parsed["mcpServers"]["mati"]["args"]
530 .as_array()
531 .unwrap()
532 .len()
533 == 1,
534 "args must only contain 'serve', no --path"
535 );
536 }
537
538 #[test]
539 fn write_mcp_json_preserves_existing_servers() {
540 let dir = TempDir::new().unwrap();
541 let path = dir.path().join(".mcp.json");
542 let existing = serde_json::json!({
543 "mcpServers": {
544 "other-tool": {
545 "command": "other",
546 "args": ["run"]
547 }
548 }
549 });
550 std::fs::write(&path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
551
552 write_mcp_json(&path, dir.path()).unwrap();
553
554 let content = std::fs::read_to_string(&path).unwrap();
555 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
556 assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
557 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
558 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
559 }
560
561 #[test]
562 fn detects_all_hook_runtime_dependencies() {
563 let missing = missing_hook_dependencies_with(|cmd| cmd == "jq");
564 assert_eq!(missing, vec!["awk"]);
565
566 let missing = missing_hook_dependencies_with(|_| false);
567 assert_eq!(missing, vec!["jq", "awk"]);
568 }
569
570 #[test]
571 fn idempotent_on_rerun() {
572 let dir = TempDir::new().unwrap();
573 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
574
575 install_hooks(dir.path()).unwrap();
576 let first_content =
577 std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
578 let first_mcp = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
579
580 install_hooks(dir.path()).unwrap();
581 let second_content =
582 std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
583 let second_mcp = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
584
585 assert_eq!(first_content, second_content);
586 assert_eq!(first_mcp, second_mcp);
587 }
588
589 #[test]
590 fn claude_wrapper_exists_and_matches_mcp_config() {
591 let dir = TempDir::new().unwrap();
592 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
593 install_hooks(dir.path()).unwrap();
594
595 let wrapper_path = dir.path().join(".claude/hooks/mati");
597 assert!(
598 wrapper_path.exists(),
599 ".claude/hooks/mati wrapper must exist"
600 );
601
602 let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
603 assert!(wrapper.contains("exec"), "wrapper must use exec");
604
605 let exec_line = wrapper.lines().find(|l| l.contains("exec")).unwrap();
607 let exec_target = exec_line
608 .strip_prefix("exec \"")
609 .and_then(|s| s.strip_suffix("\" \"$@\""))
610 .expect("exec line must follow format: exec \"<path>\" \"$@\"");
611 assert!(
612 exec_target.starts_with('/'),
613 "wrapper must use absolute path, got: {exec_target}"
614 );
615
616 let settings = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
618 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
619 assert_eq!(
620 parsed["mcpServers"]["mati"]["command"], "mati",
621 "MCP config must use bare 'mati' for portability"
622 );
623 }
624
625 #[test]
626 fn claude_hook_scripts_prepend_hooks_dir_to_path() {
627 let dir = TempDir::new().unwrap();
628 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
629 install_hooks(dir.path()).unwrap();
630
631 for (name, _) in HOOK_SCRIPTS {
632 let path = dir.path().join(".claude/hooks").join(name);
633 let content = std::fs::read_to_string(&path)
634 .unwrap_or_else(|_| panic!("hook script {name} must exist"));
635 assert!(
636 content.contains("HOOKS_DIR=") && content.contains("export PATH="),
637 "hook script {name} must prepend HOOKS_DIR to PATH"
638 );
639 }
640 }
641}