1use 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 _ => 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
92fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
113 let path = target_dir.join(".mcp.json");
114
115 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 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
196fn 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 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
230fn 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
325fn 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 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 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 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 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#[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}