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 / SessionEnd (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    "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
93/// All hook scripts to install, with their content.
94///
95/// Each script is a Rust string constant defined in `crate::hooks::*`.
96/// Replaces the pass-through stubs from M-06-J with real hook logic (M-09).
97pub 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/// Outcome of the hook installation.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub enum InstallResult {
112    /// Hooks and settings.json written successfully.
113    Installed {
114        /// Number of hook scripts written.
115        scripts: usize,
116        /// Missing runtime dependencies required by the installed hooks.
117        missing_deps: Vec<&'static str>,
118    },
119    /// `.claude/` directory doesn't exist — user isn't using Claude Code.
120    NoClaude,
121}
122
123/// Install hook registration and hook scripts into `.claude/`.
124///
125/// - Merges mati's `hooks` key into existing `.claude/settings.json`,
126///   preserving any user-defined settings (permissions, env vars, etc.).
127/// - Writes `.mcp.json` to the project root for MCP server registration.
128///   Claude Code reads `mcpServers` from `.mcp.json` at the project root;
129///   the `mcpServers` key in `.claude/settings.json` is kept as a fallback.
130/// - Creates `.claude/hooks/` and writes the real hook scripts.
131/// - Existing scripts are overwritten (mati owns these files).
132/// - Only proceeds if `.claude/` already exists.
133pub 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    // Merge hooks into settings.json, preserving existing user settings.
140    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    // Write .mcp.json to project root — Claude Code's primary MCP config location.
145    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    // Create hooks directory and write scripts.
150    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    // Write mati binary wrapper so hooks resolve the same binary as MCP.
162    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
172/// Merge mati's hook and MCP server registration into an existing settings.json.
173///
174/// If the file doesn't exist, writes the full settings. If it exists,
175/// parses it, replaces only the `hooks` and `mcpServers` keys, and writes
176/// back — preserving all other user settings.
177fn merge_hooks_into_settings(path: &Path) -> Result<()> {
178    let mut mati_settings: Value = serde_json::from_str(SETTINGS_JSON)?;
179    // Use bare command name — portable across machines.
180    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            // Merge mcpServers: add "mati" entry without clobbering other servers.
190            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
267/// Write `.mcp.json` to the project root with the mati MCP server registration.
268///
269/// Claude Code reads MCP server configuration from `.mcp.json` at the project
270/// root — this is the primary mechanism; `mcpServers` in `.claude/settings.json`
271/// is kept as a fallback for older Claude Code versions.
272///
273/// Uses the bare `mati` command (PATH-resolved) so the config is portable
274/// across machines. Claude Code sets cwd to the project root when spawning
275/// MCP servers, so `mati serve` detects the project automatically.
276fn 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// ── Tests ─────────────────────────────────────────────────────────────────────
343
344#[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        // settings.json exists and is valid JSON.
368        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        // MCP server registered with portable bare command.
375        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        // Pre-existing settings with user config.
387        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        // User settings preserved.
396        assert_eq!(parsed["permissions"]["allow"][0], "npm test");
397        assert_eq!(parsed["env"]["DEBUG"], "true");
398        // Hooks added.
399        assert!(parsed["hooks"]["PreToolUse"].is_array());
400        // MCP server added with portable bare command.
401        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        // Pre-existing settings with another MCP server.
411        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        // Existing server preserved.
420        assert_eq!(parsed["mcpServers"]["other-tool"]["command"], "other");
421        // mati server added alongside with portable bare command.
422        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        // Enforcement logic is now in Rust (hooks::decide + cli::hook_decide).
505        // Shell wrappers just exec the correct hook-decide variant.
506        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        // No --path arg — mati serve detects project from cwd.
528        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        // Wrapper must exist
596        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        // Wrapper uses absolute path (hooks run in restricted shell without ~/.cargo/bin on PATH).
606        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        // MCP config uses portable bare command (resolved via PATH by Claude Code).
617        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}