1use std::path::{Path, PathBuf};
44
45use anyhow::{Context, Result};
46use serde_json::{Value, json};
47
48pub struct HarnessAdapter {
50 pub name: &'static str,
52 pub paths_fn: fn() -> Vec<PathBuf>,
55 pub upsert_fn: fn(&Path, &str, &Value) -> Result<bool>,
59 pub remove_fn: fn(&Path, &str) -> Result<bool>,
64}
65
66pub const HARNESS_ADAPTERS: &[HarnessAdapter] = &[
70 HarnessAdapter {
71 name: "Claude Code",
72 paths_fn: claude_code_paths,
73 upsert_fn: upsert_standard,
74 remove_fn: remove_standard,
75 },
76 HarnessAdapter {
77 name: "Claude Code (alt)",
78 paths_fn: claude_code_alt_paths,
79 upsert_fn: upsert_standard,
80 remove_fn: remove_standard,
81 },
82 HarnessAdapter {
83 name: "Claude Desktop",
84 paths_fn: claude_desktop_paths,
85 upsert_fn: upsert_standard,
86 remove_fn: remove_standard,
87 },
88 HarnessAdapter {
89 name: "Cursor",
90 paths_fn: cursor_paths,
91 upsert_fn: upsert_standard,
92 remove_fn: remove_standard,
93 },
94 HarnessAdapter {
95 name: "VS Code (GitHub Copilot)",
96 paths_fn: vscode_paths,
97 upsert_fn: upsert_vscode,
98 remove_fn: remove_vscode,
99 },
100 HarnessAdapter {
101 name: "VS Code Insiders",
102 paths_fn: vscode_insiders_paths,
103 upsert_fn: upsert_vscode,
104 remove_fn: remove_vscode,
105 },
106 HarnessAdapter {
107 name: "GitHub Copilot CLI",
108 paths_fn: copilot_cli_paths,
109 upsert_fn: upsert_standard,
110 remove_fn: remove_standard,
111 },
112 HarnessAdapter {
113 name: "Pi",
114 paths_fn: pi_paths,
115 upsert_fn: upsert_standard,
116 remove_fn: remove_standard,
117 },
118 HarnessAdapter {
119 name: "OpenCode",
120 paths_fn: opencode_paths,
121 upsert_fn: upsert_opencode,
122 remove_fn: remove_opencode,
123 },
124 HarnessAdapter {
125 name: "VS Code (workspace)",
126 paths_fn: vscode_workspace_paths,
127 upsert_fn: upsert_vscode,
128 remove_fn: remove_vscode,
129 },
130 HarnessAdapter {
131 name: "project-local (.mcp.json)",
132 paths_fn: project_mcp_paths,
133 upsert_fn: upsert_standard,
134 remove_fn: remove_standard,
135 },
136 HarnessAdapter {
137 name: "OpenCode (project-local)",
138 paths_fn: opencode_project_paths,
139 upsert_fn: upsert_opencode,
140 remove_fn: remove_opencode,
141 },
142];
143
144fn claude_code_paths() -> Vec<PathBuf> {
147 dirs::home_dir()
148 .into_iter()
149 .map(|h| h.join(".claude.json"))
150 .collect()
151}
152
153fn claude_code_alt_paths() -> Vec<PathBuf> {
154 dirs::home_dir()
155 .into_iter()
156 .map(|h| h.join(".config/claude/mcp.json"))
157 .collect()
158}
159
160#[cfg(target_os = "macos")]
161fn claude_desktop_paths() -> Vec<PathBuf> {
162 dirs::home_dir()
163 .into_iter()
164 .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
165 .collect()
166}
167
168#[cfg(target_os = "windows")]
169fn claude_desktop_paths() -> Vec<PathBuf> {
170 std::env::var("APPDATA")
171 .ok()
172 .map(|appdata| PathBuf::from(appdata).join("Claude/claude_desktop_config.json"))
173 .into_iter()
174 .collect()
175}
176
177#[cfg(not(any(target_os = "macos", target_os = "windows")))]
178fn claude_desktop_paths() -> Vec<PathBuf> {
179 Vec::new()
181}
182
183fn cursor_paths() -> Vec<PathBuf> {
184 dirs::home_dir()
185 .into_iter()
186 .map(|h| h.join(".cursor/mcp.json"))
187 .collect()
188}
189
190#[cfg(target_os = "macos")]
191fn vscode_paths() -> Vec<PathBuf> {
192 dirs::home_dir()
193 .into_iter()
194 .map(|h| h.join("Library/Application Support/Code/User/settings.json"))
195 .collect()
196}
197
198#[cfg(target_os = "linux")]
199fn vscode_paths() -> Vec<PathBuf> {
200 dirs::home_dir()
201 .into_iter()
202 .map(|h| h.join(".config/Code/User/settings.json"))
203 .collect()
204}
205
206#[cfg(target_os = "windows")]
207fn vscode_paths() -> Vec<PathBuf> {
208 std::env::var("APPDATA")
209 .ok()
210 .map(|appdata| PathBuf::from(appdata).join("Code/User/settings.json"))
211 .into_iter()
212 .collect()
213}
214
215#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
216fn vscode_paths() -> Vec<PathBuf> {
217 Vec::new()
218}
219
220#[cfg(target_os = "macos")]
221fn vscode_insiders_paths() -> Vec<PathBuf> {
222 dirs::home_dir()
223 .into_iter()
224 .map(|h| h.join("Library/Application Support/Code - Insiders/User/settings.json"))
225 .collect()
226}
227
228#[cfg(target_os = "linux")]
229fn vscode_insiders_paths() -> Vec<PathBuf> {
230 dirs::home_dir()
231 .into_iter()
232 .map(|h| h.join(".config/Code - Insiders/User/settings.json"))
233 .collect()
234}
235
236#[cfg(target_os = "windows")]
237fn vscode_insiders_paths() -> Vec<PathBuf> {
238 std::env::var("APPDATA")
239 .ok()
240 .map(|appdata| PathBuf::from(appdata).join("Code - Insiders/User/settings.json"))
241 .into_iter()
242 .collect()
243}
244
245#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
246fn vscode_insiders_paths() -> Vec<PathBuf> {
247 Vec::new()
248}
249
250fn copilot_cli_paths() -> Vec<PathBuf> {
251 let mut out = Vec::new();
252 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
253 out.push(PathBuf::from(xdg).join("copilot/mcp-config.json"));
254 }
255 if let Some(home) = dirs::home_dir() {
256 out.push(home.join(".copilot/mcp-config.json"));
257 }
258 out
259}
260
261fn pi_paths() -> Vec<PathBuf> {
262 let mut out = Vec::new();
263 if let Ok(pi_dir) = std::env::var("PI_CODING_AGENT_DIR") {
264 out.push(PathBuf::from(pi_dir).join("mcp.json"));
265 }
266 if let Some(home) = dirs::home_dir() {
267 out.push(home.join(".pi/agent/mcp.json"));
268 }
269 out
270}
271
272fn opencode_paths() -> Vec<PathBuf> {
273 let mut out = Vec::new();
274 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
275 out.push(PathBuf::from(xdg).join("opencode/opencode.json"));
276 }
277 if let Some(home) = dirs::home_dir() {
278 out.push(home.join(".config/opencode/opencode.json"));
279 }
280 out
281}
282
283fn vscode_workspace_paths() -> Vec<PathBuf> {
284 vec![PathBuf::from(".vscode/settings.json")]
285}
286
287fn project_mcp_paths() -> Vec<PathBuf> {
288 vec![PathBuf::from(".mcp.json")]
289}
290
291fn opencode_project_paths() -> Vec<PathBuf> {
292 vec![PathBuf::from("opencode.json")]
293}
294
295fn read_config_value(path: &Path) -> Result<Value> {
301 if !path.exists() {
302 return Ok(json!({}));
303 }
304 let body = std::fs::read_to_string(path).context("reading config")?;
305 if body.trim().is_empty() {
306 return Ok(json!({}));
307 }
308 let parsed: Value = serde_json::from_str(&body).with_context(|| {
309 format!(
310 "{} is not strict JSON (comments / trailing commas?); \
311 add the wire MCP entry manually to avoid overwriting it",
312 path.display()
313 )
314 })?;
315 if parsed.is_object() {
316 Ok(parsed)
317 } else {
318 Ok(json!({}))
319 }
320}
321
322fn write_config_value(path: &Path, cfg: &Value) -> Result<()> {
326 if let Some(parent) = path.parent()
327 && !parent.as_os_str().is_empty()
328 {
329 std::fs::create_dir_all(parent).context("creating parent dir")?;
330 }
331 let out = serde_json::to_string_pretty(cfg)? + "\n";
332 std::fs::write(path, out).context("writing config")?;
333 Ok(())
334}
335
336pub fn upsert_standard(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
341 let mut cfg = read_config_value(path)?;
342 let root = cfg.as_object_mut().unwrap();
343 let servers = root
344 .entry("mcpServers".to_string())
345 .or_insert_with(|| json!({}));
346 if !servers.is_object() {
347 *servers = json!({});
348 }
349 let map = servers.as_object_mut().unwrap();
350 if map.get(server_name) == Some(entry) {
351 return Ok(false);
352 }
353 map.insert(server_name.to_string(), entry.clone());
354 write_config_value(path, &cfg)?;
355 Ok(true)
356}
357
358pub fn upsert_vscode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
362 let mut cfg = read_config_value(path)?;
363 let root = cfg.as_object_mut().unwrap();
364 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
365 if !mcp.is_object() {
366 *mcp = json!({});
367 }
368 let mcp_obj = mcp.as_object_mut().unwrap();
369 let servers = mcp_obj
370 .entry("servers".to_string())
371 .or_insert_with(|| json!({}));
372 if !servers.is_object() {
373 *servers = json!({});
374 }
375 let map = servers.as_object_mut().unwrap();
376 if map.get(server_name) == Some(entry) {
377 return Ok(false);
378 }
379 map.insert(server_name.to_string(), entry.clone());
380 write_config_value(path, &cfg)?;
381 Ok(true)
382}
383
384pub fn upsert_opencode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
390 let mut cfg = read_config_value(path)?;
391 let root = cfg.as_object_mut().unwrap();
392 let cmd_str = entry
394 .get("command")
395 .and_then(Value::as_str)
396 .unwrap_or("wire");
397 let args_arr: Vec<Value> = entry
398 .get("args")
399 .and_then(Value::as_array)
400 .cloned()
401 .unwrap_or_default();
402 let mut combined: Vec<Value> = vec![Value::String(cmd_str.to_string())];
403 combined.extend(args_arr);
404 let opencode_entry = json!({
405 "type": "local",
406 "command": combined,
407 "enabled": true,
408 });
409 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
410 if !mcp.is_object() {
411 *mcp = json!({});
412 }
413 let map = mcp.as_object_mut().unwrap();
414 if map.get(server_name) == Some(&opencode_entry) {
415 return Ok(false);
416 }
417 map.insert(server_name.to_string(), opencode_entry);
418 write_config_value(path, &cfg)?;
419 Ok(true)
420}
421
422pub fn remove_standard(path: &Path, server_name: &str) -> Result<bool> {
425 if !path.exists() {
426 return Ok(false);
427 }
428 let mut cfg = read_config_value(path)?;
429 let changed = cfg
430 .get_mut("mcpServers")
431 .and_then(Value::as_object_mut)
432 .map(|m| m.remove(server_name).is_some())
433 .unwrap_or(false);
434 if changed {
435 write_config_value(path, &cfg)?;
436 }
437 Ok(changed)
438}
439
440pub fn remove_vscode(path: &Path, server_name: &str) -> Result<bool> {
442 if !path.exists() {
443 return Ok(false);
444 }
445 let mut cfg = read_config_value(path)?;
446 let changed = cfg
447 .get_mut("mcp")
448 .and_then(Value::as_object_mut)
449 .and_then(|m| m.get_mut("servers"))
450 .and_then(Value::as_object_mut)
451 .map(|m| m.remove(server_name).is_some())
452 .unwrap_or(false);
453 if changed {
454 write_config_value(path, &cfg)?;
455 }
456 Ok(changed)
457}
458
459pub fn remove_opencode(path: &Path, server_name: &str) -> Result<bool> {
461 if !path.exists() {
462 return Ok(false);
463 }
464 let mut cfg = read_config_value(path)?;
465 let changed = cfg
466 .get_mut("mcp")
467 .and_then(Value::as_object_mut)
468 .map(|m| m.remove(server_name).is_some())
469 .unwrap_or(false);
470 if changed {
471 write_config_value(path, &cfg)?;
472 }
473 Ok(changed)
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 fn standard_entry() -> Value {
481 json!({"command": "wire", "args": ["mcp"]})
482 }
483
484 #[test]
485 fn registry_includes_every_v0_14_2_published_harness() {
486 let names: Vec<&str> = HARNESS_ADAPTERS.iter().map(|a| a.name).collect();
491 for required in [
492 "Claude Code",
493 "Cursor",
494 "VS Code (GitHub Copilot)",
495 "GitHub Copilot CLI",
496 "Pi",
497 "OpenCode",
498 ] {
499 assert!(
500 names.contains(&required),
501 "registry missing required adapter `{required}`"
502 );
503 }
504 }
505
506 #[test]
507 fn upsert_standard_writes_mcpservers_shape_and_is_idempotent() {
508 let dir = tempfile::tempdir().unwrap();
509 let path = dir.path().join("config.json");
510 let entry = standard_entry();
511 assert!(upsert_standard(&path, "wire", &entry).unwrap());
512 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
513 assert_eq!(v["mcpServers"]["wire"]["command"], "wire");
514 assert_eq!(v["mcpServers"]["wire"]["args"][0], "mcp");
515 assert!(
516 !upsert_standard(&path, "wire", &entry).unwrap(),
517 "idempotent"
518 );
519 }
520
521 #[test]
522 fn upsert_vscode_writes_mcp_servers_intermediate_and_is_idempotent() {
523 let dir = tempfile::tempdir().unwrap();
524 let path = dir.path().join("settings.json");
525 let entry = standard_entry();
526 assert!(upsert_vscode(&path, "wire", &entry).unwrap());
527 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
528 assert_eq!(v["mcp"]["servers"]["wire"]["command"], "wire");
529 assert!(v.get("mcpServers").is_none());
530 assert!(!upsert_vscode(&path, "wire", &entry).unwrap(), "idempotent");
531 }
532
533 #[test]
534 fn upsert_opencode_writes_combined_command_and_enabled_flag() {
535 let dir = tempfile::tempdir().unwrap();
536 let path = dir.path().join("opencode.json");
537 let entry = standard_entry();
538 assert!(upsert_opencode(&path, "wire", &entry).unwrap());
539 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
540 let wire = &v["mcp"]["wire"];
541 assert_eq!(wire["type"], "local");
542 assert_eq!(wire["enabled"], true);
543 assert_eq!(wire["command"][0], "wire");
544 assert_eq!(wire["command"][1], "mcp");
545 assert!(v.get("mcpServers").is_none());
546 assert!(
547 !upsert_opencode(&path, "wire", &entry).unwrap(),
548 "idempotent"
549 );
550 }
551
552 #[test]
553 fn upsert_preserves_sibling_keys_across_all_three_shapes() {
554 let dir = tempfile::tempdir().unwrap();
557 let entry = standard_entry();
558 for sub in ["standard.json", "vscode.json", "opencode.json"] {
559 let path = dir.path().join(sub);
560 std::fs::write(
561 &path,
562 r#"{"theme":"dark","providers":{"openai":{"apiKey":"sk-test"}}}"#,
563 )
564 .unwrap();
565 let upsert: fn(&Path, &str, &Value) -> Result<bool> = if sub == "standard.json" {
567 upsert_standard
568 } else if sub == "vscode.json" {
569 upsert_vscode
570 } else {
571 upsert_opencode
572 };
573 assert!(upsert(&path, "wire", &entry).unwrap());
574 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
575 assert_eq!(v["theme"], "dark");
576 assert_eq!(v["providers"]["openai"]["apiKey"], "sk-test");
577 }
578 }
579
580 #[test]
581 fn upsert_refuses_to_overwrite_unparseable_json() {
582 let dir = tempfile::tempdir().unwrap();
588 let path = dir.path().join("settings.json");
589 std::fs::write(&path, "// theme override\n{\"theme\":\"dark\",}").unwrap();
590 let entry = standard_entry();
591 let err = upsert_vscode(&path, "wire", &entry).unwrap_err();
592 let msg = format!("{err:#}");
595 assert!(
596 msg.contains("not strict JSON"),
597 "expected 'not strict JSON' diagnostic, got: {msg}"
598 );
599 let body = std::fs::read_to_string(&path).unwrap();
601 assert!(body.starts_with("// theme override"));
602 }
603
604 #[test]
605 fn remove_standard_drops_only_wire_and_preserves_siblings() {
606 let tmp = tempfile::tempdir().unwrap();
607 let p = tmp.path().join("mcp.json");
608 std::fs::write(
609 &p,
610 r#"{"mcpServers":{"wire":{"command":"wire","args":["mcp"]},"other":{"command":"x"}}}"#,
611 )
612 .unwrap();
613 assert!(
614 remove_standard(&p, "wire").unwrap(),
615 "should report changed"
616 );
617 let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
618 assert!(v["mcpServers"].get("wire").is_none(), "wire removed");
619 assert!(v["mcpServers"].get("other").is_some(), "sibling preserved");
620 assert!(!remove_standard(&p, "wire").unwrap());
622 }
623
624 #[test]
625 fn remove_vscode_drops_wire_under_mcp_servers() {
626 let tmp = tempfile::tempdir().unwrap();
627 let p = tmp.path().join("settings.json");
628 std::fs::write(
629 &p,
630 r#"{"mcp":{"servers":{"wire":{"command":"wire"},"keep":{}}},"editor.fontSize":12}"#,
631 )
632 .unwrap();
633 assert!(remove_vscode(&p, "wire").unwrap());
634 let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
635 assert!(v["mcp"]["servers"].get("wire").is_none());
636 assert!(v["mcp"]["servers"].get("keep").is_some());
637 assert_eq!(v["editor.fontSize"], 12, "unrelated keys preserved");
638 }
639
640 #[test]
641 fn remove_opencode_drops_wire_under_mcp() {
642 let tmp = tempfile::tempdir().unwrap();
643 let p = tmp.path().join("opencode.json");
644 std::fs::write(&p, r#"{"mcp":{"wire":{"type":"local","command":["wire","mcp"],"enabled":true},"keep":{}}}"#).unwrap();
645 assert!(remove_opencode(&p, "wire").unwrap());
646 let v: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap();
647 assert!(v["mcp"].get("wire").is_none());
648 assert!(v["mcp"].get("keep").is_some());
649 }
650
651 #[test]
652 fn remove_is_noop_when_file_absent_or_key_missing() {
653 let tmp = tempfile::tempdir().unwrap();
654 let absent = tmp.path().join("nope.json");
655 assert!(
656 !remove_standard(&absent, "wire").unwrap(),
657 "absent file → no-op, no create"
658 );
659 assert!(!absent.exists(), "must not create the file");
660 let p = tmp.path().join("c.json");
661 std::fs::write(&p, r#"{"mcpServers":{"other":{}}}"#).unwrap();
662 assert!(!remove_standard(&p, "wire").unwrap(), "missing key → no-op");
663 }
664
665 #[test]
666 fn every_adapter_has_a_remove_fn() {
667 for a in HARNESS_ADAPTERS {
668 let tmp = tempfile::tempdir().unwrap();
670 let p = tmp.path().join("absent.json");
671 assert!(
672 !(a.remove_fn)(&p, "wire").unwrap(),
673 "{} remove_fn on absent → false",
674 a.name
675 );
676 }
677 }
678}