Skip to main content

mati_core/scaffold/
settings.rs

1//! Install hooks into `.claude/` (M-06-J).
2//!
3//! Writes `.claude/settings.json` with hook registration and creates
4//! the real hook scripts in `.claude/hooks/`.
5//!
6//! Only writes if `.claude/` already exists — if the user isn't using Claude
7//! Code, hooks are skipped.
8
9use std::path::Path;
10
11use anyhow::{Context, Result};
12use serde_json::Value;
13
14/// Hook and MCP server registration for `.claude/settings.json`.
15///
16/// Contains two top-level keys:
17/// - `hooks` — PreToolUse / PostToolUse / PreCompact / PostCompact / SessionEnd / SubagentStart / Stop (ARCHITECTURE.md §10)
18/// - `mcpServers` — registers `mati serve` as an MCP stdio server (M-07-I)
19const 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
143/// All hook scripts to install, with their content.
144///
145/// Each script is a Rust string constant defined in `crate::hooks::*`.
146/// Replaces the pass-through stubs from M-06-J with real hook logic (M-09).
147pub 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/// Outcome of the hook installation.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub enum InstallResult {
167    /// Hooks and settings.json written successfully.
168    Installed {
169        /// Number of hook scripts written.
170        scripts: usize,
171        /// Missing runtime dependencies required by the installed hooks.
172        missing_deps: Vec<&'static str>,
173    },
174    /// `.claude/` directory doesn't exist — user isn't using Claude Code.
175    NoClaude,
176}
177
178/// Install hook registration and hook scripts into `.claude/`.
179///
180/// - Merges mati's `hooks` key into existing `.claude/settings.json`,
181///   preserving any user-defined settings (permissions, env vars, etc.).
182/// - Writes `.mcp.json` to the project root for MCP server registration.
183///   Claude Code reads `mcpServers` from `.mcp.json` at the project root;
184///   the `mcpServers` key in `.claude/settings.json` is kept as a fallback.
185/// - Creates `.claude/hooks/` and writes the real hook scripts.
186/// - Existing scripts are overwritten (mati owns these files).
187/// - Only proceeds if `.claude/` already exists.
188pub 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    // Merge hooks into settings.json, preserving existing user settings.
195    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    // Write .mcp.json to project root — Claude Code's primary MCP config location.
200    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    // Create hooks directory and write scripts.
205    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    // Write mati binary wrapper so hooks resolve the same binary as MCP.
217    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
227/// Merge mati's hook and MCP server registration into an existing settings.json.
228///
229/// If the file doesn't exist, writes the full settings. If it exists,
230/// parses it, replaces only the `hooks` and `mcpServers` keys, and writes
231/// back — preserving all other user settings.
232fn merge_hooks_into_settings(path: &Path) -> Result<()> {
233    let mut mati_settings: Value = serde_json::from_str(SETTINGS_JSON)?;
234    // Use bare command name — portable across machines.
235    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            // Merge mcpServers: add "mati" entry without clobbering other servers.
245            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
322/// Write `.mcp.json` to the project root with the mati MCP server registration.
323///
324/// Claude Code reads MCP server configuration from `.mcp.json` at the project
325/// root — this is the primary mechanism; `mcpServers` in `.claude/settings.json`
326/// is kept as a fallback for older Claude Code versions.
327///
328/// Uses the bare `mati` command (PATH-resolved) so the config is portable
329/// across machines. Claude Code sets cwd to the project root when spawning
330/// MCP servers, so `mati serve` detects the project automatically.
331fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
398
399#[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        // settings.json exists and is valid JSON.
423        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        // MCP server registered with portable bare command.
433        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        // Pre-existing settings with user config.
445        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        // User settings preserved.
454        assert_eq!(parsed["permissions"]["allow"][0], "npm test");
455        assert_eq!(parsed["env"]["DEBUG"], "true");
456        // Hooks added.
457        assert!(parsed["hooks"]["PreToolUse"].is_array());
458        // MCP server added with portable bare command.
459        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        // Pre-existing settings with another MCP server.
469        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        // Existing server preserved.
478        assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
479        // mati server added alongside with portable bare command.
480        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        // Enforcement logic is now in Rust (hooks::decide + cli::hook_decide).
563        // Shell wrappers just exec the correct hook-decide variant.
564        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        // No --path arg — mati serve detects project from cwd.
587        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        // Wrapper must exist
655        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        // Wrapper uses absolute path (hooks run in restricted shell without ~/.cargo/bin on PATH).
665        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        // MCP config uses portable bare command (resolved via PATH by Claude Code).
676        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}