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.json` within the `.claude/` target directory.
5///
6/// Claude-native lowering:
7/// - MCP: writes to `.mcp.json` (mcpServers section)
8/// - Hooks: writes to `settings.json` (hooks section)
9/// - Env references: rendered as `${VAR_NAME}` for Claude Desktop config compat
10use std::path::{Path, PathBuf};
11
12use crate::error::{ConfigError, MarsError};
13use crate::lock::ItemKind;
14use crate::types::DestPath;
15
16use super::{ConfigEntry, HookEntry, McpServerEntry, TargetAdapter, hook_command};
17
18#[derive(Debug)]
19pub struct ClaudeAdapter;
20
21impl TargetAdapter for ClaudeAdapter {
22    fn name(&self) -> &str {
23        ".claude"
24    }
25
26    fn skill_variant_key(&self) -> Option<&str> {
27        Some("claude")
28    }
29
30    fn default_dest_path(&self, kind: ItemKind, name: &str) -> Option<DestPath> {
31        match kind {
32            ItemKind::Skill => Some(DestPath::from(format!("skills/{name}").as_str())),
33            // Agent, Hook, McpServer, BootstrapDoc routing is deferred.
34            _ => None,
35        }
36    }
37
38    fn write_config_entries(
39        &self,
40        entries: &[ConfigEntry],
41        target_dir: &Path,
42    ) -> Result<Vec<PathBuf>, MarsError> {
43        let mut written = Vec::new();
44
45        let mcp_servers: Vec<&McpServerEntry> = entries
46            .iter()
47            .filter_map(|e| {
48                if let ConfigEntry::McpServer(s) = e {
49                    Some(s)
50                } else {
51                    None
52                }
53            })
54            .collect();
55
56        let hooks: Vec<&HookEntry> = entries
57            .iter()
58            .filter_map(|e| {
59                if let ConfigEntry::Hook(h) = e {
60                    Some(h)
61                } else {
62                    None
63                }
64            })
65            .collect();
66
67        if !mcp_servers.is_empty() {
68            let path = write_mcp_json(target_dir, &mcp_servers)?;
69            written.push(path);
70        }
71
72        if !hooks.is_empty() {
73            let path = write_hooks_settings(target_dir, &hooks)?;
74            written.push(path);
75        }
76
77        Ok(written)
78    }
79
80    fn remove_config_entries(
81        &self,
82        entry_keys: &[String],
83        target_dir: &Path,
84    ) -> Result<(), MarsError> {
85        remove_mcp_entries_by_key(entry_keys, target_dir)?;
86        remove_hook_entries_by_key(entry_keys, target_dir)?;
87        Ok(())
88    }
89}
90
91// ---------------------------------------------------------------------------
92// MCP JSON — `.mcp.json` format
93// ---------------------------------------------------------------------------
94
95/// Write (or merge) MCP servers into `<target_dir>/.mcp.json`.
96///
97/// The file format is:
98/// ```json
99/// {
100///   "mcpServers": {
101///     "server-name": {
102///       "command": "npx",
103///       "args": [...],
104///       "env": { "KEY": "${ENV_VAR}" }
105///     }
106///   }
107/// }
108/// ```
109///
110/// Existing entries with other names are preserved (merge, not replace).
111fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
112    let path = target_dir.join(".mcp.json");
113
114    // Load existing config or start fresh.
115    let mut root: serde_json::Value = if path.is_file() {
116        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
117        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
118    } else {
119        serde_json::json!({})
120    };
121
122    // Ensure mcpServers key exists.
123    let mcp_obj = root
124        .as_object_mut()
125        .ok_or_else(|| {
126            MarsError::Config(crate::error::ConfigError::Invalid {
127                message: format!("{} is not a JSON object", path.display()),
128            })
129        })?
130        .entry("mcpServers")
131        .or_insert_with(|| serde_json::json!({}));
132
133    let mcp_map = mcp_obj.as_object_mut().ok_or_else(|| {
134        MarsError::Config(crate::error::ConfigError::Invalid {
135            message: format!("{}: mcpServers is not an object", path.display()),
136        })
137    })?;
138
139    for server in servers {
140        let mut entry = serde_json::json!({
141            "command": server.command,
142            "args": server.args,
143        });
144
145        if !server.env.is_empty() {
146            let env_obj: serde_json::Map<String, serde_json::Value> = server
147                .env
148                .iter()
149                .map(|(k, v)| (k.clone(), serde_json::Value::String(format!("${{{v}}}"))))
150                .collect();
151            entry["env"] = serde_json::Value::Object(env_obj);
152        }
153
154        mcp_map.insert(server.name.clone(), entry);
155    }
156
157    let content = serde_json::to_string_pretty(&root).map_err(|e| {
158        MarsError::Config(crate::error::ConfigError::Invalid {
159            message: format!("failed to serialize {}: {e}", path.display()),
160        })
161    })?;
162    crate::fs::atomic_write(&path, content.as_bytes())?;
163
164    Ok(path)
165}
166
167/// Remove MCP server entries by key from `.mcp.json`.
168fn remove_mcp_entries_by_key(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
169    let path = target_dir.join(".mcp.json");
170    if !path.is_file() {
171        return Ok(());
172    }
173
174    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
175    let mut root: serde_json::Value =
176        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
177
178    if let Some(mcp_map) = root
179        .as_object_mut()
180        .and_then(|o| o.get_mut("mcpServers"))
181        .and_then(|v| v.as_object_mut())
182    {
183        for key in entry_keys {
184            // Keys are "mcp:<name>" — strip the prefix.
185            if let Some(name) = key.strip_prefix("mcp:") {
186                mcp_map.remove(name);
187            }
188        }
189    }
190
191    let content = serde_json::to_string_pretty(&root).map_err(|e| {
192        MarsError::Config(crate::error::ConfigError::Invalid {
193            message: format!("failed to serialize {}: {e}", path.display()),
194        })
195    })?;
196    crate::fs::atomic_write(&path, content.as_bytes())?;
197
198    Ok(())
199}
200
201// ---------------------------------------------------------------------------
202// Hooks — `settings.json` format
203// ---------------------------------------------------------------------------
204
205/// Write (or merge) hook bindings into `<target_dir>/settings.json`.
206///
207/// Claude hooks live in the `hooks` section:
208/// ```json
209/// {
210///   "hooks": {
211///     "PreToolUse": [
212///       { "hooks": [{ "type": "command", "command": "bash /path/to/script.sh" }] }
213///     ]
214///   }
215/// }
216/// ```
217fn write_hooks_settings(target_dir: &Path, hooks: &[&HookEntry]) -> Result<PathBuf, MarsError> {
218    let path = target_dir.join("settings.json");
219
220    let mut root: serde_json::Value = if path.is_file() {
221        let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
222        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}))
223    } else {
224        serde_json::json!({})
225    };
226
227    let hooks_section = root
228        .as_object_mut()
229        .ok_or_else(|| {
230            MarsError::Config(crate::error::ConfigError::Invalid {
231                message: format!("{} is not a JSON object", path.display()),
232            })
233        })?
234        .entry("hooks")
235        .or_insert_with(|| serde_json::json!({}));
236
237    let hooks_map = hooks_section.as_object_mut().ok_or_else(|| {
238        MarsError::Config(crate::error::ConfigError::Invalid {
239            message: format!("{}: hooks is not an object", path.display()),
240        })
241    })?;
242
243    for hook in hooks {
244        let native_event = &hook.native_event;
245        let command_entry = serde_json::json!({
246            "type": "command",
247            "command": hook_command(&hook.script_path),
248        });
249        let hook_binding = serde_json::json!({
250            "matcher": "",
251            "hooks": [command_entry],
252        });
253
254        let event_hooks = hooks_map
255            .entry(native_event.clone())
256            .or_insert_with(|| serde_json::json!([]))
257            .as_array_mut()
258            .ok_or_else(|| {
259                MarsError::Config(ConfigError::Invalid {
260                    message: format!("{}: hooks.{native_event} is not an array", path.display()),
261                })
262            })?;
263        remove_managed_hook_bindings(event_hooks, &hook.name);
264        event_hooks.push(hook_binding);
265    }
266
267    let content = serde_json::to_string_pretty(&root).map_err(|e| {
268        MarsError::Config(crate::error::ConfigError::Invalid {
269            message: format!("failed to serialize {}: {e}", path.display()),
270        })
271    })?;
272    crate::fs::atomic_write(&path, content.as_bytes())?;
273
274    Ok(path)
275}
276
277fn remove_managed_hook_bindings(bindings: &mut Vec<serde_json::Value>, hook_name: &str) {
278    bindings.retain(|binding| {
279        let Some(inner_hooks) = binding.get("hooks").and_then(|h| h.as_array()) else {
280            return true;
281        };
282        !inner_hooks.iter().any(|h| {
283            h.get("command")
284                .and_then(|c| c.as_str())
285                .map(|cmd| is_managed_hook_command_for(cmd, hook_name))
286                .unwrap_or(false)
287        })
288    });
289}
290
291fn is_managed_hook_command_for(command: &str, hook_name: &str) -> bool {
292    let normalized = command.replace('\\', "/").replace("//", "/");
293    normalized.contains(&format!("/hooks/{hook_name}/"))
294}
295
296/// Remove hook entries by key from `settings.json`.
297///
298/// Keys are "hook:<event>:<name>" — we use the native event name to locate
299/// the section. Because hooks are additive and the settings.json may contain
300/// user-owned entries, we only remove entries we wrote (matched by command path).
301fn remove_hook_entries_by_key(entry_keys: &[String], target_dir: &Path) -> Result<(), MarsError> {
302    let path = target_dir.join("settings.json");
303    if !path.is_file() {
304        return Ok(());
305    }
306
307    // For now: if any hook keys are being removed, we reload and remove matching
308    // command entries. This is conservative — we only remove entries we know
309    // belong to mars-managed hooks.
310    let hook_keys: Vec<(String, &str)> = entry_keys
311        .iter()
312        .filter_map(|k| {
313            let rest = k.strip_prefix("hook:")?;
314            let (event, name) = rest.split_once(':')?;
315            Some((claude_hook_event(event)?.to_string(), name))
316        })
317        .collect();
318
319    if hook_keys.is_empty() {
320        return Ok(());
321    }
322
323    let raw = std::fs::read_to_string(&path).map_err(MarsError::from)?;
324    let mut root: serde_json::Value =
325        serde_json::from_str(&raw).unwrap_or_else(|_| serde_json::json!({}));
326
327    // We track removed hooks by their universal event + name in the command string.
328    // The format we write is "bash <script_path>", so we match on that prefix.
329    if let Some(hooks_map) = root
330        .as_object_mut()
331        .and_then(|o| o.get_mut("hooks"))
332        .and_then(|v| v.as_object_mut())
333    {
334        for (event, name) in &hook_keys {
335            if let Some(event_hooks) = hooks_map.get_mut(event)
336                && let Some(arr) = event_hooks.as_array_mut()
337            {
338                arr.retain(|binding| {
339                    // Retain if we can't parse it (not ours) or if it doesn't
340                    // contain the hook name in any inner command.
341                    let Some(inner_hooks) = binding.get("hooks").and_then(|h| h.as_array()) else {
342                        return true;
343                    };
344                    !inner_hooks.iter().any(|h| {
345                        h.get("command")
346                            .and_then(|c| c.as_str())
347                            .map(|cmd| {
348                                // Exact path-segment match to avoid partial name collisions
349                                // (e.g., "audit" must not match "audit-extended").
350                                is_managed_hook_command_for(cmd, name)
351                            })
352                            .unwrap_or(false)
353                    })
354                });
355            }
356        }
357    }
358
359    let content = serde_json::to_string_pretty(&root).map_err(|e| {
360        MarsError::Config(crate::error::ConfigError::Invalid {
361            message: format!("failed to serialize {}: {e}", path.display()),
362        })
363    })?;
364    crate::fs::atomic_write(&path, content.as_bytes())?;
365
366    Ok(())
367}
368
369fn claude_hook_event(event: &str) -> Option<&'static str> {
370    match event {
371        "session.start" => Some("SessionStart"),
372        "session.end" => Some("SessionStop"),
373        "tool.pre" => Some("PreToolUse"),
374        "tool.post" => Some("PostToolUse"),
375        _ => None,
376    }
377}
378
379// ---------------------------------------------------------------------------
380// Tests
381// ---------------------------------------------------------------------------
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use indexmap::IndexMap;
387    use tempfile::TempDir;
388
389    fn make_mcp_entry(name: &str) -> ConfigEntry {
390        ConfigEntry::McpServer(McpServerEntry {
391            name: name.to_string(),
392            command: "npx".to_string(),
393            args: vec!["-y".to_string(), "some-mcp@latest".to_string()],
394            env: IndexMap::new(),
395        })
396    }
397
398    fn make_mcp_entry_with_env(name: &str, env_key: &str, env_var: &str) -> ConfigEntry {
399        let mut env = IndexMap::new();
400        env.insert(env_key.to_string(), env_var.to_string());
401        ConfigEntry::McpServer(McpServerEntry {
402            name: name.to_string(),
403            command: "npx".to_string(),
404            args: vec![],
405            env,
406        })
407    }
408
409    fn make_hook_entry(name: &str, event: &str, native: &str) -> ConfigEntry {
410        ConfigEntry::Hook(HookEntry {
411            name: name.to_string(),
412            event: event.to_string(),
413            native_event: native.to_string(),
414            script_path: format!("/hooks/{name}/run.sh"),
415            order: 0,
416        })
417    }
418
419    fn make_hook_entry_with_path(
420        name: &str,
421        event: &str,
422        native: &str,
423        script_path: &str,
424    ) -> ConfigEntry {
425        ConfigEntry::Hook(HookEntry {
426            name: name.to_string(),
427            event: event.to_string(),
428            native_event: native.to_string(),
429            script_path: script_path.to_string(),
430            order: 0,
431        })
432    }
433
434    #[test]
435    fn write_mcp_creates_mcp_json() {
436        let tmp = TempDir::new().unwrap();
437        std::fs::create_dir_all(tmp.path()).unwrap();
438
439        let adapter = ClaudeAdapter;
440        let entries = vec![make_mcp_entry("context7")];
441        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
442
443        assert_eq!(written.len(), 1);
444        assert!(tmp.path().join(".mcp.json").exists());
445
446        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
447        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
448        assert!(json["mcpServers"]["context7"].is_object());
449        assert_eq!(json["mcpServers"]["context7"]["command"], "npx");
450    }
451
452    #[test]
453    fn write_mcp_merges_with_existing() {
454        let tmp = TempDir::new().unwrap();
455        let existing = serde_json::json!({
456            "mcpServers": { "existing-server": { "command": "old" } }
457        });
458        std::fs::write(
459            tmp.path().join(".mcp.json"),
460            serde_json::to_string_pretty(&existing).unwrap(),
461        )
462        .unwrap();
463
464        let adapter = ClaudeAdapter;
465        let entries = vec![make_mcp_entry("new-server")];
466        adapter.write_config_entries(&entries, tmp.path()).unwrap();
467
468        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
469        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
470        assert!(json["mcpServers"]["existing-server"].is_object());
471        assert!(json["mcpServers"]["new-server"].is_object());
472    }
473
474    #[test]
475    fn write_mcp_env_renders_as_interpolation() {
476        let tmp = TempDir::new().unwrap();
477        let adapter = ClaudeAdapter;
478        let entries = vec![make_mcp_entry_with_env("server", "API_KEY", "MY_SECRET")];
479        adapter.write_config_entries(&entries, tmp.path()).unwrap();
480
481        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
482        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
483        assert_eq!(
484            json["mcpServers"]["server"]["env"]["API_KEY"],
485            "${MY_SECRET}"
486        );
487    }
488
489    #[test]
490    fn write_hooks_creates_settings_json() {
491        let tmp = TempDir::new().unwrap();
492        let adapter = ClaudeAdapter;
493        let entries = vec![make_hook_entry("audit", "tool.pre", "PreToolUse")];
494        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
495
496        assert_eq!(written.len(), 1);
497        assert!(tmp.path().join("settings.json").exists());
498
499        let raw = std::fs::read_to_string(tmp.path().join("settings.json")).unwrap();
500        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
501        assert!(json["hooks"]["PreToolUse"].is_array());
502        assert!(!json["hooks"]["PreToolUse"].as_array().unwrap().is_empty());
503    }
504
505    #[test]
506    fn write_hooks_replaces_existing_managed_hook_with_same_event_and_name() {
507        let tmp = TempDir::new().unwrap();
508        let adapter = ClaudeAdapter;
509        adapter
510            .write_config_entries(
511                &[make_hook_entry_with_path(
512                    "audit",
513                    "tool.pre",
514                    "PreToolUse",
515                    "/old/hooks/audit/run.sh",
516                )],
517                tmp.path(),
518            )
519            .unwrap();
520        adapter
521            .write_config_entries(
522                &[make_hook_entry_with_path(
523                    "audit",
524                    "tool.pre",
525                    "PreToolUse",
526                    "/new/hooks/audit/run.sh",
527                )],
528                tmp.path(),
529            )
530            .unwrap();
531
532        let raw = std::fs::read_to_string(tmp.path().join("settings.json")).unwrap();
533        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
534        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
535        assert_eq!(hooks.len(), 1);
536        let command = hooks[0]["hooks"][0]["command"].as_str().unwrap();
537        assert!(command.contains("/new/hooks/audit/"));
538    }
539
540    #[test]
541    fn remove_mcp_entries_removes_by_name() {
542        let tmp = TempDir::new().unwrap();
543        let adapter = ClaudeAdapter;
544        let entries = vec![make_mcp_entry("context7"), make_mcp_entry("other")];
545        adapter.write_config_entries(&entries, tmp.path()).unwrap();
546
547        adapter
548            .remove_config_entries(&["mcp:context7".to_string()], tmp.path())
549            .unwrap();
550
551        let raw = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
552        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
553        assert!(json["mcpServers"]["context7"].is_null());
554        assert!(json["mcpServers"]["other"].is_object());
555    }
556
557    #[test]
558    fn write_mcp_and_hooks_both_written() {
559        let tmp = TempDir::new().unwrap();
560        let adapter = ClaudeAdapter;
561        let entries = vec![
562            make_mcp_entry("context7"),
563            make_hook_entry("audit", "tool.pre", "PreToolUse"),
564        ];
565        let written = adapter.write_config_entries(&entries, tmp.path()).unwrap();
566        assert_eq!(written.len(), 2);
567        assert!(tmp.path().join(".mcp.json").exists());
568        assert!(tmp.path().join("settings.json").exists());
569    }
570
571    #[test]
572    fn remove_hook_entries_matches_backslash_commands() {
573        let tmp = TempDir::new().unwrap();
574        let existing = serde_json::json!({
575            "hooks": {
576                "PreToolUse": [
577                    {
578                        "matcher": "",
579                        "hooks": [
580                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit\\\\run.sh\"" }
581                        ]
582                    },
583                    {
584                        "matcher": "",
585                        "hooks": [
586                            { "type": "command", "command": "bash \"C:\\\\pkg\\\\hooks\\\\audit-extended\\\\run.sh\"" }
587                        ]
588                    }
589                ]
590            }
591        });
592        std::fs::write(
593            tmp.path().join("settings.json"),
594            serde_json::to_string_pretty(&existing).unwrap(),
595        )
596        .unwrap();
597
598        remove_hook_entries_by_key(&["hook:tool.pre:audit".to_string()], tmp.path()).unwrap();
599
600        let raw = std::fs::read_to_string(tmp.path().join("settings.json")).unwrap();
601        let json: serde_json::Value = serde_json::from_str(&raw).unwrap();
602        let hooks = json["hooks"]["PreToolUse"].as_array().unwrap();
603        assert_eq!(hooks.len(), 1);
604    }
605}