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