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}
60
61pub const HARNESS_ADAPTERS: &[HarnessAdapter] = &[
65 HarnessAdapter {
66 name: "Claude Code",
67 paths_fn: claude_code_paths,
68 upsert_fn: upsert_standard,
69 },
70 HarnessAdapter {
71 name: "Claude Code (alt)",
72 paths_fn: claude_code_alt_paths,
73 upsert_fn: upsert_standard,
74 },
75 HarnessAdapter {
76 name: "Claude Desktop",
77 paths_fn: claude_desktop_paths,
78 upsert_fn: upsert_standard,
79 },
80 HarnessAdapter {
81 name: "Cursor",
82 paths_fn: cursor_paths,
83 upsert_fn: upsert_standard,
84 },
85 HarnessAdapter {
86 name: "VS Code (GitHub Copilot)",
87 paths_fn: vscode_paths,
88 upsert_fn: upsert_vscode,
89 },
90 HarnessAdapter {
91 name: "VS Code Insiders",
92 paths_fn: vscode_insiders_paths,
93 upsert_fn: upsert_vscode,
94 },
95 HarnessAdapter {
96 name: "GitHub Copilot CLI",
97 paths_fn: copilot_cli_paths,
98 upsert_fn: upsert_standard,
99 },
100 HarnessAdapter {
101 name: "Pi",
102 paths_fn: pi_paths,
103 upsert_fn: upsert_standard,
104 },
105 HarnessAdapter {
106 name: "OpenCode",
107 paths_fn: opencode_paths,
108 upsert_fn: upsert_opencode,
109 },
110 HarnessAdapter {
111 name: "VS Code (workspace)",
112 paths_fn: vscode_workspace_paths,
113 upsert_fn: upsert_vscode,
114 },
115 HarnessAdapter {
116 name: "project-local (.mcp.json)",
117 paths_fn: project_mcp_paths,
118 upsert_fn: upsert_standard,
119 },
120 HarnessAdapter {
121 name: "OpenCode (project-local)",
122 paths_fn: opencode_project_paths,
123 upsert_fn: upsert_opencode,
124 },
125];
126
127fn claude_code_paths() -> Vec<PathBuf> {
130 dirs::home_dir()
131 .into_iter()
132 .map(|h| h.join(".claude.json"))
133 .collect()
134}
135
136fn claude_code_alt_paths() -> Vec<PathBuf> {
137 dirs::home_dir()
138 .into_iter()
139 .map(|h| h.join(".config/claude/mcp.json"))
140 .collect()
141}
142
143#[cfg(target_os = "macos")]
144fn claude_desktop_paths() -> Vec<PathBuf> {
145 dirs::home_dir()
146 .into_iter()
147 .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
148 .collect()
149}
150
151#[cfg(target_os = "windows")]
152fn claude_desktop_paths() -> Vec<PathBuf> {
153 std::env::var("APPDATA")
154 .ok()
155 .map(|appdata| PathBuf::from(appdata).join("Claude/claude_desktop_config.json"))
156 .into_iter()
157 .collect()
158}
159
160#[cfg(not(any(target_os = "macos", target_os = "windows")))]
161fn claude_desktop_paths() -> Vec<PathBuf> {
162 Vec::new()
164}
165
166fn cursor_paths() -> Vec<PathBuf> {
167 dirs::home_dir()
168 .into_iter()
169 .map(|h| h.join(".cursor/mcp.json"))
170 .collect()
171}
172
173#[cfg(target_os = "macos")]
174fn vscode_paths() -> Vec<PathBuf> {
175 dirs::home_dir()
176 .into_iter()
177 .map(|h| h.join("Library/Application Support/Code/User/settings.json"))
178 .collect()
179}
180
181#[cfg(target_os = "linux")]
182fn vscode_paths() -> Vec<PathBuf> {
183 dirs::home_dir()
184 .into_iter()
185 .map(|h| h.join(".config/Code/User/settings.json"))
186 .collect()
187}
188
189#[cfg(target_os = "windows")]
190fn vscode_paths() -> Vec<PathBuf> {
191 std::env::var("APPDATA")
192 .ok()
193 .map(|appdata| PathBuf::from(appdata).join("Code/User/settings.json"))
194 .into_iter()
195 .collect()
196}
197
198#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
199fn vscode_paths() -> Vec<PathBuf> {
200 Vec::new()
201}
202
203#[cfg(target_os = "macos")]
204fn vscode_insiders_paths() -> Vec<PathBuf> {
205 dirs::home_dir()
206 .into_iter()
207 .map(|h| h.join("Library/Application Support/Code - Insiders/User/settings.json"))
208 .collect()
209}
210
211#[cfg(target_os = "linux")]
212fn vscode_insiders_paths() -> Vec<PathBuf> {
213 dirs::home_dir()
214 .into_iter()
215 .map(|h| h.join(".config/Code - Insiders/User/settings.json"))
216 .collect()
217}
218
219#[cfg(target_os = "windows")]
220fn vscode_insiders_paths() -> Vec<PathBuf> {
221 std::env::var("APPDATA")
222 .ok()
223 .map(|appdata| PathBuf::from(appdata).join("Code - Insiders/User/settings.json"))
224 .into_iter()
225 .collect()
226}
227
228#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
229fn vscode_insiders_paths() -> Vec<PathBuf> {
230 Vec::new()
231}
232
233fn copilot_cli_paths() -> Vec<PathBuf> {
234 let mut out = Vec::new();
235 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
236 out.push(PathBuf::from(xdg).join("copilot/mcp-config.json"));
237 }
238 if let Some(home) = dirs::home_dir() {
239 out.push(home.join(".copilot/mcp-config.json"));
240 }
241 out
242}
243
244fn pi_paths() -> Vec<PathBuf> {
245 let mut out = Vec::new();
246 if let Ok(pi_dir) = std::env::var("PI_CODING_AGENT_DIR") {
247 out.push(PathBuf::from(pi_dir).join("mcp.json"));
248 }
249 if let Some(home) = dirs::home_dir() {
250 out.push(home.join(".pi/agent/mcp.json"));
251 }
252 out
253}
254
255fn opencode_paths() -> Vec<PathBuf> {
256 let mut out = Vec::new();
257 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
258 out.push(PathBuf::from(xdg).join("opencode/opencode.json"));
259 }
260 if let Some(home) = dirs::home_dir() {
261 out.push(home.join(".config/opencode/opencode.json"));
262 }
263 out
264}
265
266fn vscode_workspace_paths() -> Vec<PathBuf> {
267 vec![PathBuf::from(".vscode/settings.json")]
268}
269
270fn project_mcp_paths() -> Vec<PathBuf> {
271 vec![PathBuf::from(".mcp.json")]
272}
273
274fn opencode_project_paths() -> Vec<PathBuf> {
275 vec![PathBuf::from("opencode.json")]
276}
277
278fn read_config_value(path: &Path) -> Result<Value> {
284 if !path.exists() {
285 return Ok(json!({}));
286 }
287 let body = std::fs::read_to_string(path).context("reading config")?;
288 if body.trim().is_empty() {
289 return Ok(json!({}));
290 }
291 let parsed: Value = serde_json::from_str(&body).with_context(|| {
292 format!(
293 "{} is not strict JSON (comments / trailing commas?); \
294 add the wire MCP entry manually to avoid overwriting it",
295 path.display()
296 )
297 })?;
298 if parsed.is_object() {
299 Ok(parsed)
300 } else {
301 Ok(json!({}))
302 }
303}
304
305fn write_config_value(path: &Path, cfg: &Value) -> Result<()> {
309 if let Some(parent) = path.parent()
310 && !parent.as_os_str().is_empty()
311 {
312 std::fs::create_dir_all(parent).context("creating parent dir")?;
313 }
314 let out = serde_json::to_string_pretty(cfg)? + "\n";
315 std::fs::write(path, out).context("writing config")?;
316 Ok(())
317}
318
319pub fn upsert_standard(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
324 let mut cfg = read_config_value(path)?;
325 let root = cfg.as_object_mut().unwrap();
326 let servers = root
327 .entry("mcpServers".to_string())
328 .or_insert_with(|| json!({}));
329 if !servers.is_object() {
330 *servers = json!({});
331 }
332 let map = servers.as_object_mut().unwrap();
333 if map.get(server_name) == Some(entry) {
334 return Ok(false);
335 }
336 map.insert(server_name.to_string(), entry.clone());
337 write_config_value(path, &cfg)?;
338 Ok(true)
339}
340
341pub fn upsert_vscode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
345 let mut cfg = read_config_value(path)?;
346 let root = cfg.as_object_mut().unwrap();
347 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
348 if !mcp.is_object() {
349 *mcp = json!({});
350 }
351 let mcp_obj = mcp.as_object_mut().unwrap();
352 let servers = mcp_obj
353 .entry("servers".to_string())
354 .or_insert_with(|| json!({}));
355 if !servers.is_object() {
356 *servers = json!({});
357 }
358 let map = servers.as_object_mut().unwrap();
359 if map.get(server_name) == Some(entry) {
360 return Ok(false);
361 }
362 map.insert(server_name.to_string(), entry.clone());
363 write_config_value(path, &cfg)?;
364 Ok(true)
365}
366
367pub fn upsert_opencode(path: &Path, server_name: &str, entry: &Value) -> Result<bool> {
373 let mut cfg = read_config_value(path)?;
374 let root = cfg.as_object_mut().unwrap();
375 let cmd_str = entry
377 .get("command")
378 .and_then(Value::as_str)
379 .unwrap_or("wire");
380 let args_arr: Vec<Value> = entry
381 .get("args")
382 .and_then(Value::as_array)
383 .cloned()
384 .unwrap_or_default();
385 let mut combined: Vec<Value> = vec![Value::String(cmd_str.to_string())];
386 combined.extend(args_arr);
387 let opencode_entry = json!({
388 "type": "local",
389 "command": combined,
390 "enabled": true,
391 });
392 let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
393 if !mcp.is_object() {
394 *mcp = json!({});
395 }
396 let map = mcp.as_object_mut().unwrap();
397 if map.get(server_name) == Some(&opencode_entry) {
398 return Ok(false);
399 }
400 map.insert(server_name.to_string(), opencode_entry);
401 write_config_value(path, &cfg)?;
402 Ok(true)
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 fn standard_entry() -> Value {
410 json!({"command": "wire", "args": ["mcp"]})
411 }
412
413 #[test]
414 fn registry_includes_every_v0_14_2_published_harness() {
415 let names: Vec<&str> = HARNESS_ADAPTERS.iter().map(|a| a.name).collect();
420 for required in [
421 "Claude Code",
422 "Cursor",
423 "VS Code (GitHub Copilot)",
424 "GitHub Copilot CLI",
425 "Pi",
426 "OpenCode",
427 ] {
428 assert!(
429 names.contains(&required),
430 "registry missing required adapter `{required}`"
431 );
432 }
433 }
434
435 #[test]
436 fn upsert_standard_writes_mcpservers_shape_and_is_idempotent() {
437 let dir = tempfile::tempdir().unwrap();
438 let path = dir.path().join("config.json");
439 let entry = standard_entry();
440 assert!(upsert_standard(&path, "wire", &entry).unwrap());
441 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
442 assert_eq!(v["mcpServers"]["wire"]["command"], "wire");
443 assert_eq!(v["mcpServers"]["wire"]["args"][0], "mcp");
444 assert!(
445 !upsert_standard(&path, "wire", &entry).unwrap(),
446 "idempotent"
447 );
448 }
449
450 #[test]
451 fn upsert_vscode_writes_mcp_servers_intermediate_and_is_idempotent() {
452 let dir = tempfile::tempdir().unwrap();
453 let path = dir.path().join("settings.json");
454 let entry = standard_entry();
455 assert!(upsert_vscode(&path, "wire", &entry).unwrap());
456 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
457 assert_eq!(v["mcp"]["servers"]["wire"]["command"], "wire");
458 assert!(v.get("mcpServers").is_none());
459 assert!(!upsert_vscode(&path, "wire", &entry).unwrap(), "idempotent");
460 }
461
462 #[test]
463 fn upsert_opencode_writes_combined_command_and_enabled_flag() {
464 let dir = tempfile::tempdir().unwrap();
465 let path = dir.path().join("opencode.json");
466 let entry = standard_entry();
467 assert!(upsert_opencode(&path, "wire", &entry).unwrap());
468 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
469 let wire = &v["mcp"]["wire"];
470 assert_eq!(wire["type"], "local");
471 assert_eq!(wire["enabled"], true);
472 assert_eq!(wire["command"][0], "wire");
473 assert_eq!(wire["command"][1], "mcp");
474 assert!(v.get("mcpServers").is_none());
475 assert!(
476 !upsert_opencode(&path, "wire", &entry).unwrap(),
477 "idempotent"
478 );
479 }
480
481 #[test]
482 fn upsert_preserves_sibling_keys_across_all_three_shapes() {
483 let dir = tempfile::tempdir().unwrap();
486 let entry = standard_entry();
487 for sub in ["standard.json", "vscode.json", "opencode.json"] {
488 let path = dir.path().join(sub);
489 std::fs::write(
490 &path,
491 r#"{"theme":"dark","providers":{"openai":{"apiKey":"sk-test"}}}"#,
492 )
493 .unwrap();
494 let upsert: fn(&Path, &str, &Value) -> Result<bool> = if sub == "standard.json" {
496 upsert_standard
497 } else if sub == "vscode.json" {
498 upsert_vscode
499 } else {
500 upsert_opencode
501 };
502 assert!(upsert(&path, "wire", &entry).unwrap());
503 let v: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
504 assert_eq!(v["theme"], "dark");
505 assert_eq!(v["providers"]["openai"]["apiKey"], "sk-test");
506 }
507 }
508
509 #[test]
510 fn upsert_refuses_to_overwrite_unparseable_json() {
511 let dir = tempfile::tempdir().unwrap();
517 let path = dir.path().join("settings.json");
518 std::fs::write(&path, "// theme override\n{\"theme\":\"dark\",}").unwrap();
519 let entry = standard_entry();
520 let err = upsert_vscode(&path, "wire", &entry).unwrap_err();
521 let msg = format!("{err:#}");
524 assert!(
525 msg.contains("not strict JSON"),
526 "expected 'not strict JSON' diagnostic, got: {msg}"
527 );
528 let body = std::fs::read_to_string(&path).unwrap();
530 assert!(body.starts_with("// theme override"));
531 }
532}