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