From c8b9ad2dd84c0b8f34cc5764bd9059b7657059ac Mon Sep 17 00:00:00 2001 From: Fenish Date: Mon, 12 Oct 2026 10:52:43 +0300 Subject: [PATCH 1/2] fix(sync): bring in OpenCode 2 MCP servers and plugins OpenCode 2 keeps MCP servers under `mcp.servers` and plugins under `plugins`. The setup screen listed the servers, but the sync read `mcp` as a flat map and only the `plugin` list, so none of them reached the pouch or the dashboard. Servers are now read from `mcp.servers` and beside it, and written there without OpenCode 1's `enabled` when the config (or the installed OpenCode) is version 2. Plugins are read from `plugins` and `plugin`, including `{package, options}` objects and `[package, options]` pairs, and entries with options are kept as written when the list changes. --- crates/sp-harness/src/config.rs | 61 ++++++++++------ crates/sp-harness/src/lib.rs | 8 ++- crates/sp-sync/src/jsonc.rs | 122 +++++++++++++++++++------------- crates/sp-sync/src/mcp.rs | 90 +++++++++++++++++++++-- crates/sp-sync/src/plugins.rs | 113 ++++++++++++++++++++++++----- crates/sp-sync/src/settings.rs | 2 +- 6 files changed, 298 insertions(+), 98 deletions(-) diff --git a/crates/sp-harness/src/config.rs b/crates/sp-harness/src/config.rs index 851ff54..ed11759 100644 --- a/crates/sp-harness/src/config.rs +++ b/crates/sp-harness/src/config.rs @@ -46,31 +46,42 @@ fn json_keys(text: &str, key: &str) -> Vec { let Ok(value) = serde_json::from_str::(text) else { return Vec::new(); }; - value - .get(key) - .and_then(serde_json::Value::as_object) - .map(json_servers) - .unwrap_or_default() + mcp_entries(&value, key).keys().cloned().collect() } -/// The servers in a JSON map. Some configs nest them one level deeper, -/// under `servers`. -fn json_servers(map: &serde_json::Map) -> Vec { - let mut names = Vec::new(); - for (name, server) in map { - let Some(fields) = server.as_object() else { - continue; - }; - let defines = ["type", "command", "url", "httpUrl", "serverUrl"] - .iter() - .any(|field| fields.contains_key(*field)); - if name == "servers" && !defines { - names.extend(json_servers(fields)); - } else { - names.push(name.clone()); +type Map = serde_json::Map; + +/// The `servers` object under an assistant's MCP key, if the servers are +/// nested one level deeper (OpenCode 2's `mcp.servers`). A server that +/// happens to be called `servers` doesn't count. +pub fn nested_servers(map: &Map) -> Option<&Map> { + let fields = map.get("servers")?.as_object()?; + let defines = ["type", "command", "url", "httpUrl", "serverUrl"] + .iter() + .any(|field| fields.contains_key(*field)); + (!defines).then_some(fields) +} + +/// The server entries under `key` in a parsed config, by name: the ones +/// nested under `servers` and any written beside it. +pub fn mcp_entries(value: &serde_json::Value, key: &str) -> Map { + let Some(map) = value.get(key).and_then(serde_json::Value::as_object) else { + return Map::new(); + }; + let nested = nested_servers(map); + let mut entries: Map = map + .iter() + .filter(|(name, server)| { + server.is_object() && !(nested.is_some() && name.as_str() == "servers") + }) + .map(|(name, server)| (name.clone(), server.clone())) + .collect(); + for (name, server) in nested.into_iter().flatten() { + if server.is_object() { + entries.insert(name.clone(), server.clone()); } } - names + entries } fn toml_keys(text: &str, key: &str) -> Vec { @@ -178,6 +189,14 @@ mod tests { ); assert_eq!(flat, ["fs"]); assert_eq!(nested, ["a", "b"]); + // Written both ways in one file, both count; values that aren't + // servers don't. + let mut both = json_keys( + r#"{"mcp":{"timeout":5,"old":{"command":["y"]},"servers":{"a":{}}}}"#, + "mcp", + ); + both.sort(); + assert_eq!(both, ["a", "old"]); // A server that happens to be called `servers` is still one. assert_eq!( json_keys(r#"{"mcp":{"servers":{"type":"local"}}}"#, "mcp"), diff --git a/crates/sp-harness/src/lib.rs b/crates/sp-harness/src/lib.rs index 0c055ca..49d534e 100644 --- a/crates/sp-harness/src/lib.rs +++ b/crates/sp-harness/src/lib.rs @@ -14,7 +14,7 @@ mod config; mod files; -pub use config::strip_jsonc; +pub use config::{mcp_entries, nested_servers, strip_jsonc}; pub use files::{IGNORED_DIRS, ignored_file}; use serde::{Deserialize, Serialize}; @@ -509,7 +509,8 @@ impl Places { pub enum McpFormat { /// `mcpServers`, `type` stdio/http/sse (Claude Code, Cursor, Factory). Claude, - /// `mcp`, `local` with a command array, or `remote` (OpenCode). + /// `mcp.servers` (`mcp` in OpenCode 1), `local` with a command array, or + /// `remote` (OpenCode). OpenCode, /// `[mcp_servers]` in TOML (Codex). Codex, @@ -618,7 +619,8 @@ pub enum PluginFormat { /// `enabledPlugins` and `extraKnownMarketplaces` in `settings.json` /// (Claude Code, Factory). Claude, - /// npm packages in the `plugin` list of `opencode.json` (OpenCode). + /// npm packages in the `plugins` list of `opencode.json` (OpenCode; + /// `plugin` in OpenCode 1). OpenCode, } diff --git a/crates/sp-sync/src/jsonc.rs b/crates/sp-sync/src/jsonc.rs index 281b8df..f808ae2 100644 --- a/crates/sp-sync/src/jsonc.rs +++ b/crates/sp-sync/src/jsonc.rs @@ -176,12 +176,12 @@ fn value_of(text: &str, member: &Member) -> Option { .ok() } -/// Sets `key` (an object at the top level) to hold `entries` for the names -/// SkillPouch owns: `managed` names not in `entries` are removed, others -/// are left as the user has them. Returns the new text. +/// Sets the object at `path` (keys from the top) to hold `entries` for the +/// names SkillPouch owns: `managed` names not in `entries` are removed, +/// others are left as the user has them. Returns the new text. pub fn set_entries( text: &str, - key: &str, + path: &[&str], entries: &BTreeMap, managed: &std::collections::BTreeSet, ) -> Parsed { @@ -195,21 +195,8 @@ pub fn set_entries( if text.as_bytes().get(root_open) != Some(&b'{') { return Err("it isn't a JSON object".into()); } - let root = object(text.as_bytes(), root_open)?; - let servers = match root.members.iter().find(|m| m.name == key) { - Some(member) if text.as_bytes()[member.value_start] == b'{' => member.value_start, - Some(_) => return Err(format!("`{key}` isn't an object")), - None if entries.is_empty() => return Ok(text), - None => { - let member = format!("{}: {{}}", serde_json::to_string(key).unwrap_or_default()); - text = insert_member(&text, &root, &member, "", &unit); - let root = object(text.as_bytes(), root_open)?; - root.members - .iter() - .find(|m| m.name == key) - .map(|m| m.value_start) - .ok_or("couldn't add the key")? - } + let Some(servers) = open_path(&mut text, root_open, path, !entries.is_empty(), &unit)? else { + return Ok(text); }; // Changes inside the object, applied one at a time on fresh offsets. @@ -261,27 +248,9 @@ pub fn set_value(text: &str, path: &[&str], value: Option<&Value>) -> Parsed at = member.value_start, - Some(_) => return Err(format!("`{key}` isn't an object")), - None if value.is_none() => return Ok(text), - None => { - let base = member_indent(&text, ¤t, &unit); - let member = format!("{}: {{}}", serde_json::to_string(key).unwrap_or_default()); - text = insert_member(&text, ¤t, &member, &base, &unit); - let current = object(text.as_bytes(), at)?; - at = current - .members - .iter() - .find(|m| m.name == *key) - .map(|m| m.value_start) - .ok_or("couldn't add the key")?; - } - } - } + let Some(at) = open_path(&mut text, root, parents, value.is_some(), &unit)? else { + return Ok(text); + }; let current = object(text.as_bytes(), at)?; let base = member_indent(&text, ¤t, &unit); let index = current.members.iter().position(|m| m.name == *last); @@ -307,6 +276,38 @@ pub fn set_value(text: &str, path: &[&str], value: Option<&Value>) -> Parsed Parsed> { + for key in path { + let current = object(text.as_bytes(), at)?; + match current.members.iter().find(|m| m.name == *key) { + Some(member) if text.as_bytes()[member.value_start] == b'{' => at = member.value_start, + Some(_) => return Err(format!("`{key}` isn't an object")), + None if !create => return Ok(None), + None => { + let base = member_indent(text, ¤t, unit); + let member = format!("{}: {{}}", serde_json::to_string(key).unwrap_or_default()); + *text = insert_member(text, ¤t, &member, &base, unit); + let current = object(text.as_bytes(), at)?; + at = current + .members + .iter() + .find(|m| m.name == *key) + .map(|m| m.value_start) + .ok_or("couldn't add the key")?; + } + } + } + Ok(Some(at)) +} + /// How the members of `object` are indented (one level in from its line). fn member_indent(text: &str, object: &Object, unit: &str) -> String { match object.members.first() { @@ -407,7 +408,7 @@ mod tests { let managed = BTreeSet::from(["old".to_string(), "gh".to_string()]); let out = set_entries( text, - "mcp", + &["mcp"], &entries(&[("gh", json!({"type": "remote", "url": "u"}))]), &managed, )?; @@ -425,7 +426,7 @@ mod tests { assert_eq!( set_entries( &out, - "mcp", + &["mcp"], &entries(&[("gh", json!({"type": "remote", "url": "u"}))]), &managed )?, @@ -434,7 +435,7 @@ mod tests { // Changed: only that value is rewritten. let changed = set_entries( &out, - "mcp", + &["mcp"], &entries(&[("gh", json!({"url": "v"}))]), &managed, )?; @@ -447,14 +448,14 @@ mod tests { fn creates_the_key_and_the_file() -> Parsed<()> { let out = set_entries( "", - "mcpServers", + &["mcpServers"], &entries(&[("a", json!({"command": "x"}))]), &BTreeSet::new(), )?; assert_eq!(parsed(&out)["mcpServers"]["a"]["command"], "x"); let out = set_entries( "{\n\t\"theme\": 1\n}", - "mcpServers", + &["mcpServers"], &entries(&[("a", json!({"command": "x"}))]), &BTreeSet::new(), )?; @@ -463,12 +464,33 @@ mod tests { Ok(()) } + #[test] + fn sets_entries_in_a_nested_object() -> Parsed<()> { + let path = ["mcp", "servers"]; + let a = entries(&[("a", json!({"command": ["x"]}))]); + let out = set_entries( + "{\n // mine\n \"model\": \"x\"\n}\n", + &path, + &a, + &BTreeSet::new(), + )?; + assert!(out.contains("// mine"), "{out}"); + assert_eq!(parsed(&out)["mcp"]["servers"]["a"]["command"], json!(["x"])); + assert_eq!(set_entries(&out, &path, &a, &BTreeSet::new())?, out); + // Nothing to set, nothing created. + assert_eq!( + set_entries("{}", &path, &BTreeMap::new(), &BTreeSet::new())?, + "{}" + ); + Ok(()) + } + #[test] fn removing_the_last_entry_leaves_valid_json() -> Parsed<()> { let text = "{\"mcpServers\": {\n \"a\": {},\n \"b\": {}\n}}"; let out = set_entries( text, - "mcpServers", + &["mcpServers"], &BTreeMap::new(), &BTreeSet::from(["b".to_string()]), )?; @@ -476,7 +498,7 @@ mod tests { assert_eq!(value["mcpServers"], json!({"a": {}})); let out = set_entries( &out, - "mcpServers", + &["mcpServers"], &BTreeMap::new(), &BTreeSet::from(["a".to_string()]), )?; @@ -507,16 +529,16 @@ mod tests { #[test] fn refuses_what_it_cannot_parse() { - assert!(set_entries("[1]", "mcp", &BTreeMap::new(), &BTreeSet::new()).is_err()); + assert!(set_entries("[1]", &["mcp"], &BTreeMap::new(), &BTreeSet::new()).is_err()); assert!( set_entries( "{\"mcp\": 3}", - "mcp", + &["mcp"], &entries(&[("a", json!({}))]), &BTreeSet::new() ) .is_err() ); - assert!(set_entries("{\"a\" 1}", "mcp", &BTreeMap::new(), &BTreeSet::new()).is_err()); + assert!(set_entries("{\"a\" 1}", &["mcp"], &BTreeMap::new(), &BTreeSet::new()).is_err()); } } diff --git a/crates/sp-sync/src/mcp.rs b/crates/sp-sync/src/mcp.rs index c7c0b07..651dcaf 100644 --- a/crates/sp-sync/src/mcp.rs +++ b/crates/sp-sync/src/mcp.rs @@ -158,7 +158,7 @@ pub fn read(path: &Path, format: McpFormat) -> BTreeMap { .unwrap_or_default(), _ => serde_json::from_str::(&sp_harness::strip_jsonc(&text)) .ok() - .map(|value| object(&value, key_of(format))) + .map(|value| sp_harness::mcp_entries(&value, key_of(format))) .unwrap_or_default(), }; entries @@ -189,13 +189,23 @@ pub fn write( Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), Err(error) => return Err(io(path)(error)), }; - let native: BTreeMap = servers + let mut native: BTreeMap = servers .iter() .filter_map(|(name, server)| Some((name.clone(), to_native(format, server)?))) .collect(); + let opencode_2 = format == McpFormat::OpenCode && opencode_nests(&text, path); + if opencode_2 { + // OpenCode 2 turns servers off with `disabled`; it has no `enabled`. + for entry in native.values_mut() { + if let Some(fields) = entry.as_object_mut() { + fields.remove("enabled"); + } + } + } let updated = match format { McpFormat::Codex => write_toml(&text, &native, managed), - _ => write_json(&text, key_of(format), &native, managed), + _ if opencode_2 => write_json(&text, &[key_of(format), "servers"], &native, managed), + _ => write_json(&text, &[key_of(format)], &native, managed), }; let updated = match updated { Ok(updated) => updated, @@ -227,16 +237,32 @@ fn restrict_like_before(path: &Path) { let _ = path; } +/// Whether an OpenCode config keeps its servers in `mcp.servers` (OpenCode +/// 2): as it does already, or, with no servers yet, as the installed +/// OpenCode reads them. +fn opencode_nests(text: &str, path: &Path) -> bool { + let value: Value = serde_json::from_str(&sp_harness::strip_jsonc(text)).unwrap_or_default(); + if let Some(map) = value.get("mcp").and_then(Value::as_object) { + if sp_harness::nested_servers(map).is_some() { + return true; + } + if map.values().any(Value::is_object) { + return false; + } + } + crate::settings::local_format(path.parent().unwrap_or(Path::new("."))) == 2 +} + fn write_json( text: &str, - key: &str, + path: &[&str], native: &BTreeMap, managed: &BTreeSet, ) -> std::result::Result { if text.trim().is_empty() && native.is_empty() { return Ok(text.to_string()); } - crate::jsonc::set_entries(text, key, native, managed) + crate::jsonc::set_entries(text, path, native, managed) } fn write_toml( @@ -407,4 +433,58 @@ mod tests { ); Ok(()) } + + #[test] + fn opencode_2_servers_go_under_servers_without_enabled() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("opencode.json"); + fs::write( + &path, + "{\n \"plugins\": [],\n \"mcp\": {\n \"servers\": {\n // mine\n \"mine\": { \"type\": \"local\", \"command\": [\"a\"] }\n }\n }\n}\n", + ) + .map_err(io(&path))?; + let mut servers = BTreeMap::new(); + servers.insert("gh".to_string(), stdio()); + write(&path, McpFormat::OpenCode, &servers, &BTreeSet::new())?; + let text = fs::read_to_string(&path).map_err(io(&path))?; + let value: Value = + serde_json::from_str(&sp_harness::strip_jsonc(&text)).unwrap_or_default(); + assert!(text.contains("// mine"), "{text}"); + assert_eq!( + value["mcp"]["servers"]["gh"], + json!({ "type": "local", "command": ["npx", "-y", "gh-mcp"], "environment": { "TOKEN": "x" } }) + ); + assert!(value["mcp"].get("gh").is_none(), "{text}"); + let names: Vec<_> = read(&path, McpFormat::OpenCode).into_keys().collect(); + assert_eq!(names, ["gh", "mine"]); + + write( + &path, + McpFormat::OpenCode, + &BTreeMap::new(), + &BTreeSet::from(["gh".to_string()]), + )?; + let names: Vec<_> = read(&path, McpFormat::OpenCode).into_keys().collect(); + assert_eq!(names, ["mine"]); + Ok(()) + } + + #[test] + fn opencode_1_servers_stay_flat() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("opencode.json"); + fs::write( + &path, + r#"{"mcp": {"mine": {"type": "local", "command": ["a"], "enabled": true}}}"#, + ) + .map_err(io(&path))?; + let mut servers = BTreeMap::new(); + servers.insert("gh".to_string(), stdio()); + write(&path, McpFormat::OpenCode, &servers, &BTreeSet::new())?; + let value: Value = serde_json::from_str(&fs::read_to_string(&path).map_err(io(&path))?) + .unwrap_or_default(); + assert_eq!(value["mcp"]["gh"]["enabled"], true); + assert!(value["mcp"].get("servers").is_none()); + Ok(()) + } } diff --git a/crates/sp-sync/src/plugins.rs b/crates/sp-sync/src/plugins.rs index ed68b1c..7a20a63 100644 --- a/crates/sp-sync/src/plugins.rs +++ b/crates/sp-sync/src/plugins.rs @@ -9,8 +9,8 @@ //! //! Claude Code and Factory get `enabledPlugins` and `extraKnownMarketplaces` //! entries; plugin folders are offered through a `skillpouch` marketplace -//! in the pouch's `plugins/` folder. OpenCode gets its `plugin` list. Each -//! assistant installs the plugins itself. +//! in the pouch's `plugins/` folder. OpenCode gets its `plugins` list +//! (`plugin` in OpenCode 1). Each assistant installs the plugins itself. use crate::error::io; use crate::item::{File, LocalItem}; @@ -134,17 +134,43 @@ fn on(path: &Path, format: PluginFormat) -> (BTreeSet, Value) { .filter(|(_, on)| on.as_bool() == Some(true)) .map(|(id, _)| id.clone()) .collect(), - PluginFormat::OpenCode => value - .get("plugin") - .and_then(Value::as_array) - .into_iter() + PluginFormat::OpenCode => OPENCODE_KEYS + .iter() + .filter_map(|key| value.get(key).and_then(Value::as_array)) .flatten() - .filter_map(|p| p.as_str().map(str::to_string)) + .filter_map(package) .collect(), }; (ids, value) } +/// OpenCode 2's plugin list, then OpenCode 1's. +const OPENCODE_KEYS: [&str; 2] = ["plugins", "plugin"]; + +/// The package of an OpenCode plugin entry: `"pkg"`, OpenCode 1's +/// `["pkg", {options}]` or OpenCode 2's `{"package": "pkg", "options": {…}}`. +fn package(entry: &Value) -> Option { + match entry { + Value::String(package) => Some(package.clone()), + Value::Array(parts) => parts.first()?.as_str().map(str::to_string), + Value::Object(fields) => fields.get("package")?.as_str().map(str::to_string), + _ => None, + } +} + +/// The key an OpenCode config lists plugins under: the one it has, else +/// the one the installed OpenCode reads. +fn opencode_key(value: &Value, path: &Path) -> &'static str { + if let Some(key) = OPENCODE_KEYS.iter().find(|key| value.get(**key).is_some()) { + return key; + } + if crate::settings::local_format(path.parent().unwrap_or(Path::new("."))) == 1 { + "plugin" + } else { + "plugins" + } +} + /// Writes the pouch's plugin items into one config file. pub fn write( path: &Path, @@ -192,11 +218,11 @@ pub fn write( if text.trim().is_empty() && enabled.is_empty() { Ok(text.clone()) } else { - crate::jsonc::set_entries(&text, "enabledPlugins", &enabled, &previous.enabled) + crate::jsonc::set_entries(&text, &["enabledPlugins"], &enabled, &previous.enabled) .and_then(|text| { crate::jsonc::set_entries( &text, - "extraKnownMarketplaces", + &["extraKnownMarketplaces"], &markets, &previous.marketplaces, ) @@ -204,7 +230,13 @@ pub fn write( } } PluginFormat::OpenCode => { - let (current, _) = on(path, format); + let (_, value) = on(path, format); + let key = opencode_key(&value, path); + let current: Vec = value + .get(key) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); let wanted: BTreeSet = items .iter() .filter_map(|item| match of(item) { @@ -212,30 +244,33 @@ pub fn write( _ => None, }) .collect(); - let had_list = fs::read_to_string(path).is_ok_and(|t| t.contains("\"plugin\"")); - let mut list: Vec = current + // Entries with options are the user's and stay as written. + let mut list: Vec = current .iter() - .filter(|p| !previous.npm.contains(*p) || wanted.contains(*p)) + .filter(|entry| { + package(entry).is_none_or(|p| !previous.npm.contains(&p) || wanted.contains(&p)) + }) .cloned() .collect(); + let listed: BTreeSet = list.iter().filter_map(package).collect(); // The user's own entry for the same package stays theirs. for package in &wanted { - if !list.contains(package) { - list.push(package.clone()); + if !listed.contains(package) { + list.push(json!(package)); record.npm.insert(package.clone()); } else if previous.npm.contains(package) { record.npm.insert(package.clone()); } } - if list.is_empty() && !had_list { + if list.is_empty() && value.get(key).is_none() { Ok(text.clone()) } else if list.is_empty() && !previous.npm.is_empty() && current.len() == previous.npm.len() { - crate::jsonc::set_value(&text, &["plugin"], None) + crate::jsonc::set_value(&text, &[key], None) } else { - crate::jsonc::set_value(&text, &["plugin"], Some(&json!(list))) + crate::jsonc::set_value(&text, &[key], Some(&json!(list))) } } }; @@ -484,4 +519,46 @@ mod tests { assert_eq!(read(&path)["plugin"], json!(["mine"])); Ok(()) } + + #[test] + fn opencode_2_list_is_plugins_and_keeps_entries_with_options() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("opencode.json"); + let notify = json!({ "package": "opencode-notify", "options": { "sound": true } }); + fs::write(&path, json!({ "plugins": ["mine", notify] }).to_string()).map_err(io(&path))?; + assert_eq!( + turned_on(&path, PluginFormat::OpenCode), + BTreeSet::from(["mine".to_string(), "opencode-notify".to_string()]) + ); + let waka = item( + "waka", + &[( + "plugin.json", + r#"{"name":"waka","npm":"opencode-wakatime"}"#, + )], + ); + let record = write(&path, PluginFormat::OpenCode, &[&waka], dir.path(), None)? + .map_err(SyncError::Invalid)?; + let value = read(&path); + assert_eq!( + value["plugins"], + json!(["mine", notify, "opencode-wakatime"]) + ); + assert!(value.get("plugin").is_none()); + write( + &path, + PluginFormat::OpenCode, + &[], + dir.path(), + Some(&record), + )? + .map_err(SyncError::Invalid)?; + assert_eq!(read(&path)["plugins"], json!(["mine", notify])); + + // OpenCode 1's `[package, options]` pairs count too. + fs::write(&path, r#"{"plugin": [["opencode-notify", {}]]}"#).map_err(io(&path))?; + let found = added(&path, PluginFormat::OpenCode, None, &BTreeMap::new()); + assert_eq!(found[0].npm.as_deref(), Some("opencode-notify")); + Ok(()) + } } diff --git a/crates/sp-sync/src/settings.rs b/crates/sp-sync/src/settings.rs index 7582ec5..a79e1f8 100644 --- a/crates/sp-sync/src/settings.rs +++ b/crates/sp-sync/src/settings.rs @@ -302,7 +302,7 @@ pub fn detect_opencode(text: &str) -> u8 { } } -fn local_format(root: &Path) -> u8 { +pub(crate) fn local_format(root: &Path) -> u8 { let text = ["opencode.json", "opencode.jsonc"] .iter() .find_map(|file| fs::read_to_string(root.join(file)).ok()); From 5e4fa5e93d625d05439de19780aeafbb7c99447f Mon Sep 17 00:00:00 2001 From: Fenish Date: Mon, 12 Oct 2026 10:52:43 +0300 Subject: [PATCH 2/2] test(sync): read every assistant's setup end to end A home with all eleven assistants set up as their documentation shows: what each one lists, the server.json every MCP dialect becomes, and the hooks and plugins brought in from each config, OpenCode 1 and 2 included. --- crates/sp-sync/tests/harnesses.rs | 523 ++++++++++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 crates/sp-sync/tests/harnesses.rs diff --git a/crates/sp-sync/tests/harnesses.rs b/crates/sp-sync/tests/harnesses.rs new file mode 100644 index 0000000..eca8b18 --- /dev/null +++ b/crates/sp-sync/tests/harnesses.rs @@ -0,0 +1,523 @@ +//! Every assistant's global setup, as people write it, read the way a sync +//! reads it: what the setup screen lists, what goes into the pouch, and the +//! hooks and plugins brought in from the config files. + +use serde_json::{Value, json}; +use sp_harness::{Places, discover, targets}; +use sp_sync::{adopt, hooks, plugins}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +type Res = Result<(), Box>; + +fn write(path: &Path, text: &str) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, text) +} + +fn skill(dir: &Path, name: &str) -> std::io::Result<()> { + write( + &dir.join(name).join("SKILL.md"), + &format!("---\nname: {name}\ndescription: Does {name}\n---\n# {name}\n"), + ) +} + +const AGENT: &str = "---\nname: reviewer\ndescription: Reviews code\n---\nReview it.\n"; +const COMMAND: &str = "---\ndescription: Ship it\n---\nShip $ARGUMENTS\n"; +const TOML_COMMAND: &str = "description = \"Ship it\"\nprompt = \"Ship {{args}}\"\n"; + +/// A home with every assistant set up the way its documentation shows. +fn home(home: &Path, opencode_config: &str) -> std::io::Result<()> { + let claude = home.join(".claude"); + write(&claude.join("CLAUDE.md"), "# Rules")?; + skill(&claude.join("skills"), "pdf")?; + write(&claude.join("agents/reviewer.md"), AGENT)?; + write(&claude.join("commands/ship.md"), COMMAND)?; + write( + &home.join(".claude.json"), + r#"{"numStartups": 3, "mcpServers": { + "github": {"type": "stdio", "command": "npx", "args": ["-y", "gh-mcp"], "env": {"TOKEN": "t"}}, + "docs": {"type": "sse", "url": "https://docs.example/sse", "headers": {"A": "b"}} + }}"#, + )?; + write( + &claude.join("settings.json"), + r#"{ + "hooks": {"PostToolUse": [{"matcher": "Edit|Write", "hooks": [ + {"type": "command", "command": "prettier --write .", "timeout": 30} + ]}]}, + "enabledPlugins": {"review@acme": true, "off@acme": false}, + "extraKnownMarketplaces": {"acme": {"source": {"source": "github", "repo": "acme/plugins"}}} + }"#, + )?; + + let codex = home.join(".codex"); + write(&codex.join("AGENTS.md"), "# Rules")?; + skill(&home.join(".agents/skills"), "shared")?; + write(&codex.join("prompts/ship.md"), COMMAND)?; + write( + &codex.join("config.toml"), + "model = \"gpt\"\n\n[mcp_servers.github]\ncommand = \"npx\"\nargs = [\"-y\", \"gh-mcp\"]\nenv = { TOKEN = \"t\" }\n\n[mcp_servers.docs]\nurl = \"https://docs.example/mcp\"\n", + )?; + write( + &codex.join("hooks.json"), + r#"{"hooks": {"Stop": [{"hooks": [{"type": "command", "command": "say done"}]}]}}"#, + )?; + + let opencode = home.join(".config/opencode"); + write(&opencode.join("AGENTS.md"), "# Rules")?; + skill(&opencode.join("skills"), "pdf")?; + write( + &opencode.join("agents/reviewer.md"), + "---\ndescription: Reviews code\nmode: subagent\n---\nReview it.\n", + )?; + write(&opencode.join("commands/ship.md"), COMMAND)?; + write(&opencode.join("opencode.jsonc"), opencode_config)?; + + let gemini = home.join(".gemini"); + write(&gemini.join("GEMINI.md"), "# Rules")?; + skill(&gemini.join("skills"), "pdf")?; + write(&gemini.join("agents/reviewer.md"), AGENT)?; + write(&gemini.join("commands/ship.toml"), TOML_COMMAND)?; + write( + &gemini.join("settings.json"), + r#"{"mcpServers": { + "github": {"command": "npx", "args": ["-y", "gh-mcp"], "env": {"TOKEN": "t"}}, + "docs": {"httpUrl": "https://docs.example/mcp"}, + "events": {"url": "https://docs.example/sse"} + }, + "hooks": {"AfterTool": [{"matcher": "write_file", "hooks": [ + {"name": "fmt", "type": "command", "command": "prettier --write .", "timeout": 30000} + ]}]}}"#, + )?; + + let antigravity = gemini.join("config"); + skill(&antigravity.join("skills"), "slides")?; + write(&antigravity.join("agents/reviewer.md"), AGENT)?; + write( + &antigravity.join("mcp_config.json"), + r#"{"mcpServers": {"docs": {"serverUrl": "https://docs.example/mcp"}, + "github": {"command": "npx", "args": ["-y", "gh-mcp"]}}}"#, + )?; + + let copilot = home.join(".copilot"); + write(&copilot.join("copilot-instructions.md"), "# Rules")?; + skill(&copilot.join("skills"), "pdf")?; + write(&copilot.join("agents/reviewer.agent.md"), AGENT)?; + write( + &copilot.join("mcp-config.json"), + r#"{"mcpServers": { + "github": {"type": "local", "command": "npx", "args": ["-y", "gh-mcp"], "tools": ["*"]}, + "docs": {"type": "http", "url": "https://docs.example/mcp", "tools": ["*"]} + }}"#, + )?; + write( + &copilot.join("hooks/skillpouch.json"), + r#"{"version": 1, "hooks": {"PostToolUse": [ + {"type": "command", "command": "prettier --write .", "matcher": "Edit", "timeoutSec": 30} + ]}}"#, + )?; + + let cursor = home.join(".cursor"); + skill(&cursor.join("skills"), "pdf")?; + write(&cursor.join("commands/ship.md"), "Ship it\n")?; + write( + &cursor.join("mcp.json"), + r#"{"mcpServers": {"github": {"command": "npx", "args": ["-y", "gh-mcp"]}, + "docs": {"url": "https://docs.example/mcp"}}}"#, + )?; + write( + &cursor.join("hooks.json"), + r#"{"version": 1, "hooks": {"afterFileEdit": [{"command": "ignored"}], + "postToolUse": [{"command": "prettier --write .", "matcher": "Edit"}]}}"#, + )?; + + let windsurf = home.join(".codeium/windsurf"); + write(&windsurf.join("memories/global_rules.md"), "# Rules")?; + write( + &windsurf.join("global_workflows/ship.md"), + "---\ndescription: Ship it\n---\nShip\n", + )?; + write( + &windsurf.join("mcp_config.json"), + r#"{"mcpServers": {"docs": {"serverUrl": "https://docs.example/mcp"}, + "github": {"command": "npx", "args": ["-y", "gh-mcp"]}}}"#, + )?; + write( + &windsurf.join("hooks.json"), + r#"{"hooks": {"post_write_code": [{"command": "prettier --write ."}]}}"#, + )?; + + let qwen = home.join(".qwen"); + write(&qwen.join("QWEN.md"), "# Rules")?; + write(&qwen.join("agents/reviewer.md"), AGENT)?; + write(&qwen.join("commands/ship.toml"), TOML_COMMAND)?; + write( + &qwen.join("settings.json"), + r#"{"mcpServers": {"github": {"command": "npx", "args": ["-y", "gh-mcp"]}, + "docs": {"httpUrl": "https://docs.example/mcp"}}, + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "say done"}]}]}}"#, + )?; + + let factory = home.join(".factory"); + skill(&factory.join("skills"), "pdf")?; + write( + &factory.join("droids/reviewer.md"), + "---\nname: reviewer\ndescription: Reviews code\nmodel: inherit\n---\nReview it.\n", + )?; + write(&factory.join("commands/ship.md"), COMMAND)?; + write( + &factory.join("mcp.json"), + r#"{"mcpServers": {"github": {"type": "stdio", "command": "npx", "args": ["-y", "gh-mcp"]}, + "docs": {"type": "http", "url": "https://docs.example/mcp"}}}"#, + )?; + write( + &factory.join("settings.json"), + r#"{"model": "x", "enabledPlugins": {"lint@acme": true}, + "hooks": {"Stop": [{"hooks": [{"type": "command", "command": "say done"}]}]}}"#, + )?; + + let crush = home.join(".config/crush"); + write(&crush.join("CRUSH.md"), "# Rules")?; + skill(&crush.join("skills"), "pdf")?; + Ok(()) +} + +const OPENCODE_2: &str = r#"{ + "$schema": "https://opencode.ai/config.json", + // mine + "mcp": { + "servers": { + "github": { + "type": "local", + "command": ["npx", "-y", "gh-mcp"], + "environment": {"TOKEN": "t"}, + }, + "docs": {"type": "remote", "url": "https://docs.example/mcp", "oauth": false}, + "off": {"type": "local", "command": ["off-mcp"], "disabled": true}, + }, + }, + "plugins": [ + "opencode-wakatime", + "@me/tool@1.2", + {"package": "opencode-notify", "options": {"sound": true}}, + ], +}"#; + +const OPENCODE_1: &str = r#"{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": {"type": "local", "command": ["npx", "-y", "gh-mcp"], "environment": {"TOKEN": "t"}, "enabled": true}, + "docs": {"type": "remote", "url": "https://docs.example/mcp"}, + }, + "plugin": ["opencode-wakatime", ["opencode-notify", {"sound": true}]], +}"#; + +fn places(home: &Path) -> Places { + Places::plain(home, &home.join(".skillpouch")) +} + +/// What a sync brings into the pouch from each assistant: `kind/name`. +fn found(home: &Path) -> BTreeMap<&'static str, Vec> { + discover(&places(home)) + .iter() + .map(|harness| { + let mut items: Vec = adopt::found_in(harness) + .iter() + .map(|found| format!("{}/{}", found.item.kind, found.item.name)) + .collect(); + items.sort(); + (harness.id, items) + }) + .collect() +} + +fn server(home: &Path, id: &str, name: &str) -> Value { + discover(&places(home)) + .iter() + .filter(|harness| harness.id == id) + .flat_map(adopt::found_in) + .find(|found| found.item.kind == "mcp_server" && found.item.name == name) + .and_then(|found| serde_json::from_slice(&found.item.files["server.json"].bytes).ok()) + .unwrap_or_default() +} + +fn items(list: &[&str]) -> Vec { + list.iter().map(|item| item.to_string()).collect() +} + +#[test] +fn every_assistant_is_read_whole() -> Res { + let dir = tempfile::tempdir()?; + home(dir.path(), OPENCODE_2)?; + let found = found(dir.path()); + let everything = |extra: &[&str]| { + let mut all = items(&[ + "agent/reviewer", + "command/ship", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf", + ]); + all.extend(items(extra)); + all.sort(); + all + }; + assert_eq!(found["claude-code"], everything(&[])); + assert_eq!( + found["codex"], + items(&[ + "command/ship", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/shared", + ]) + ); + // The disabled server is still the user's: it comes along. + assert_eq!(found["opencode"], everything(&["mcp_server/off"])); + assert_eq!(found["gemini-cli"], everything(&["mcp_server/events"])); + assert_eq!( + found["antigravity"], + items(&[ + "agent/reviewer", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/slides", + ]) + ); + assert_eq!( + found["copilot"], + items(&[ + "agent/reviewer", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf", + ]) + ); + assert_eq!( + found["cursor"], + items(&[ + "command/ship", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf" + ]) + ); + assert_eq!( + found["windsurf"], + items(&[ + "command/ship", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + ]) + ); + assert_eq!( + found["qwen-code"], + items(&[ + "agent/reviewer", + "command/ship", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + ]) + ); + assert_eq!( + found["factory"], + items(&[ + "agent/reviewer", + "command/ship", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf", + ]) + ); + assert_eq!( + found["crush"], + items(&["instructions/instructions", "skill/pdf"]) + ); + Ok(()) +} + +#[test] +fn every_mcp_dialect_becomes_the_same_server() -> Res { + let dir = tempfile::tempdir()?; + home(dir.path(), OPENCODE_2)?; + let home = dir.path(); + let gh = |env: Value| json!({ "name": "github", "description": "", "command": "npx", "args": ["-y", "gh-mcp"], "env": env }); + let docs = |kind: &str, url: &str| json!({ "name": "docs", "description": "", "type": kind, "url": url, "headers": {} }); + let token = json!({ "TOKEN": "t" }); + for id in ["claude-code", "codex", "opencode", "gemini-cli"] { + assert_eq!(server(home, id, "github"), gh(token.clone()), "{id}"); + } + for id in [ + "antigravity", + "copilot", + "cursor", + "windsurf", + "qwen-code", + "factory", + ] { + assert_eq!(server(home, id, "github"), gh(json!({})), "{id}"); + } + let mut sse = docs("sse", "https://docs.example/sse"); + sse["headers"] = json!({ "A": "b" }); + assert_eq!(server(home, "claude-code", "docs"), sse); + let mut events = docs("sse", "https://docs.example/sse"); + events["name"] = json!("events"); + assert_eq!(server(home, "gemini-cli", "events"), events); + for id in [ + "codex", + "opencode", + "gemini-cli", + "antigravity", + "copilot", + "cursor", + "windsurf", + "qwen-code", + "factory", + ] { + assert_eq!( + server(home, id, "docs"), + docs("http", "https://docs.example/mcp"), + "{id}" + ); + } + Ok(()) +} + +#[test] +fn opencode_1_configs_still_read() -> Res { + let dir = tempfile::tempdir()?; + home(dir.path(), OPENCODE_1)?; + assert_eq!( + found(dir.path())["opencode"], + items(&[ + "agent/reviewer", + "command/ship", + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf", + ]) + ); + assert_eq!( + server(dir.path(), "opencode", "github")["env"], + json!({ "TOKEN": "t" }) + ); + let target = targets(&places(dir.path())) + .into_iter() + .find(|target| target.id == "opencode") + .ok_or("no opencode")?; + let (path, format) = target.plugins.ok_or("no plugins")?; + let npm: Vec<_> = plugins::added(&path, format, None, &BTreeMap::new()) + .into_iter() + .filter_map(|plugin| plugin.npm) + .collect(); + assert_eq!(npm, ["opencode-notify", "opencode-wakatime"]); + Ok(()) +} + +#[test] +fn every_hooks_dialect_is_brought_in() -> Res { + let dir = tempfile::tempdir()?; + home(dir.path(), OPENCODE_2)?; + let mut found: BTreeMap<&str, Vec<(String, String, String)>> = BTreeMap::new(); + for target in targets(&places(dir.path())) { + let Some((path, format)) = &target.hooks else { + continue; + }; + let added = hooks::changes(path, *format, None).added; + found.insert( + target.id, + added + .into_iter() + .map(|added| (added.hook.event, added.hook.matcher, added.hook.command)) + .collect(), + ); + } + let hook = |event: &str, matcher: &str, command: &str| { + vec![(event.into(), matcher.into(), command.into())] + }; + let format = hook("PostToolUse", "Edit|Write", "prettier --write ."); + let done = hook("Stop", "", "say done"); + assert_eq!(found["claude-code"], format); + assert_eq!(found["codex"], done); + assert_eq!( + found["gemini-cli"], + hook("PostToolUse", "write_file", "prettier --write .") + ); + assert_eq!( + found["copilot"], + hook("PostToolUse", "Edit", "prettier --write .") + ); + // `afterFileEdit` has no Claude Code event: it stays Cursor's. + assert_eq!( + found["cursor"], + hook("PostToolUse", "Edit", "prettier --write .") + ); + assert_eq!(found["windsurf"], format); + assert_eq!(found["qwen-code"], done); + assert_eq!(found["factory"], done); + // OpenCode, Antigravity and Crush have no hooks file. + let ids: BTreeSet<_> = found.keys().copied().collect(); + assert_eq!( + ids, + BTreeSet::from([ + "claude-code", + "codex", + "copilot", + "cursor", + "factory", + "gemini-cli", + "qwen-code", + "windsurf", + ]) + ); + Ok(()) +} + +#[test] +fn every_plugin_list_is_brought_in() -> Res { + let dir = tempfile::tempdir()?; + home(dir.path(), OPENCODE_2)?; + let mut found: BTreeMap<&str, Vec> = BTreeMap::new(); + for target in targets(&places(dir.path())) { + if let Some((path, format)) = &target.plugins { + found.insert( + target.id, + plugins::added(path, *format, None, &BTreeMap::new()), + ); + } + } + assert_eq!(found.len(), 3, "{found:?}"); + let claude = &found["claude-code"]; + assert_eq!(claude.len(), 1); + assert_eq!(claude[0].source.as_deref(), Some("review@acme")); + assert_eq!( + claude[0].marketplace, + Some(json!({"source": "github", "repo": "acme/plugins"})) + ); + let factory: Vec<_> = found["factory"].iter().map(|p| p.source.clone()).collect(); + assert_eq!(factory, [Some("lint@acme".to_string())]); + let opencode: Vec<_> = found["opencode"] + .iter() + .map(|p| (p.name.as_str(), p.npm.as_deref().unwrap_or_default())) + .collect(); + assert_eq!( + opencode, + [ + ("tool", "@me/tool@1.2"), + ("opencode-notify", "opencode-notify"), + ("opencode-wakatime", "opencode-wakatime"), + ] + ); + Ok(()) +}