Skip to main content

mars_agents/target/
claude.rs

1/// `.claude` target adapter.
2///
3/// Handles MCP server registration in `.mcp.json` and hook binding in
4/// `settings.local.json` within the `.claude/` target directory.
5///
6/// Claude-native lowering:
7/// - MCP: writes to `.mcp.json` (mcpServers section)
8/// - Hooks: writes to `settings.local.json` (hooks section). Hook commands
9///   carry machine-local cache paths, so they belong in the gitignored
10///   `settings.local.json` rather than the committed `settings.json`.
11/// - Env references: rendered as `${VAR_NAME}` for Claude Desktop config compat
12use std::path::{Path, PathBuf};
13
14use crate::error::{ConfigError, MarsError};
15use crate::lock::ItemKind;
16use crate::types::DestPath;
17
18use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
19
20#[derive(Debug)]
21pub struct ClaudeAdapter;
22
23impl TargetAdapter for ClaudeAdapter {
24    fn name(&self) -> &str {
25        ".claude"
26    }
27
28    fn skill_variant_key(&self) -> Option<&str> {
29        Some("claude")
30    }
31
32    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
33        match kind {
34            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
35            // Agent, Hook, McpServer, BootstrapDoc routing is deferred.
36            _ => None,
37        }
38    }
39
40    fn write_config_entries(
41        &self,
42        entries: &[ConfigEntry],
43        target_dir: &Path,
44    ) -> Result<Vec<PathBuf>, MarsError> {
45        let mut written = Vec::new();
46
47        let mcp_servers: Vec<&McpServerEntry> = entries
48            .iter()
49            .filter_map(|e| {
50                if let ConfigEntry::McpServer(s) = e {
51                    Some(s)
52                } else {
53                    None
54                }
55            })
56            .collect();
57
58        let hooks: Vec<&HookEntry> = entries
59            .iter()
60            .filter_map(|e| {
61                if let ConfigEntry::Hook(h) = e {
62                    Some(h)
63                } else {
64                    None
65                }
66            })
67            .collect();
68
69        if !mcp_servers.is_empty() {
70            let path = write_mcp_json(target_dir, &mcp_servers)?;
71            written.push(path);
72        }
73
74        if !hooks.is_empty() {
75            let path = write_hooks_settings(target_dir, &hooks)?;
76            written.push(path);
77        }
78
79        Ok(written)
80    }
81
82    fn remove_config_entries(
83        &self,
84        entry_keys: &[String],
85        target_dir: &Path,
86    ) -> Result<(), MarsError> {
87        remove_mcp_entries_by_key(entry_keys, target_dir)?;
88        remove_hook_entries_by_key(entry_keys, target_dir)?;
89        Ok(())
90    }
91}
92
93// ---------------------------------------------------------------------------
94// MCP JSON — `.mcp.json` format
95// ---------------------------------------------------------------------------
96
97/// Write (or merge) MCP servers into `<target_dir>/.mcp.json`.
98///
99/// The file format is:
100/// ```json
101/// {
102///   "mcpServers": {
103///     "server-name": {
104///       "command": "npx",
105///       "args": [...],
106///       "env": { "KEY": "${ENV_VAR}" }
107///     }
108///   }
109/// }
110/// ```
111///
112/// Existing entries with other names are preserved (merge, not replace).
113fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
114    let path = target_dir.join(".mcp.json");
115
116    // Load existing config or start fresh.
117    let mut root: serde_json::Value = if path.is_file() {
118        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
119        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
120    } else {
121        serde_json::json!({})
122    };
123
124    // Ensure mcpServers key exists.
125    let mcp_obj = root
126        .as_object_mut()
127        .ok_or_else(|| {
128            MarsError::Config(crate::error::ConfigError::Invalid {
129                message: format!("{} is not a JSON object", path.display()),
130            })
131        })?
132        .entry("mcpServers")
133        .or_insert_with(|| serde_json::json!({}));
134
135    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
136        MarsError::Config(crate::error::ConfigError::Invalid {
137            message: format!("{}: mcpServers is not an object", path.display()),
138        })
139    })?;
140
141    for server in servers {
142        let mut entry = serde_json::json!({
143            "command": server.command,
144            "args": server.args,
145        });
146
147        if !server.env.is_empty() {
148            let env_obj: serde_json::Map<String, serde_json::Value> = server
149                .env
150                .iter()
151                .map(|(k, v)| (k.clone(), serde_json::Value::String(format!("${{{v}}}"))))
152                .collect();
153            entry["env"] = serde_json::Value::Object(env_obj);
154        }
155
156        mcp_map.insert(server.name.clone(), entry);
157    }
158
159    let content = serde_json::to_string_pretty(&root).map_err(|e| {
160        MarsError::Config(crate::error::ConfigError::Invalid {
161            message: format!("failed to serialize {}: {e}", path.display()),
162        })
163    })?;
164    crate::fs::atomic_write(&path, content.as_bytes())?;
165
166    Ok(path)
167}
168
169/// Remove MCP server entries by key from `.mcp.json`.
170fn remove_mcp_entries_by_key(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
171    let path = target_dir.join(".mcp.json");
172    if !path.is_file() {
173        return Ok(());
174    }
175
176    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
177    let mut root: serde_json::Value =
178        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
179
180    if let Some(mcp_map) = root
181        .as_object_mut()
182        .and_then(|o| o.get_mut("mcpServers"))
183        .and_then(|v| v.as_object_mut())
184    {
185        for key in entry_keys {
186            // Keys are "mcp:<name>" — strip the prefix.
187            if let Some(name) = key.strip_prefix("mcp:") {
188                mcp_map.remove(name);
189            }
190        }
191    }
192
193    let content = serde_json::to_string_pretty(&root).map_err(|e| {
194        MarsError::Config(crate::error::ConfigError::Invalid {
195            message: format!("failed to serialize {}: {e}", path.display()),
196        })
197    })?;
198    crate::fs::atomic_write(&path, content.as_bytes())?;
199
200    Ok(())
201}
202
203// ---------------------------------------------------------------------------
204// Hooks — `settings.local.json` format
205// ---------------------------------------------------------------------------
206
207/// Write (or merge) hook bindings into `<target_dir>/settings.local.json`.
208///
209/// Hooks go to `settings.local.json` (gitignored) rather than `settings.json`
210/// because hook commands embed machine-local cache paths that change on every
211/// sync and every machine.
212///
213/// Claude hooks live in the `hooks` section:
214/// ```json
215/// {
216///   "hooks": {
217///     "PreToolUse": [
218///       { "hooks": [{ "type": "command", "command": "bash /path/to/script.sh" }] }
219///     ]
220///   }
221/// }
222/// ```
223fn write_hooks_settings(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
224    let path = target_dir.join("settings.local.json");
225
226    let mut root: serde_json::Value = if path.is_file() {
227        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
228        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
229    } else {
230        serde_json::json!({})
231    };
232
233    let hooks_section = root
234        .as_object_mut()
235        .ok_or_else(|| {
236            MarsError::Config(crate::error::ConfigError::Invalid {
237                message: format!("{} is not a JSON object", path.display()),
238            })
239        })?
240        .entry("hooks")
241        .or_insert_with(|| serde_json::json!({}));
242
243    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
244        MarsError::Config(crate::error::ConfigError::Invalid {
245            message: format!("{}: hooks is not an object", path.display()),
246        })
247    })?;
248
249    for hook in hooks {
250        let native_event = &hook.native_event;
251        let command_entry = serde_json::json!({
252            "type": "command",
253            "command": hook_command(&hook.script_path),
254        });
255        let hook_binding = serde_json::json!({
256            "matcher": "",
257            "hooks": [command_entry],
258        });
259
260        let event_hooks = hooks_map
261            .entry(native_event.clone())
262            .or_insert_with(|| serde_json::json!([]))
263            .as_array_mut()
264            .ok_or_else(|| {
265                MarsError::Config(ConfigError::Invalid {
266                    message: format!("{}: hooks.{native_event} is not an array", path.display()),
267                })
268            })?;
269        remove_managed_hook_bindings(event_hooks, &hook.name);
270        event_hooks.push(hook_binding);
271    }
272
273    let content = serde_json::to_string_pretty(&root).map_err(|e| {
274        MarsError::Config(crate::error::ConfigError::Invalid {
275            message: format!("failed to serialize {}: {e}", path.display()),
276        })
277    })?;
278    crate::fs::atomic_write(&path, content.as_bytes())?;
279
280    // Migrate any stale managed hooks out of the committed settings.json. Users
281    // who synced before hooks moved to settings.local.json have leftover entries
282    // there with machine-local paths; clean them up so they don't persist.
283    let hook_names: Vec<&str> = hooks.iter().map(|h| h.name.as_str()).collect();
284    migrate_hooks_from_settings_json(target_dir, &hook_names)?;
285
286    Ok(path)
287}
288
289/// Remove mars-managed hook bindings from the committed `settings.json`.
290///
291/// Hooks now live in `settings.local.json`; this strips any leftover managed
292/// bindings (matched by `/hooks/<name>/` in the command path) from
293/// `settings.json` so stale machine-local paths don't persist in the committed
294/// file. Writes back only when something changed.
295fn migrate_hooks_from_settings_json(
296    target_dir: &Path,
297    hook_names: &[&str],
298) -> Result<(), MarsError> {
299    let path = target_dir.join("settings.json");
300    if !path.is_file() {
301        return Ok(());
302    }
303
304    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
305    let mut root: serde_json::Value =
306        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
307
308    let mut changed = false;
309
310    if let Some(obj) = root.as_object_mut()
311        && let Some(hooks_value) = obj.get_mut("hooks")
312        && let Some(hooks_map) = hooks_value.as_object_mut()
313    {
314        for event_hooks in hooks_map.values_mut() {
315            if let Some(arr) = event_hooks.as_array_mut() {
316                let before = arr.len();
317                for name in hook_names {
318                    remove_managed_hook_bindings(arr, name);
319                }
320                if arr.len() != before {
321                    changed = true;
322                }
323            }
324        }
325
326        // Drop empty event arrays, then the hooks section if nothing remains.
327        hooks_map.retain(|_, v| !v.as_array().map(|a| a.is_empty()).unwrap_or(false));
328        if hooks_map.is_empty() {
329            obj.remove("hooks");
330            changed = true;
331        }
332    }
333
334    if changed {
335        let content = serde_json::to_string_pretty(&root).map_err(|e| {
336            MarsError::Config(crate::error::ConfigError::Invalid {
337                message: format!("failed to serialize {}: {e}", path.display()),
338            })
339        })?;
340        crate::fs::atomic_write(&path, content.as_bytes())?;
341    }
342
343    Ok(())
344}
345
346fn remove_managed_hook_bindings(bindings: &mut Vec<serde_json::Value>, hook_name: &str) {
347    bindings.retain(|binding| {
348        let Some(inner_hooks) = binding.get("hooks").and_then(|h| h.as_array()) else {
349            return true;
350        };
351        !inner_hooks.iter().any(|h| {
352            h.get("command")
353                .and_then(|c| c.as_str())
354                .map(|cmd| is_managed_hook_command_for(cmd, hook_name))
355                .unwrap_or(false)
356        })
357    });
358}
359
360fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
361    let normalized = command.replace('\\', "/").replace("//", "/");
362    normalized.contains(&format!("/hooks/{hook_name}/"))
363}
364
365/// Remove hook entries by key from `settings.local.json`.
366///
367/// Keys are "hook:<event>:<name>" — we use the native event name to locate
368/// the section. Because hooks are additive and the settings file may contain
369/// user-owned entries, we only remove entries we wrote (matched by command path).
370///
371/// We also apply the same removal to the committed `settings.json` so any stale
372/// managed bindings left there by an older sync get cleaned up.
373fn remove_hook_entries_by_key(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
374    let hook_keys: Vec<(String, &str)> = entry_keys
375        .iter()
376        .filter_map(|k| {
377            let rest = k.strip_prefix("hook:")?;
378            let (event, name) = rest.split_once(':')?;
379            Some((claude_hook_event(event)?.to_string(), name))
380        })
381        .collect();
382
383    if hook_keys.is_empty() {
384        return Ok(());
385    }
386
387    remove_hook_keys_from_file(&target_dir.join("settings.local.json"), &hook_keys)?;
388    remove_hook_keys_from_file(&target_dir.join("settings.json"), &hook_keys)?;
389
390    Ok(())
391}
392
393/// Remove the given (event, name) managed hook bindings from a single settings
394/// file, if it exists. Conservative — only removes entries whose command path
395/// matches a mars-managed hook (`/hooks/<name>/`).
396fn remove_hook_keys_from_file(path: &Path, hook_keys: &[(String, &str)]) -> Result<(), MarsError> {
397    if !path.is_file() {
398        return Ok(());
399    }
400
401    let raw = std::fs::read_to_string(path).map_err(MarsError::from)?;
402    let mut root: serde_json::Value =
403        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
404
405    if let Some(hooks_map) = root
406        .as_object_mut()
407        .and_then(|o| o.get_mut("hooks"))
408        .and_then(|v| v.as_object_mut())
409    {
410        for (event, name) in hook_keys {
411            if let Some(event_hooks) = hooks_map.get_mut(event)
412                && let Some(arr) = event_hooks.as_array_mut()
413            {
414                remove_managed_hook_bindings(arr, name);
415            }
416        }
417    }
418
419    let content = serde_json::to_string_pretty(&root).map_err(|e| {
420        MarsError::Config(crate::error::ConfigError::Invalid {
421            message: format!("failed to serialize {}: {e}", path.display()),
422        })
423    })?;
424    crate::fs::atomic_write(path, content.as_bytes())?;
425
426    Ok(())
427}
428
429fn claude_hook_event(event: &str) -> Option<&'static str> {
430    match event {
431        "session.start" => Some("SessionStart"),
432        "session.end" => Some("SessionStop"),
433        "tool.pre" => Some("PreToolUse"),
434        "tool.post" => Some("PostToolUse"),
435        _ => None,
436    }
437}
438
439// ---------------------------------------------------------------------------
440// Tests
441// ---------------------------------------------------------------------------
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use indexmap::IndexMap;
447    use tempfile::TempDir;
448
449    fn make_mcp_entry(name: &str) -> ConfigEntry {
450        ConfigEntry::McpServer(McpServerEntry {
451            name: name.to_string(),
452            command: "npx".to_string(),
453            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
454            env: IndexMap::new(),
455        })
456    }
457
458    fn make_mcp_entry_with_env(name: &str, env_key: &str, env_var: &str) -> ConfigEntry {
459        let mut env = IndexMap::new();
460        env.insert(env_key.to_string(), env_var.to_string());
461        ConfigEntry::McpServer(McpServerEntry {
462            name: name.to_string(),
463            command: "npx".to_string(),
464            args: vec![],
465            env,
466        })
467    }
468
469    fn make_hook_entry(name: &str, event: &str, native: &str) -> ConfigEntry {
470        ConfigEntry::Hook(HookEntry {
471            name: name.to_string(),
472            event: event.to_string(),
473            native_event: native.to_string(),
474            script_path: format!("/hooks/{name}/run.sh"),
475            order: 0,
476        })
477    }
478
479    fn make_hook_entry_with_path(
480        name: &str,
481        event: &str,
482        native: &str,
483        script_path: &str,
484    ) -> ConfigEntry {
485        ConfigEntry::Hook(HookEntry {
486            name: name.to_string(),
487            event: event.to_string(),
488            native_event: native.to_string(),
489            script_path: script_path.to_string(),
490            order: 0,
491        })
492    }
493
494    #[test]
495    fn write_mcp_creates_mcp_json() {
496        let tmp = TempDir::new().unwrap();
497        std::fs::create_dir_all(tmp.path()).unwrap();
498
499        let adapter = ClaudeAdapter;
500        let entries = vec![make_mcp_entry("context7")];
501        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
502
503        assert_eq!(written.len(), 1);
504        assert!(tmp.path().join(".mcp.json").exists());
505
506        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
507        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
508        assert!(json["mcpServers"]["context7"].is_object());
509        assert_eq!(json["mcpServers"]["context7"]["command"], "npx");
510    }
511
512    #[test]
513    fn write_mcp_merges_with_existing() {
514        let tmp = TempDir::new().unwrap();
515        let existing = serde_json::json!({
516            "mcpServers": { "existing-server": { "command": "old" } }
517        });
518        std::fs::write(
519            tmp.path().join(".mcp.json"),
520            serde_json::to_string_pretty(&existing).unwrap(),
521        )
522        .unwrap();
523
524        let adapter = ClaudeAdapter;
525        let entries = vec![make_mcp_entry("new-server")];
526        adapter.write_config_entries(&entries, tmp.path()).unwrap();
527
528        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
529        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
530        assert!(json["mcpServers"]["existing-server"].is_object());
531        assert!(json["mcpServers"]["new-server"].is_object());
532    }
533
534    #[test]
535    fn write_mcp_env_renders_as_interpolation() {
536        let tmp = TempDir::new().unwrap();
537        let adapter = ClaudeAdapter;
538        let entries = vec![make_mcp_entry_with_env("server", "API_KEY", "MY_SECRET")];
539        adapter.write_config_entries(&entries, tmp.path()).unwrap();
540
541        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
542        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
543        assert_eq!(
544            json["mcpServers"]["server"]["env"]["API_KEY"],
545            "${MY_SECRET}"
546        );
547    }
548
549    #[test]
550    fn write_hooks_creates_settings_local_json() {
551        let tmp = TempDir::new().unwrap();
552        let adapter = ClaudeAdapter;
553        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
554        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
555
556        assert_eq!(written.len(), 1);
557        assert!(tmp.path().join("settings.local.json").exists());
558        assert!(!tmp.path().join("settings.json").exists());
559
560        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
561        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
562        assert!(json["hooks"]["PreToolUse"].is_array());
563        assert!(!json["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
564    }
565
566    #[test]
567    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
568        let tmp = TempDir::new().unwrap();
569        let adapter = ClaudeAdapter;
570        adapter
571            .write_config_entries(
572                &[make_hook_entry_with_path(
573                    "audit",
574                    "tool.pre",
575                    "PreToolUse",
576                    "/old/hooks/audit/run.sh",
577                )],
578                tmp.path(),
579            )
580            .unwrap();
581        adapter
582            .write_config_entries(
583                &[make_hook_entry_with_path(
584                    "audit",
585                    "tool.pre",
586                    "PreToolUse",
587                    "/new/hooks/audit/run.sh",
588                )],
589                tmp.path(),
590            )
591            .unwrap();
592
593        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
594        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
595        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
596        assert_eq!(hooks.len(), 1);
597        let command = hooks[0]["hooks"][0]["command"].as_str().unwrap();
598        assert!(command.contains("/new/hooks/audit/"));
599    }
600
601    #[test]
602    fn remove_mcp_entries_removes_by_name() {
603        let tmp = TempDir::new().unwrap();
604        let adapter = ClaudeAdapter;
605        let entries = vec![make_mcp_entry("context7"), make_mcp_entry("other")];
606        adapter.write_config_entries(&entries, tmp.path()).unwrap();
607
608        adapter
609            .remove_config_entries(&["mcp:context7".to_string()], tmp.path())
610            .unwrap();
611
612        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
613        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
614        assert!(json["mcpServers"]["context7"].is_null());
615        assert!(json["mcpServers"]["other"].is_object());
616    }
617
618    #[test]
619    fn write_mcp_and_hooks_both_written() {
620        let tmp = TempDir::new().unwrap();
621        let adapter = ClaudeAdapter;
622        let entries = vec![
623            make_mcp_entry("context7"),
624            make_hook_entry("audit", "tool.pre", "PreToolUse"),
625        ];
626        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
627        assert_eq!(written.len(), 2);
628        assert!(tmp.path().join(".mcp.json").exists());
629        assert!(tmp.path().join("settings.local.json").exists());
630        assert!(!tmp.path().join("settings.json").exists());
631    }
632
633    #[test]
634    fn remove_hook_entries_matches_backslash_commands() {
635        let tmp = TempDir::new().unwrap();
636        let existing = serde_json::json!({
637            "hooks": {
638                "PreToolUse": [
639                    {
640                        "matcher": "",
641                        "hooks": [
642                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
643                        ]
644                    },
645                    {
646                        "matcher": "",
647                        "hooks": [
648                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
649                        ]
650                    }
651                ]
652            }
653        });
654        std::fs::write(
655            tmp.path().join("settings.local.json"),
656            serde_json::to_string_pretty(&existing).unwrap(),
657        )
658        .unwrap();
659
660        remove_hook_entries_by_key(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
661
662        let raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
663        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
664        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
665        assert_eq!(hooks.len(), 1);
666    }
667
668    #[test]
669    fn write_hooks_migrates_stale_hooks_out_of_settings_json() {
670        let tmp = TempDir::new().unwrap();
671
672        // Simulate an older sync that wrote a managed hook into the committed
673        // settings.json, alongside a user-owned hook that must be preserved.
674        let stale = serde_json::json!({
675            "hooks": {
676                "PreToolUse": [
677                    {
678                        "matcher": "",
679                        "hooks": [
680                            { "type": "command", "command": "bash /old/cache/hooks/audit/run.sh" }
681                        ]
682                    },
683                    {
684                        "matcher": "",
685                        "hooks": [
686                            { "type": "command", "command": "echo user-owned" }
687                        ]
688                    }
689                ]
690            }
691        });
692        std::fs::write(
693            tmp.path().join("settings.json"),
694            serde_json::to_string_pretty(&stale).unwrap(),
695        )
696        .unwrap();
697
698        let adapter = ClaudeAdapter;
699        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
700        adapter.write_config_entries(&entries, tmp.path()).unwrap();
701
702        // New hook lands in settings.local.json.
703        let local_raw = std::fs::read_to_string(tmp.path().join("settings.local.json")).unwrap();
704        let local: serde_json::Value = serde_json::from_str(&local_raw).unwrap();
705        let local_hooks = local["hooks"]["PreToolUse"].as_array().unwrap();
706        assert_eq!(local_hooks.len(), 1);
707        assert!(
708            local_hooks[0]["hooks"][0]["command"]
709                .as_str()
710                .unwrap()
711                .contains("/hooks/audit/")
712        );
713
714        // Stale managed hook is gone from settings.json; user-owned hook stays.
715        let committed_raw = std::fs::read_to_string(tmp.path().join("settings.json")).unwrap();
716        let committed: serde_json::Value = serde_json::from_str(&committed_raw).unwrap();
717        let committed_hooks = committed["hooks"]["PreToolUse"].as_array().unwrap();
718        assert_eq!(committed_hooks.len(), 1);
719        assert_eq!(committed_hooks[0]["hooks"][0]["command"], "echo user-owned");
720    }
721
722    #[test]
723    fn write_hooks_drops_empty_hooks_section_from_settings_json() {
724        let tmp = TempDir::new().unwrap();
725
726        // Only a managed hook in settings.json — after migration the hooks
727        // section should be removed entirely.
728        let stale = serde_json::json!({
729            "hooks": {
730                "PreToolUse": [
731                    {
732                        "matcher": "",
733                        "hooks": [
734                            { "type": "command", "command": "bash /old/cache/hooks/audit/run.sh" }
735                        ]
736                    }
737                ]
738            },
739            "other": "preserved"
740        });
741        std::fs::write(
742            tmp.path().join("settings.json"),
743            serde_json::to_string_pretty(&stale).unwrap(),
744        )
745        .unwrap();
746
747        let adapter = ClaudeAdapter;
748        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
749        adapter.write_config_entries(&entries, tmp.path()).unwrap();
750
751        let committed_raw = std::fs::read_to_string(tmp.path().join("settings.json")).unwrap();
752        let committed: serde_json::Value = serde_json::from_str(&committed_raw).unwrap();
753        assert!(committed.get("hooks").is_none());
754        assert_eq!(committed["other"], "preserved");
755    }
756}