1use 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 _ => 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
93fn write_mcp_json(target_dir: &Path, servers: &[&McpServerEntry]) -> Result<PathBuf, MarsError> {
114 let path = target_dir.join(".mcp.json");
115
116 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 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
169fn 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 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
203fn 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 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
289fn 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 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
365fn 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
393fn 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#[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 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 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 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 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}