Skip to main content

wire/adapters/
harness.rs

1//! Harness adapters — per-host MCP-config registration contract.
2//!
3//! Each adapter is a small static record declaring (1) the probable
4//! on-disk config paths the host reads at startup, and (2) which JSON
5//! shape upsert function to merge wire's MCP entry with.
6//!
7//! ## The contract
8//!
9//! ```ignore
10//! pub struct HarnessAdapter {
11//!     pub name: &'static str,
12//!     pub paths_fn: fn() -> Vec<PathBuf>,
13//!     pub upsert_fn: fn(&Path, &str, &Value) -> Result<bool>,
14//! }
15//! ```
16//!
17//! - `name` — operator-facing label printed by `wire setup`.
18//! - `paths_fn` — returns every probable config path on the running
19//!   platform. May be empty (the host isn't installed on this OS).
20//!   Honor `$XDG_CONFIG_HOME` + per-platform conventions inside.
21//! - `upsert_fn` — atomically merges the provided MCP entry into the
22//!   host's config file at `path`. Returns `Ok(true)` if the file was
23//!   changed, `Ok(false)` if the exact entry was already present
24//!   (idempotent), `Err` on unrecoverable I/O / parse failure.
25//!
26//! ## Adding a new harness — three-step recipe
27//!
28//! 1. Write a `paths_fn` returning the host's probable config paths.
29//! 2. Pick (or write) an `upsert_fn`. Three pre-built shapes ship:
30//!    - [`upsert_standard`] for `{"mcpServers": {"<name>": {...}}}` —
31//!      Claude Code, Cursor, Claude Desktop, GitHub Copilot CLI, Pi,
32//!      project-local `.mcp.json`.
33//!    - [`upsert_vscode`] for `{"mcp": {"servers": {"<name>": {...}}}}` —
34//!      VS Code (Copilot), VS Code Insiders, `.vscode/settings.json`.
35//!    - [`upsert_opencode`] for `{"mcp": {"<name>": {"type":"local",
36//!      "command":["<bin>",...args], "enabled":true}}}` — OpenCode.
37//! 3. Add a [`HarnessAdapter`] entry to [`HARNESS_ADAPTERS`].
38//! 4. Add a test in the local `tests` module covering the new path
39//!    detection + shape.
40//!
41//! Walkthrough: `docs/adapters/HARNESS.md`.
42
43use std::path::{Path, PathBuf};
44
45use anyhow::{Context, Result};
46use serde_json::{Value, json};
47
48/// One harness this build can register the wire MCP server with.
49pub struct HarnessAdapter {
50    /// Operator-facing label printed by `wire setup`.
51    pub name: &'static str,
52    /// Returns every probable config path on the running platform.
53    /// May be empty when the host isn't shipped for the current OS.
54    pub paths_fn: fn() -> Vec<PathBuf>,
55    /// Atomic merge of `(server_name, entry)` into the host's
56    /// config file at `path`. Returns `Ok(true)` on change,
57    /// `Ok(false)` on no-op (entry already exact).
58    pub upsert_fn: fn(&Path, &str, &Value) -> Result<bool>,
59}
60
61/// The registry. Walked by `cli::cmd_setup` in order — first match
62/// wins for display order, every match upserts. Adding a harness is
63/// one entry here + one test below.
64pub 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
127// ---------- per-host path resolvers ----------
128
129fn 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    // Claude Desktop doesn't ship on linux/BSD as of v0.14.x.
163    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
278// ---------- per-shape upsert functions ----------
279
280/// Shared loader: read existing JSON file (or default to empty),
281/// guard against non-JSON / non-object roots. Used by every upsert
282/// shape so all three behave identically on parse / IO failure.
283fn 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
305/// Shared writer: atomic-ish write of the merged config. Creates the
306/// parent dir on demand. All three upsert shapes share this so file
307/// permissions + newline conventions stay consistent.
308fn 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
319/// Standard MCP shape: `{"mcpServers": {"<name>": {"command":
320/// "<bin>", "args": [...]}}}`. Used by Claude Code, Cursor, Claude
321/// Desktop, GitHub Copilot CLI, Pi, and the project-local
322/// `.mcp.json` convention.
323pub 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
341/// VS Code shape: `{"mcp": {"servers": {"<name>": {...}}}}`. Used by
342/// VS Code (User settings.json), VS Code Insiders, and the
343/// `.vscode/settings.json` workspace convention.
344pub 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
367/// OpenCode shape: `{"mcp": {"<name>": {"type": "local", "command":
368/// ["<bin>", ...args], "enabled": true}}}`. Three differences vs.
369/// standard: top-level `mcp` (not `mcpServers`); no `servers`
370/// intermediate; `command` is a single combined array, not the
371/// `{command, args}` pair.
372pub 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    // Map standard {command, args} → OpenCode combined command array.
376    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        // The published-v0.14.2 docs (PI.md + OPENCODE.md +
416        // README.md integrations list) commit to these adapters
417        // existing. Adding a harness is fine; removing one needs a
418        // deliberate doc + migration story.
419        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        // Author-friction guarantee: a host's existing config keys
484        // survive a `wire setup --apply` run.
485        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            // Pick the matching upsert by filename.
495            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        // JSONC files (VS Code settings.json with comments / trailing
512        // commas) are common. We must NOT replace them with our own
513        // `{...wire only...}` content. Instead return Err so the
514        // caller surfaces the target under "Skipped" and the
515        // operator edits the file by hand.
516        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        // The Err message must mention the JSON parse problem so the
522        // operator knows why we didn't write.
523        let msg = format!("{err:#}");
524        assert!(
525            msg.contains("not strict JSON"),
526            "expected 'not strict JSON' diagnostic, got: {msg}"
527        );
528        // File must be unchanged.
529        let body = std::fs::read_to_string(&path).unwrap();
530        assert!(body.starts_with("// theme override"));
531    }
532}