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 "matcher": "Edit|Write|NotebookEdit",
44 "hooks": [
45 {
46 "type": "command",
47 "command": ".claude/hooks/pre-edit.sh",
48 "timeout": 3000
49 }
50 ]
51 }
52 ],
53 "PostToolUse": [
54 {
55 "matcher": "Read|Glob|Grep",
56 "hooks": [
57 {
58 "type": "command",
59 "command": ".claude/hooks/post-read-compliance.sh",
60 "async": true
61 }
62 ]
63 },
64 {
65 "matcher": "Edit|Write|NotebookEdit",
66 "hooks": [
67 {
68 "type": "command",
69 "command": ".claude/hooks/post-edit.sh",
70 "async": true
71 }
72 ]
73 },
74 {
75 "matcher": "mcp__mati__mem_get",
76 "hooks": [
77 { "type": "command", "command": ".claude/hooks/post-memget.sh" }
78 ]
79 }
80 ],
81 "PreCompact": [
82 {
83 "hooks": [
84 {
85 "type": "command",
86 "command": ".claude/hooks/pre-compact.sh"
87 }
88 ]
89 }
90 ],
91 "PostCompact": [
92 {
93 "hooks": [
94 {
95 "type": "command",
96 "command": ".claude/hooks/post-compact.sh"
97 }
98 ]
99 }
100 ],
101 "SessionEnd": [
102 {
103 "hooks": [
104 {
105 "type": "command",
106 "command": ".claude/hooks/session-end.sh",
107 "timeout": 3000
108 }
109 ]
110 }
111 ],
112 "SubagentStart": [
113 {
114 "hooks": [
115 {
116 "type": "command",
117 "command": ".claude/hooks/subagent-start.sh"
118 }
119 ]
120 }
121 ],
122 "Stop": [
123 {
124 "hooks": [
125 {
126 "type": "command",
127 "command": ".claude/hooks/stop.sh",
128 "async": true
129 }
130 ]
131 }
132 ]
133 },
134 "mcpServers": {
135 "mati": {
136 "command": "mati",
137 "args": ["serve"]
138 }
139 }
140}
141"#;
142
143pub const HOOK_SCRIPTS: &[(&str, &str)] = &[
148 ("pre-read.sh", crate::hooks::pre_read::SCRIPT),
149 ("pre-edit.sh", crate::hooks::pre_edit::SCRIPT),
150 ("pre-bash.sh", crate::hooks::pre_bash::SCRIPT),
151 (
152 "post-read-compliance.sh",
153 crate::hooks::post_compliance::SCRIPT,
154 ),
155 ("post-edit.sh", crate::hooks::post_edit::SCRIPT),
156 ("pre-compact.sh", crate::hooks::pre_compact::SCRIPT),
157 ("post-compact.sh", crate::hooks::post_compact::SCRIPT),
158 ("session-end.sh", crate::hooks::session_end::SCRIPT),
159 ("subagent-start.sh", crate::hooks::subagent_start::SCRIPT),
160 ("stop.sh", crate::hooks::claude_stop::SCRIPT),
161 ("post-memget.sh", crate::hooks::post_memget::SCRIPT),
162];
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum InstallResult {
167 Installed {
169 scripts: usize,
171 missing_deps: Vec<&'static str>,
173 },
174 NoClaude,
176}
177
178pub fn install_hooks(project_root: &Path) -> Result<InstallResult> {
189 let claude_dir = project_root.join(".claude");
190 if !claude_dir.is_dir() {
191 return Ok(InstallResult::NoClaude);
192 }
193
194 let settings_path = claude_dir.join("settings.json");
196 merge_hooks_into_settings(&settings_path)
197 .with_context(|| format!("failed to update {}", settings_path.display()))?;
198
199 let mcp_json_path = project_root.join(".mcp.json");
201 write_mcp_json(&mcp_json_path, project_root)
202 .with_context(|| format!("failed to write {}", mcp_json_path.display()))?;
203
204 let hooks_dir = claude_dir.join("hooks");
206 std::fs::create_dir_all(&hooks_dir)
207 .with_context(|| format!("failed to create {}", hooks_dir.display()))?;
208
209 for (name, content) in HOOK_SCRIPTS {
210 let path = hooks_dir.join(name);
211 write_if_changed(&path, content)
212 .with_context(|| format!("failed to write {}", path.display()))?;
213 make_executable(&path)?;
214 }
215
216 super::write_mati_wrapper(&hooks_dir)?;
218
219 let missing_deps = missing_hook_dependencies();
220
221 Ok(InstallResult::Installed {
222 scripts: HOOK_SCRIPTS.len(),
223 missing_deps,
224 })
225}
226
227fn merge_hooks_into_settings(path: &Path) -> Result<()> {
233 let mut mati_settings: Value = serde_json::from_str(SETTINGS_JSON)?;
234 mati_settings["mcpServers"]["mati"]["command"] = serde_json::Value::String("mati".to_owned());
236
237 let merged = if path.exists() {
238 let existing_str = std::fs::read_to_string(path)?;
239 let mut existing: Value = serde_json::from_str(&existing_str)
240 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
241
242 if let Value::Object(ref mut map) = existing {
243 merge_hooks(map, &mati_settings["hooks"]);
244 let mati_server = mati_settings["mcpServers"]["mati"].clone();
246 if let Some(Value::Object(ref mut servers)) = map.get_mut("mcpServers") {
247 servers.insert("mati".to_string(), mati_server);
248 } else {
249 map.insert(
250 "mcpServers".to_string(),
251 mati_settings["mcpServers"].clone(),
252 );
253 }
254 }
255 existing
256 } else {
257 mati_settings
258 };
259
260 let output = serde_json::to_string_pretty(&merged)?;
261 write_if_changed(path, &output)?;
262 Ok(())
263}
264
265fn merge_hooks(root: &mut serde_json::Map<String, Value>, mati_hooks: &Value) {
266 let Some(mati_events) = mati_hooks.as_object() else {
267 root.insert("hooks".to_string(), mati_hooks.clone());
268 return;
269 };
270
271 let hooks_value = root
272 .entry("hooks".to_string())
273 .or_insert_with(|| Value::Object(serde_json::Map::new()));
274
275 let Value::Object(existing_events) = hooks_value else {
276 *hooks_value = mati_hooks.clone();
277 return;
278 };
279
280 for (event_name, mati_entries_value) in mati_events {
281 let Some(mati_entries) = mati_entries_value.as_array() else {
282 existing_events.insert(event_name.clone(), mati_entries_value.clone());
283 continue;
284 };
285
286 let owned_commands = mati_hook_commands(mati_entries);
287 let existing_entries = existing_events
288 .entry(event_name.clone())
289 .or_insert_with(|| Value::Array(Vec::new()));
290
291 let Value::Array(existing_entries) = existing_entries else {
292 *existing_entries = Value::Array(mati_entries.clone());
293 continue;
294 };
295
296 existing_entries.retain(|entry| !entry_contains_owned_command(entry, &owned_commands));
297 existing_entries.extend(mati_entries.clone());
298 }
299}
300
301fn mati_hook_commands(entries: &[Value]) -> Vec<String> {
302 entries.iter().flat_map(entry_hook_commands).collect()
303}
304
305fn entry_hook_commands(entry: &Value) -> Vec<String> {
306 entry
307 .get("hooks")
308 .and_then(Value::as_array)
309 .into_iter()
310 .flatten()
311 .filter_map(|hook| hook.get("command").and_then(Value::as_str))
312 .map(ToOwned::to_owned)
313 .collect()
314}
315
316fn entry_contains_owned_command(entry: &Value, owned_commands: &[String]) -> bool {
317 entry_hook_commands(entry)
318 .iter()
319 .any(|command| owned_commands.iter().any(|owned| owned == command))
320}
321
322fn write_mcp_json(path: &Path, _project_root: &Path) -> Result<()> {
332 let mati_server = serde_json::json!({
333 "command": "mati",
334 "args": ["serve"]
335 });
336
337 let mut mcp_config = if path.exists() {
338 let existing_str = std::fs::read_to_string(path)?;
339 serde_json::from_str(&existing_str)
340 .unwrap_or_else(|_| Value::Object(serde_json::Map::new()))
341 } else {
342 Value::Object(serde_json::Map::new())
343 };
344
345 if let Value::Object(ref mut map) = mcp_config {
346 if let Some(Value::Object(ref mut servers)) = map.get_mut("mcpServers") {
347 servers.insert("mati".to_string(), mati_server);
348 } else {
349 map.insert(
350 "mcpServers".to_string(),
351 serde_json::json!({ "mati": mati_server }),
352 );
353 }
354 } else {
355 mcp_config = serde_json::json!({
356 "mcpServers": {
357 "mati": mati_server
358 }
359 });
360 }
361
362 let output = serde_json::to_string_pretty(&mcp_config)?;
363 write_if_changed(path, &output)?;
364 Ok(())
365}
366
367fn command_available(cmd: &str) -> bool {
368 std::process::Command::new(cmd)
369 .arg("--version")
370 .stdout(std::process::Stdio::null())
371 .stderr(std::process::Stdio::null())
372 .status()
373 .map(|s| s.success())
374 .unwrap_or(false)
375}
376
377fn missing_hook_dependencies() -> Vec<&'static str> {
378 missing_hook_dependencies_with(command_available)
379}
380
381fn missing_hook_dependencies_with<F>(mut has_cmd: F) -> Vec<&'static str>
382where
383 F: FnMut(&str) -> bool,
384{
385 let mut missing = Vec::new();
386 if !has_cmd("jq") {
387 missing.push("jq");
388 }
389 if !has_cmd("awk") {
390 missing.push("awk");
391 }
392 missing
393}
394
395use super::{make_executable, write_if_changed};
396
397#[cfg(test)]
400mod tests {
401 use super::*;
402 use tempfile::TempDir;
403
404 #[test]
405 fn skips_when_no_claude_dir() {
406 let dir = TempDir::new().unwrap();
407 let result = install_hooks(dir.path()).unwrap();
408 assert_eq!(result, InstallResult::NoClaude);
409 }
410
411 #[test]
412 fn installs_settings_and_scripts() {
413 let dir = TempDir::new().unwrap();
414 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
415
416 let result = install_hooks(dir.path()).unwrap();
417 match result {
418 InstallResult::Installed { scripts, .. } => assert_eq!(scripts, 11),
419 other => panic!("expected Installed, got {other:?}"),
420 }
421
422 let settings = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
424 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
425 assert!(parsed["hooks"]["PreToolUse"].is_array());
426 assert!(parsed["hooks"]["PostToolUse"].is_array());
427 assert!(parsed["hooks"]["PreCompact"].is_array());
428 assert!(parsed["hooks"]["PostCompact"].is_array());
429 assert!(parsed["hooks"]["SessionEnd"].is_array());
430 assert!(parsed["hooks"]["SubagentStart"].is_array());
431 assert!(parsed["hooks"]["Stop"].is_array());
432 let cmd = parsed["mcpServers"]["mati"]["command"].as_str().unwrap();
434 assert_eq!(cmd, "mati", "command must be bare 'mati' for portability");
435 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
436 }
437
438 #[test]
439 fn merges_into_existing_settings_without_clobbering() {
440 let dir = TempDir::new().unwrap();
441 let claude_dir = dir.path().join(".claude");
442 std::fs::create_dir_all(&claude_dir).unwrap();
443
444 let existing = r#"{"permissions": {"allow": ["npm test"]}, "env": {"DEBUG": "true"}}"#;
446 std::fs::write(claude_dir.join("settings.json"), existing).unwrap();
447
448 install_hooks(dir.path()).unwrap();
449
450 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
451 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
452
453 assert_eq!(parsed["permissions"]["allow"][0], "npm test");
455 assert_eq!(parsed["env"]["DEBUG"], "true");
456 assert!(parsed["hooks"]["PreToolUse"].is_array());
458 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
460 }
461
462 #[test]
463 fn merges_mcp_servers_without_clobbering_existing_servers() {
464 let dir = TempDir::new().unwrap();
465 let claude_dir = dir.path().join(".claude");
466 std::fs::create_dir_all(&claude_dir).unwrap();
467
468 let existing = r#"{"mcpServers": {"other-tool": {"command": "other", "args": ["run"]}}}"#;
470 std::fs::write(claude_dir.join("settings.json"), existing).unwrap();
471
472 install_hooks(dir.path()).unwrap();
473
474 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
475 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
476
477 assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
479 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
481 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
482 }
483
484 #[test]
485 fn merges_hooks_without_clobbering_unrelated_existing_hooks() {
486 let dir = TempDir::new().unwrap();
487 let claude_dir = dir.path().join(".claude");
488 std::fs::create_dir_all(&claude_dir).unwrap();
489
490 let existing = serde_json::json!({
491 "hooks": {
492 "PreToolUse": [
493 {
494 "matcher": "Write",
495 "hooks": [
496 {
497 "type": "command",
498 "command": ".claude/hooks/custom-pre-write.sh"
499 }
500 ]
501 }
502 ]
503 }
504 });
505 std::fs::write(
506 claude_dir.join("settings.json"),
507 serde_json::to_string_pretty(&existing).unwrap(),
508 )
509 .unwrap();
510
511 install_hooks(dir.path()).unwrap();
512
513 let settings = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap();
514 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
515 let pre_tool_use = parsed["hooks"]["PreToolUse"].as_array().unwrap();
516
517 assert!(
518 pre_tool_use.iter().any(|entry| {
519 entry["hooks"]
520 .as_array()
521 .into_iter()
522 .flatten()
523 .any(|hook| hook["command"] == ".claude/hooks/custom-pre-write.sh")
524 }),
525 "custom existing hook should be preserved"
526 );
527 assert!(
528 pre_tool_use.iter().any(|entry| {
529 entry["hooks"]
530 .as_array()
531 .into_iter()
532 .flatten()
533 .any(|hook| hook["command"] == ".claude/hooks/pre-read.sh")
534 }),
535 "mati pre-read hook should be present"
536 );
537 }
538
539 #[test]
540 fn all_hook_scripts_exist_and_are_executable() {
541 let dir = TempDir::new().unwrap();
542 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
543
544 install_hooks(dir.path()).unwrap();
545
546 let hooks_dir = dir.path().join(".claude/hooks");
547 for (name, _) in HOOK_SCRIPTS {
548 let path = hooks_dir.join(name);
549 assert!(path.exists(), "missing hook script: {name}");
550
551 #[cfg(unix)]
552 {
553 use std::os::unix::fs::PermissionsExt;
554 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
555 assert_eq!(mode & 0o111, 0o111, "{name} should be executable");
556 }
557 }
558 }
559
560 #[test]
561 fn pre_hooks_delegate_to_hook_decide() {
562 assert!(crate::hooks::pre_read::SCRIPT.contains("exec mati hook-decide claude-pre-read"));
565 assert!(crate::hooks::pre_edit::SCRIPT.contains("exec mati hook-decide claude-pre-edit"));
566 assert!(crate::hooks::pre_bash::SCRIPT.contains("exec mati hook-decide claude-pre-bash"));
567 }
568
569 #[test]
570 fn writes_mcp_json_to_project_root() {
571 let dir = TempDir::new().unwrap();
572 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
573
574 install_hooks(dir.path()).unwrap();
575
576 let mcp_json_path = dir.path().join(".mcp.json");
577 assert!(
578 mcp_json_path.exists(),
579 ".mcp.json should be written to project root"
580 );
581
582 let content = std::fs::read_to_string(&mcp_json_path).unwrap();
583 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
584 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
585 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
586 assert!(
588 parsed["mcpServers"]["mati"]["args"]
589 .as_array()
590 .unwrap()
591 .len()
592 == 1,
593 "args must only contain 'serve', no --path"
594 );
595 }
596
597 #[test]
598 fn write_mcp_json_preserves_existing_servers() {
599 let dir = TempDir::new().unwrap();
600 let path = dir.path().join(".mcp.json");
601 let existing = serde_json::json!({
602 "mcpServers": {
603 "other-tool": {
604 "command": "other",
605 "args": ["run"]
606 }
607 }
608 });
609 std::fs::write(&path, serde_json::to_string_pretty(&existing).unwrap()).unwrap();
610
611 write_mcp_json(&path, dir.path()).unwrap();
612
613 let content = std::fs::read_to_string(&path).unwrap();
614 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
615 assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
616 assert_eq!(parsed["mcpServers"]["mati"]["command"], "mati");
617 assert_eq!(parsed["mcpServers"]["mati"]["args"][0], "serve");
618 }
619
620 #[test]
621 fn detects_all_hook_runtime_dependencies() {
622 let missing = missing_hook_dependencies_with(|cmd| cmd == "jq");
623 assert_eq!(missing, vec!["awk"]);
624
625 let missing = missing_hook_dependencies_with(|_| false);
626 assert_eq!(missing, vec!["jq", "awk"]);
627 }
628
629 #[test]
630 fn idempotent_on_rerun() {
631 let dir = TempDir::new().unwrap();
632 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
633
634 install_hooks(dir.path()).unwrap();
635 let first_content =
636 std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
637 let first_mcp = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
638
639 install_hooks(dir.path()).unwrap();
640 let second_content =
641 std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
642 let second_mcp = std::fs::read_to_string(dir.path().join(".mcp.json")).unwrap();
643
644 assert_eq!(first_content, second_content);
645 assert_eq!(first_mcp, second_mcp);
646 }
647
648 #[test]
649 fn claude_wrapper_exists_and_matches_mcp_config() {
650 let dir = TempDir::new().unwrap();
651 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
652 install_hooks(dir.path()).unwrap();
653
654 let wrapper_path = dir.path().join(".claude/hooks/mati");
656 assert!(
657 wrapper_path.exists(),
658 ".claude/hooks/mati wrapper must exist"
659 );
660
661 let wrapper = std::fs::read_to_string(&wrapper_path).unwrap();
662 assert!(wrapper.contains("exec"), "wrapper must use exec");
663
664 let exec_line = wrapper.lines().find(|l| l.contains("exec")).unwrap();
666 let exec_target = exec_line
667 .strip_prefix("exec \"")
668 .and_then(|s| s.strip_suffix("\" \"$@\""))
669 .expect("exec line must follow format: exec \"<path>\" \"$@\"");
670 assert!(
671 exec_target.starts_with('/'),
672 "wrapper must use absolute path, got: {exec_target}"
673 );
674
675 let settings = std::fs::read_to_string(dir.path().join(".claude/settings.json")).unwrap();
677 let parsed: serde_json::Value = serde_json::from_str(&settings).unwrap();
678 assert_eq!(
679 parsed["mcpServers"]["mati"]["command"], "mati",
680 "MCP config must use bare 'mati' for portability"
681 );
682 }
683
684 #[test]
685 fn claude_hook_scripts_prepend_hooks_dir_to_path() {
686 let dir = TempDir::new().unwrap();
687 std::fs::create_dir_all(dir.path().join(".claude")).unwrap();
688 install_hooks(dir.path()).unwrap();
689
690 for (name, _) in HOOK_SCRIPTS {
691 let path = dir.path().join(".claude/hooks").join(name);
692 let content = std::fs::read_to_string(&path)
693 .unwrap_or_else(|_| panic!("hook script {name} must exist"));
694 assert!(
695 content.contains("HOOKS_DIR=") && content.contains("export PATH="),
696 "hook script {name} must prepend HOOKS_DIR to PATH"
697 );
698 }
699 }
700}