diff --git a/crates/skillpouch/src/commands/settings.rs b/crates/skillpouch/src/commands/settings.rs index f7df319..5098af3 100644 --- a/crates/skillpouch/src/commands/settings.rs +++ b/crates/skillpouch/src/commands/settings.rs @@ -39,7 +39,7 @@ pub fn run(ctx: &Context, action: SettingsAction) -> Result { .iter() .flat_map(|item| item.entries().into_keys()) .chain(entries.iter().flat_map(|item| { - sp_sync::settings::local_entries(target.id, &target.root, item) + sp_sync::settings::local_entries(target.id, &target.settings, item) })) .chain( baseline @@ -51,7 +51,7 @@ pub fn run(ctx: &Context, action: SettingsAction) -> Result { let status = if baseline.is_some_and(|state| state.excluded.contains(&key)) { "excluded on this computer" } else if entries.as_ref().is_some_and(|setting| { - sp_sync::settings::translated_on(target.id, &target.root, setting, &key) + sp_sync::settings::translated_on(target.id, &target.settings, setting, &key) }) { "translated" } else if baseline.is_some_and(|state| state.baseline.contains_key(&key)) { @@ -80,7 +80,7 @@ pub fn run(ctx: &Context, action: SettingsAction) -> Result { .iter() .find(|target| target.id == harness) .is_some_and(|target| { - sp_sync::settings::local_entries(&harness, &target.root, item) + sp_sync::settings::local_entries(&harness, &target.settings, item) .contains(&entry) }) }) { @@ -89,7 +89,7 @@ pub fn run(ctx: &Context, action: SettingsAction) -> Result { let root = targets .iter() .find(|target| target.id == harness) - .map(|target| target.root.clone()) + .map(|target| target.settings.clone()) .ok_or_else(|| anyhow::anyhow!("assistant is not installed"))?; let device = state.harnesses.entry(harness.clone()).or_default(); if exclude { diff --git a/crates/skillpouch/src/daemon.rs b/crates/skillpouch/src/daemon.rs index dea9b25..804bd7a 100644 --- a/crates/skillpouch/src/daemon.rs +++ b/crates/skillpouch/src/daemon.rs @@ -794,9 +794,18 @@ mod service { use std::path::Path; use std::process::{Command, Stdio}; - /// Settings passed on so the service uses the same folder. The server - /// isn't: the service syncs with whichever one this computer logged in to. - const PASSED_ON: [&str; 2] = ["HOME", "SKILLPOUCH_HOME"]; + /// Settings passed on so the service uses the same folders, its own and + /// the assistants' (`sp_harness::Places::of`). The server isn't: the + /// service syncs with whichever one this computer logged in to. + pub(super) const PASSED_ON: [&str; 7] = [ + "HOME", + "SKILLPOUCH_HOME", + "CLAUDE_CONFIG_DIR", + "XDG_CONFIG_HOME", + "CODEX_HOME", + "COPILOT_HOME", + "CURSOR_CONFIG_DIR", + ]; fn quiet(command: &mut Command) -> bool { command @@ -972,6 +981,19 @@ mod service { mod tests { use super::*; + #[test] + fn the_service_finds_the_assistants_where_this_shell_does() { + for name in [ + "CLAUDE_CONFIG_DIR", + "XDG_CONFIG_HOME", + "CODEX_HOME", + "COPILOT_HOME", + "CURSOR_CONFIG_DIR", + ] { + assert!(service::PASSED_ON.contains(&name), "{name}"); + } + } + #[test] fn the_windows_login_command_quotes_its_paths() { let command = service::login_command( diff --git a/crates/skillpouch/src/sync.rs b/crates/skillpouch/src/sync.rs index d28ef5c..4c3025a 100644 --- a/crates/skillpouch/src/sync.rs +++ b/crates/skillpouch/src/sync.rs @@ -853,10 +853,9 @@ fn bring_in_new( // again on every sync: only names the pouch doesn't have come in. let read_only = sp_harness::git_checkout(&harness.root).is_some(); found.extend(adopt::found_in(harness).into_iter().filter(|f| { - let ours = f - .origin - .as_ref() - .is_some_and(|origin| projection.copies.contains_key(origin)); + let ours = f.origin.as_ref().is_some_and(|origin| { + projection.copies.contains_key(origin) || projection.plugin_parts.contains(origin) + }); let key = item_key(&f.item); let known = read_only && (existing.contains_key(&key) || projection.dismissed.contains(&key)); diff --git a/crates/sp-harness/src/lib.rs b/crates/sp-harness/src/lib.rs index 49d534e..dc854cc 100644 --- a/crates/sp-harness/src/lib.rs +++ b/crates/sp-harness/src/lib.rs @@ -35,7 +35,8 @@ pub const POUCH_KINDS: [(&str, &str); 8] = [ ]; /// User preferences only; integrations (MCP, hooks, plugins and files) have -/// separate item kinds. Paths are relative to the assistant's root. +/// separate item kinds. Paths are relative to the assistant's settings +/// folder (`Target::settings`). pub struct SettingsFile { pub file: &'static str, pub keys: &'static [&'static str], @@ -233,6 +234,57 @@ pub fn settings(id: &str) -> &'static [SettingsFile] { ], maps: &[], }], + // The CLI's `cli-config.json`; not `version`, the network or what it + // counts for itself. The editor's own settings are VS Code's. + "cursor" => &[SettingsFile { + file: "cli-config.json", + keys: &[ + "model", + "maxMode", + "approvalMode", + "autoAcceptWebSearch", + "editor", + "display", + "notifications", + "hints", + "modelSlashCommands", + "steering", + "rewind", + "suggestNextPrompt", + "exploreSubagentModel", + "permissions", + "sandbox", + "attribution", + ], + maps: &[], + }], + // The CLI's `settings.json`; not `trustedWorkspaces` (this computer's + // folders) or `useG1Credits` (spending). + "antigravity" => &[SettingsFile { + file: "settings.json", + keys: &[ + "colorScheme", + "altScreenMode", + "toolPermission", + "artifactReviewPolicy", + "agentMode", + "notifications", + "showTips", + "showFeedbackSurvey", + "editor", + "editorMode", + "vimInsertFirst", + "allowNonWorkspaceAccess", + "enableTerminalSandbox", + "enableTelemetry", + "verbosity", + "runningLightSpeed", + "statusLine", + "title", + "permissions", + ], + maps: &[], + }], _ => &[], } } @@ -285,7 +337,7 @@ mod settings_tests { use super::*; #[test] - fn settings_are_only_the_eight_global_assistants() { + fn settings_are_every_assistant_but_windsurf() { for id in [ "opencode", "claude-code", @@ -295,11 +347,22 @@ mod settings_tests { "copilot", "crush", "factory", + "cursor", + "antigravity", ] { assert!(!settings(id).is_empty(), "{id}"); } - for id in ["cursor", "windsurf", "antigravity"] { - assert!(settings(id).is_empty(), "{id}"); + // Its preferences are a binary file (`~/.codeium/user_settings.pb`). + assert!(settings("windsurf").is_empty()); + assert_eq!(settings("cursor")[0].file, "cli-config.json"); + for private in ["version", "network", "hasChangedDefaultModel"] { + assert!(!settings("cursor")[0].keys.contains(&private), "{private}"); + } + for private in ["trustedWorkspaces", "useG1Credits"] { + assert!( + !settings("antigravity")[0].keys.contains(&private), + "{private}" + ); } assert!( settings("gemini-cli") @@ -444,6 +507,11 @@ pub struct Places { pub antigravity: PathBuf, pub copilot: PathBuf, pub cursor: PathBuf, + /// Where Cursor's CLI keeps `cli-config.json`: `~/.cursor`, unless + /// `CURSOR_CONFIG_DIR` or (on Linux) `XDG_CONFIG_HOME` moves it. + pub cursor_config: PathBuf, + /// `~/.gemini/antigravity-cli`: the Antigravity CLI's own settings. + pub antigravity_cli: PathBuf, pub windsurf: PathBuf, pub qwen: PathBuf, pub factory: PathBuf, @@ -467,6 +535,8 @@ impl Places { antigravity: home.join(".gemini/config"), copilot: home.join(".copilot"), cursor: home.join(".cursor"), + cursor_config: home.join(".cursor"), + antigravity_cli: home.join(".gemini/antigravity-cli"), windsurf: home.join(".codeium/windsurf"), qwen: home.join(".qwen"), factory: home.join(".factory"), @@ -478,7 +548,7 @@ impl Places { /// The places under `home`, moved by the variables each assistant /// honours (`CLAUDE_CONFIG_DIR`, `XDG_CONFIG_HOME`, `CODEX_HOME`, - /// `COPILOT_HOME`). + /// `COPILOT_HOME`, `CURSOR_CONFIG_DIR`). pub fn of(home: &Path, skillpouch: &Path) -> Self { let var = |name: &str| { std::env::var_os(name) @@ -493,6 +563,12 @@ impl Places { if let Some(config) = var("XDG_CONFIG_HOME") { places.opencode = config.join("opencode"); places.crush = config.join("crush"); + if cfg!(target_os = "linux") { + places.cursor_config = config.join("cursor"); + } + } + if let Some(dir) = var("CURSOR_CONFIG_DIR") { + places.cursor_config = dir; } if let Some(dir) = var("CODEX_HOME") { places.codex = dir; @@ -520,13 +596,15 @@ pub enum McpFormat { Copilot, /// `mcpServers`, `serverUrl` for remote servers (Windsurf, Antigravity). Windsurf, + /// Claude's entries under `mcp` in `crush.json` (Crush). + Crush, } impl McpFormat { /// The key holding the servers. pub fn key(self) -> &'static str { match self { - Self::OpenCode => "mcp", + Self::OpenCode | Self::Crush => "mcp", Self::Codex => "mcp_servers", _ => "mcpServers", } @@ -617,8 +695,12 @@ pub enum HookFormat { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginFormat { /// `enabledPlugins` and `extraKnownMarketplaces` in `settings.json` - /// (Claude Code, Factory). + /// (Claude Code). Claude, + /// The same keys in Factory's `settings.json`. Droid only loads plugins + /// it installed itself, so plugin folders are unpacked into its folders + /// instead. + Factory, /// npm packages in the `plugins` list of `opencode.json` (OpenCode; /// `plugin` in OpenCode 1). OpenCode, @@ -801,7 +883,8 @@ fn layouts(places: &Places) -> Vec { id: "cursor", name: "Cursor", root: places.cursor.clone(), - markers: vec![], + // The CLI's folder, when a variable moved it. + markers: vec![places.cursor_config.clone()], instructions: None, skills: vec![places.cursor.join("skills")], agents: &[], @@ -834,7 +917,8 @@ fn layouts(places: &Places) -> Vec { root: places.qwen.clone(), markers: vec![], instructions: Some(places.qwen.join("QWEN.md")), - skills: vec![], + // Qwen Code reads both; the shared one is Codex's too. + skills: vec![shared_skills.clone(), places.qwen.join("skills")], agents: &["agents"], commands: (&["commands"], CommandFormat::Toml), agent_format: AgentFormat::Gemini, @@ -861,7 +945,7 @@ fn layouts(places: &Places) -> Vec { }), plugins: Some(( Some(places.factory.join("settings.json")), - PluginFormat::Claude, + PluginFormat::Factory, )), }, Layout { @@ -874,7 +958,7 @@ fn layouts(places: &Places) -> Vec { agents: &[], commands: (&[], CommandFormat::Markdown), agent_format: AgentFormat::Claude, - mcp: None, + mcp: Some((vec![places.crush.join("crush.json")], McpFormat::Crush)), hooks: None, plugins: None, }, @@ -897,6 +981,9 @@ pub struct Target { pub id: &'static str, pub name: &'static str, pub root: PathBuf, + /// The folder its settings files are in: `root`, except for Cursor's + /// CLI and Antigravity's. + pub settings: PathBuf, /// The global instructions file (`CLAUDE.md`). pub instructions: Option, /// One folder per skill. @@ -956,6 +1043,11 @@ pub fn targets(places: &Places) -> Vec { hooks: layout.hooks, plugins, read_only, + settings: match layout.id { + "cursor" => places.cursor_config.clone(), + "antigravity" => places.antigravity_cli.clone(), + _ => layout.root.clone(), + }, root: layout.root, } }) diff --git a/crates/sp-sync/src/adopt.rs b/crates/sp-sync/src/adopt.rs index c7dee2e..219798d 100644 --- a/crates/sp-sync/src/adopt.rs +++ b/crates/sp-sync/src/adopt.rs @@ -27,6 +27,7 @@ pub fn format_of(harness_id: &str) -> McpFormat { "gemini-cli" | "qwen-code" => McpFormat::Gemini, "copilot" => McpFormat::Copilot, "windsurf" | "antigravity" => McpFormat::Windsurf, + "crush" => McpFormat::Crush, _ => McpFormat::Claude, } } diff --git a/crates/sp-sync/src/bundle.rs b/crates/sp-sync/src/bundle.rs new file mode 100644 index 0000000..aee4ac0 --- /dev/null +++ b/crates/sp-sync/src/bundle.rs @@ -0,0 +1,383 @@ +//! A whole plugin folder in the pouch, taken apart for the assistants that +//! can't install Claude Code plugins. Each gets the plugin's skills, agents, +//! commands, hooks and MCP servers the way it gets the pouch's own, so one +//! plugin works in every assistant: +//! +//! ```text +//! plugins/ponytail/.claude-plugin/plugin.json { "hooks": "./hooks/x.json" } +//! plugins/ponytail/skills/lazy/SKILL.md → a skill +//! plugins/ponytail/agents/reviewer.md → an agent +//! plugins/ponytail/commands/git/commit.md → the command `git/commit` +//! plugins/ponytail/hooks/hooks.json, .mcp.json → hooks, MCP servers +//! ``` +//! +//! The folders and files are Claude Code's defaults; paths in `plugin.json` +//! add to them. Hooks and local MCP servers get `CLAUDE_PLUGIN_ROOT`, the +//! plugin's folder in the pouch, since their scripts read it: servers in +//! their environment, hooks from an `export` in front of the command (on +//! Windows, where there's no shell to rely on, written into the command). + +use crate::hooks::{self, Hook}; +use crate::item::LocalItem; +use crate::mcp::{self, Server}; +use crate::plugins::{self, Kind}; +use serde_json::{Value, json}; +use sp_harness::{HookFormat, McpFormat}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +const MANIFEST: &str = ".claude-plugin/plugin.json"; +/// What a plugin's scripts read to find its folder. +pub const ROOT_VARIABLE: &str = "CLAUDE_PLUGIN_ROOT"; + +/// One plugin's parts, with absolute paths into its folder. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Unpacked { + /// The plugin's own name. + pub plugin: String, + /// Name → the skill's folder. + pub skills: BTreeMap, + /// Name → the agent's Markdown file. + pub agents: BTreeMap, + /// Name (`git/commit`) → the command's Markdown file. + pub commands: BTreeMap, + /// Named `::`, which no pouch item can be called. + pub hooks: Vec, + pub servers: BTreeMap, +} + +/// The parts of plugin folder `item`, which lives at `root`; `None` for a +/// plugin reference. +pub fn unpack(item: &LocalItem, root: &Path) -> Option { + let Kind::Folder { plugin, .. } = plugins::of(item)? else { + return None; + }; + let manifest: Value = item + .files + .get(MANIFEST) + .and_then(|file| serde_json::from_slice(&file.bytes).ok()) + .unwrap_or_default(); + let mut unpacked = Unpacked { + plugin: plugin.clone(), + ..Unpacked::default() + }; + let paths = |key: &str, default: &str| { + let mut found = vec![default.to_string()]; + found.extend(relative_paths(manifest.get(key))); + found + }; + + for dir in paths("skills", "skills") { + for path in item.files.keys() { + let Some(skill) = path.strip_suffix("/SKILL.md") else { + continue; + }; + let name = if skill == dir { + skill.rsplit('/').next() + } else { + skill + .strip_prefix(&format!("{dir}/")) + .filter(|name| !name.contains('/')) + }; + if let Some(name) = name { + unpacked + .skills + .entry(name.to_string()) + .or_insert_with(|| root.join(skill)); + } + } + } + for (key, nested, list) in [ + ("agents", false, &mut unpacked.agents), + ("commands", true, &mut unpacked.commands), + ] { + for place in paths(key, key) { + for (name, path) in documents(item, &place, nested) { + list.entry(name).or_insert_with(|| root.join(path)); + } + } + } + + let mut hook_configs: Vec = Vec::new(); + let from_file = |path: &str| { + item.files + .get(path) + .and_then(|file| serde_json::from_slice::(&file.bytes).ok()) + }; + hook_configs.extend(from_file("hooks/hooks.json")); + let mut servers = from_file(".mcp.json").into_iter().collect::>(); + for value in listed(manifest.get("hooks")) { + match value { + Value::String(path) => hook_configs.extend(from_file(clean(path))), + Value::Object(_) => hook_configs.push(value.clone()), + _ => {} + } + } + for value in listed(manifest.get("mcpServers")) { + match value { + Value::String(path) => servers.extend(from_file(clean(path))), + Value::Object(_) => servers.push(value.clone()), + _ => {} + } + } + + let mut count = 0; + for config in &hook_configs { + let events = config.get("hooks").unwrap_or(config); + let Some(events) = events.as_object() else { + continue; + }; + for (event, entries) in events { + for entry in entries.as_array().into_iter().flatten() { + for hook in hooks::from_native(HookFormat::Claude, event, entry) { + count += 1; + let hook = Hook { + name: format!("{plugin}:{}:{count}", event.to_lowercase()), + command: with_root(&hook.command, root), + ..hook + }; + if !unpacked.hooks.iter().any(|have| have.same_action(&hook)) { + unpacked.hooks.push(hook); + } + } + } + } + } + for config in &servers { + let entries = config.get("mcpServers").unwrap_or(config); + for (name, entry) in entries.as_object().into_iter().flatten() { + let Some(mut server) = mcp::from_native(McpFormat::Claude, name, entry) else { + continue; + }; + substitute(&mut server, root); + let local = server.get("command").is_some(); + if let Some(env) = server.get_mut("env").and_then(Value::as_object_mut) + && local + { + env.entry(ROOT_VARIABLE) + .or_insert_with(|| json!(display(root))); + } + unpacked.servers.entry(name.clone()).or_insert(server); + } + } + Some(unpacked) +} + +/// `"./x"` and `["./x", "./y"]` → `x`, `y`. +fn relative_paths(value: Option<&Value>) -> Vec { + listed(value) + .into_iter() + .filter_map(Value::as_str) + .map(|path| clean(path).to_string()) + .filter(|path| !path.is_empty() && !path.split('/').any(|part| part == "..")) + .collect() +} + +fn listed(value: Option<&Value>) -> Vec<&Value> { + match value { + Some(Value::Array(items)) => items.iter().collect(), + Some(value) => vec![value], + None => Vec::new(), + } +} + +fn clean(path: &str) -> &str { + path.trim_start_matches("./").trim_end_matches('/') +} + +/// The Markdown documents at `place` (a folder, or one `.md` file), by name. +fn documents<'a>(item: &'a LocalItem, place: &str, nested: bool) -> Vec<(String, &'a str)> { + if place.ends_with(".md") { + return item + .files + .get_key_value(place) + .map(|(path, _)| { + let name = path.rsplit('/').next().unwrap_or(path); + (name.trim_end_matches(".md").to_string(), path.as_str()) + }) + .into_iter() + .collect(); + } + item.files + .keys() + .filter_map(|path| { + let name = path + .strip_prefix(&format!("{place}/"))? + .strip_suffix(".md")?; + (nested || !name.contains('/')).then(|| (name.to_string(), path.as_str())) + }) + .collect() +} + +fn display(root: &Path) -> String { + root.to_string_lossy().into_owned() +} + +/// `${CLAUDE_PLUGIN_ROOT}` (and `$CLAUDE_PLUGIN_ROOT`) in every string. +fn substitute(value: &mut Value, root: &Path) { + match value { + Value::String(text) => *text = replace_root(text, root), + Value::Array(items) => items.iter_mut().for_each(|item| substitute(item, root)), + Value::Object(fields) => fields.values_mut().for_each(|item| substitute(item, root)), + _ => {} + } +} + +fn replace_root(text: &str, root: &Path) -> String { + let root = display(root); + text.replace(&format!("${{{ROOT_VARIABLE}}}"), &root) + .replace(&format!("${ROOT_VARIABLE}"), &root) +} + +/// A hook command that finds its plugin wherever SkillPouch keeps it. The +/// command itself is left as it is, so the shell expands the variable in it. +fn with_root(command: &str, root: &Path) -> String { + if cfg!(unix) { + let quoted = display(root).replace('\'', r"'\''"); + format!("export {ROOT_VARIABLE}='{quoted}'; {command}") + } else { + replace_root(command, root) + } +} + +/// A hook taken from a plugin, by its name. +pub fn is_part(hook_name: &str) -> bool { + hook_name.contains(':') +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::item::File; + use crate::{Result, SyncError}; + + fn not_a_folder() -> SyncError { + SyncError::Invalid("not a plugin folder".into()) + } + + fn plugin(files: &[(&str, &str)]) -> LocalItem { + LocalItem { + kind: "plugin".into(), + name: "ponytail".into(), + files: files + .iter() + .map(|(path, text)| (path.to_string(), File::new(*text))) + .collect(), + } + } + + #[test] + fn finds_every_part_in_the_default_places() -> Result<()> { + let item = plugin(&[ + (MANIFEST, r#"{ "name": "ponytail" }"#), + ("skills/lazy/SKILL.md", "---\nname: lazy\n---\n"), + ("skills/lazy/ref.md", "more"), + ("agents/reviewer.md", "---\nname: reviewer\n---\n"), + ("commands/git/commit.md", "Commit."), + ("commands/notes.toml", "for another assistant"), + ( + "hooks/hooks.json", + r#"{ "hooks": { "PostToolUse": [ { "matcher": "Edit", + "hooks": [ { "type": "command", "command": "fmt", "timeout": 5 } ] } ] } }"#, + ), + ( + ".mcp.json", + r#"{ "mcpServers": { "docs": { "type": "http", "url": "https://x/mcp" } } }"#, + ), + ]); + let root = Path::new("/p/ponytail"); + let unpacked = unpack(&item, root).ok_or_else(not_a_folder)?; + assert_eq!(unpacked.skills["lazy"], root.join("skills/lazy")); + assert_eq!(unpacked.agents["reviewer"], root.join("agents/reviewer.md")); + assert_eq!(unpacked.commands.keys().collect::>(), ["git/commit"]); + assert_eq!(unpacked.hooks.len(), 1); + assert_eq!(unpacked.hooks[0].name, "ponytail:posttooluse:1"); + assert_eq!(unpacked.hooks[0].matcher, "Edit"); + assert_eq!(unpacked.hooks[0].timeout, Some(5)); + assert_eq!(unpacked.servers["docs"]["url"], "https://x/mcp"); + Ok(()) + } + + #[test] + fn manifest_paths_add_to_the_defaults_and_the_root_is_filled_in() -> Result<()> { + let item = plugin(&[ + ( + MANIFEST, + r#"{ "name": "ponytail", "hooks": "./config/hooks.json", + "agents": ["./extra/auditor.md"], "skills": "./more", + "mcpServers": { "db": { "command": "${CLAUDE_PLUGIN_ROOT}/bin/db", + "args": ["--data", "${CLAUDE_PLUGIN_ROOT}/data"] } } }"#, + ), + ("more/fast/SKILL.md", "---\nname: fast\n---\n"), + ("extra/auditor.md", "---\nname: auditor\n---\n"), + ( + "config/hooks.json", + r#"{ "hooks": { "SessionStart": [ { "hooks": [ { "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/start.js\"" } ] } ] } }"#, + ), + ]); + let root = Path::new("/p/ponytail"); + let unpacked = unpack(&item, root).ok_or_else(not_a_folder)?; + assert_eq!(unpacked.skills["fast"], root.join("more/fast")); + assert_eq!(unpacked.agents["auditor"], root.join("extra/auditor.md")); + let command = &unpacked.hooks[0].command; + let expected = if cfg!(unix) { + "export CLAUDE_PLUGIN_ROOT='/p/ponytail'; node \"${CLAUDE_PLUGIN_ROOT}/hooks/start.js\"" + } else { + "node \"/p/ponytail/hooks/start.js\"" + }; + assert_eq!(command, expected); + let server = &unpacked.servers["db"]; + assert_eq!(server["command"], "/p/ponytail/bin/db"); + assert_eq!(server["args"][1], "/p/ponytail/data"); + assert_eq!(server["env"][ROOT_VARIABLE], "/p/ponytail"); + Ok(()) + } + + /// The root goes in as one quoted word; the command is never rewritten, + /// so nothing in the path can turn into shell syntax. + #[cfg(unix)] + #[test] + fn an_odd_folder_name_stays_one_word() { + let command = with_root("a && b", Path::new("/x/it's $(here)")); + assert_eq!( + command, + r#"export CLAUDE_PLUGIN_ROOT='/x/it'\''s $(here)'; a && b"# + ); + let output = std::process::Command::new("sh") + .arg("-c") + .arg(with_root( + "printf %s \"$CLAUDE_PLUGIN_ROOT\"", + Path::new("/x/it's $(here)"), + )) + .output() + .map(|out| String::from_utf8_lossy(&out.stdout).into_owned()) + .unwrap_or_default(); + assert_eq!(output, "/x/it's $(here)"); + } + + #[test] + fn a_plugin_reference_has_nothing_to_unpack() { + let item = LocalItem { + kind: "plugin".into(), + name: "wakatime".into(), + files: [( + "plugin.json".to_string(), + File::new(r#"{ "name": "wakatime", "npm": "opencode-wakatime" }"#), + )] + .into(), + }; + assert_eq!(unpack(&item, Path::new("/p")), None); + } + + #[test] + fn paths_out_of_the_plugin_are_ignored() -> Result<()> { + let item = plugin(&[ + (MANIFEST, r#"{ "name": "p", "agents": "../../elsewhere" }"#), + ("agents/a.md", "a"), + ]); + let unpacked = unpack(&item, Path::new("/p")).ok_or_else(not_a_folder)?; + assert_eq!(unpacked.agents.keys().collect::>(), ["a"]); + Ok(()) + } +} diff --git a/crates/sp-sync/src/hooks.rs b/crates/sp-sync/src/hooks.rs index 36adcc5..a0c0f8d 100644 --- a/crates/sp-sync/src/hooks.rs +++ b/crates/sp-sync/src/hooks.rs @@ -11,6 +11,7 @@ //! Each hook becomes one entry in the assistant's list for that event, so //! SkillPouch can tell its entries from the user's and change only its own. +use crate::bundle::{ROOT_VARIABLE, is_part}; use crate::error::io; use crate::{Result, SyncError}; use serde::{Deserialize, Serialize}; @@ -470,6 +471,26 @@ pub fn write( let mut record = HookFile::default(); for event in &events { let mut list = current.get(event).cloned().unwrap_or_default(); + // A plugin's hook edited in place goes back to what the plugin says: + // kept, the edit would run beside it and outlive the plugin. An edit + // keeps the `CLAUDE_PLUGIN_ROOT` it was given; the user's own hooks, + // new or edited, don't have it and stay. + let mut edited = previous + .into_iter() + .flat_map(|file| &file.written) + .filter(|w| &w.event == event && is_part(&w.name) && !list.contains(&w.entry)) + .count(); + let seen = previous.and_then(|file| file.seen.get(event)); + list.retain(|entry| { + let new = !seen.is_some_and(|seen| seen.contains(entry)); + let drop = edited > 0 + && new + && from_native(format, event, entry) + .iter() + .any(|hook| hook.command.contains(ROOT_VARIABLE)); + edited -= usize::from(drop); + !drop + }); for old in previous .into_iter() .flat_map(|file| &file.written) @@ -792,6 +813,75 @@ mod tests { Ok(()) } + fn plugin_hook() -> Hook { + Hook { + name: "ponytail:posttooluse:1".into(), + command: "export CLAUDE_PLUGIN_ROOT='/p'; node \"$CLAUDE_PLUGIN_ROOT/fmt.js\"".into(), + ..format_hook() + } + } + + fn commands(path: &Path) -> Vec { + read(path, HookFormat::Claude) + .get("PostToolUse") + .into_iter() + .flatten() + .flat_map(|entry| from_native(HookFormat::Claude, "PostToolUse", entry)) + .map(|hook| hook.command) + .collect() + } + + fn edit(path: &Path, from: &str, to: &str) -> Result<()> { + let text = fs::read_to_string(path).map_err(io(path))?; + fs::write(path, text.replace(from, to)).map_err(io(path)) + } + + #[test] + fn a_plugins_hook_edited_by_hand_goes_back_to_the_plugins() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("settings.json"); + let hooks = [format_hook(), plugin_hook()]; + let first = write(&path, HookFormat::Claude, &hooks, None)?.map_err(SyncError::Invalid)?; + // Both edited: the user's own hook and the plugin's. + edit(&path, "prettier --write .", "prettier --check .")?; + edit(&path, "fmt.js", "lint.js")?; + + write(&path, HookFormat::Claude, &hooks, Some(&first))?.map_err(SyncError::Invalid)?; + let now = commands(&path); + assert!(now.contains(&"prettier --check .".to_string()), "{now:?}"); + assert!( + !now.iter().any(|command| command.contains("lint.js")), + "{now:?}" + ); + let plugin = now + .iter() + .filter(|command| command.contains("fmt.js")) + .count(); + assert_eq!(plugin, 1, "{now:?}"); + Ok(()) + } + + #[test] + fn a_hook_added_while_a_plugins_went_stays() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("settings.json"); + let first = write(&path, HookFormat::Claude, &[plugin_hook()], None)? + .map_err(SyncError::Invalid)?; + edit( + &path, + &plugin_hook().command.replace('"', "\\\""), + "eslint --fix .", + )?; + assert_eq!(commands(&path), ["eslint --fix ."]); + + write(&path, HookFormat::Claude, &[plugin_hook()], Some(&first))? + .map_err(SyncError::Invalid)?; + let now = commands(&path); + assert!(now.contains(&"eslint --fix .".to_string()), "{now:?}"); + assert!(now.contains(&plugin_hook().command), "{now:?}"); + Ok(()) + } + #[test] fn cursor_files_get_a_version() -> Result<()> { let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; diff --git a/crates/sp-sync/src/lib.rs b/crates/sp-sync/src/lib.rs index bf5de5c..767c38d 100644 --- a/crates/sp-sync/src/lib.rs +++ b/crates/sp-sync/src/lib.rs @@ -8,6 +8,7 @@ //! MCP servers, hooks and plugins into their config files. pub mod adopt; +pub mod bundle; mod command; pub mod engine; mod error; diff --git a/crates/sp-sync/src/mcp.rs b/crates/sp-sync/src/mcp.rs index 651dcaf..6a72355 100644 --- a/crates/sp-sync/src/mcp.rs +++ b/crates/sp-sync/src/mcp.rs @@ -79,7 +79,7 @@ pub fn from_native(format: McpFormat, name: &str, entry: &Value) -> Option { + McpFormat::Claude | McpFormat::Copilot | McpFormat::Crush => { if let Some(url) = text(entry, "url") { let kind = if text(entry, "type").as_deref() == Some("sse") { "sse" @@ -102,7 +102,7 @@ pub fn to_native(format: McpFormat, server: &Server) -> Option { if let Some(url) = text(server, "url") { let sse = text(server, "type").as_deref() == Some("sse"); return Some(match format { - McpFormat::Claude => { + McpFormat::Claude | McpFormat::Crush => { json!({ "type": if sse { "sse" } else { "http" }, "url": url, "headers": headers }) } McpFormat::OpenCode => { @@ -127,7 +127,7 @@ pub fn to_native(format: McpFormat, server: &Server) -> Option { line.extend(args); json!({ "type": "local", "command": line, "environment": env, "enabled": true }) } - McpFormat::Claude => { + McpFormat::Claude | McpFormat::Crush => { json!({ "type": "stdio", "command": command, "args": args, "env": env }) } McpFormat::Copilot => { @@ -356,6 +356,7 @@ mod tests { McpFormat::OpenCode, McpFormat::Codex, McpFormat::Gemini, + McpFormat::Crush, ] { for server in [stdio(), remote.clone()] { let native = to_native(format, &server).unwrap_or_default(); @@ -374,6 +375,28 @@ mod tests { assert_eq!(to_native(McpFormat::Claude, &json!({ "name": "x" })), None); } + #[test] + fn crush_servers_go_under_mcp_beside_its_settings() -> Result<()> { + let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; + let path = dir.path().join("crush.json"); + fs::write( + &path, + r#"{"options":{"tui":{"compact_mode":true}},"mcp":{"mine":{"type":"http","url":"https://m"}}}"#, + ) + .map_err(io(&path))?; + let servers = BTreeMap::from([("gh".to_string(), stdio())]); + write(&path, McpFormat::Crush, &servers, &BTreeSet::new())?; + let text = fs::read_to_string(&path).map_err(io(&path))?; + let value: Value = + serde_json::from_str(&text).map_err(|e| SyncError::Invalid(e.to_string()))?; + assert_eq!(value["options"]["tui"]["compact_mode"], json!(true)); + assert_eq!(value["mcp"]["gh"]["type"], json!("stdio")); + assert_eq!(value["mcp"]["gh"]["command"], json!("npx")); + let names: Vec<_> = read(&path, McpFormat::Crush).into_keys().collect(); + assert_eq!(names, ["gh", "mine"]); + Ok(()) + } + #[test] fn json_keeps_other_settings_and_removes_only_its_own() -> Result<()> { let dir = tempfile::tempdir().map_err(io(Path::new(".")))?; diff --git a/crates/sp-sync/src/noticed.rs b/crates/sp-sync/src/noticed.rs index bec832f..b64d1cf 100644 --- a/crates/sp-sync/src/noticed.rs +++ b/crates/sp-sync/src/noticed.rs @@ -185,7 +185,7 @@ pub fn noticed( { let on = plugins::turned_on(path, *format); let ours = match format { - PluginFormat::Claude => &written.enabled, + PluginFormat::Claude | PluginFormat::Factory => &written.enabled, PluginFormat::OpenCode => &written.npm, }; for id in ours.iter().filter(|id| !on.contains(*id)) { @@ -217,12 +217,17 @@ pub fn noticed( } /// The pouch item a link or copy points into: `///…`. +/// A plugin's part (`/plugins//skills/x`) isn't an item: losing +/// it in one assistant doesn't take the whole plugin out of the pouch. fn item_of(base: &Path, to: &Path) -> Option<(String, String)> { let mut parts = to.strip_prefix(base).ok()?.components(); let folder = parts.next()?.as_os_str().to_str()?; let name = parts.next()?.as_os_str().to_str()?; - let kind = item::KINDS.iter().find(|kind| kind.1 == folder)?.0; - Some((kind.to_string(), name.to_string())) + let (kind, _, main) = item::KINDS.iter().find(|kind| kind.1 == folder)?; + match parts.next() { + Some(file) if file.as_os_str() != *main || parts.next().is_some() => None, + _ => Some((kind.to_string(), name.to_string())), + } } /// What is at `path` now, as the item's files: a whole folder for a skill, @@ -276,6 +281,20 @@ mod tests { use crate::project::{project, set_current}; use sp_harness::CommandFormat; + #[test] + fn a_plugins_parts_are_not_items() { + let base = Path::new("/sp/current"); + let of = |to: &str| item_of(base, &base.join(to)); + assert_eq!(of("skills/pdf"), Some(("skill".into(), "pdf".into()))); + assert_eq!( + of("agents/reviewer/AGENT.md"), + Some(("agent".into(), "reviewer".into())) + ); + assert_eq!(of("plugins/ponytail/skills/terse"), None); + assert_eq!(of("plugins/ponytail/agents/reviewer.md"), None); + assert_eq!(of("skills/pdf/notes/ref.md"), None); + } + struct Setup { _tmp: tempfile::TempDir, home: Home, @@ -319,6 +338,7 @@ mod tests { id: "claude-code", name: "Claude Code", root: claude.clone(), + settings: claude.clone(), instructions: Some(claude.join("CLAUDE.md")), skills: Some(claude.join("skills")), agents: Some((claude.join("agents"), sp_harness::AgentFormat::Claude)), diff --git a/crates/sp-sync/src/plugins.rs b/crates/sp-sync/src/plugins.rs index 7a20a63..468f5c2 100644 --- a/crates/sp-sync/src/plugins.rs +++ b/crates/sp-sync/src/plugins.rs @@ -8,8 +8,9 @@ //! ``` //! //! 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 `plugins` list +//! entries. Claude Code gets plugin folders through a `skillpouch` +//! marketplace in the pouch's `plugins/` folder; Droid only loads plugins +//! it installed itself, so Factory gets their parts instead. OpenCode gets its `plugins` list //! (`plugin` in OpenCode 1). Each assistant installs the plugins itself. use crate::error::io; @@ -126,7 +127,7 @@ fn on(path: &Path, format: PluginFormat) -> (BTreeSet, Value) { .and_then(|text| serde_json::from_str(&sp_harness::strip_jsonc(&text)).ok()) .unwrap_or_default(); let ids = match format { - PluginFormat::Claude => value + PluginFormat::Claude | PluginFormat::Factory => value .get("enabledPlugins") .and_then(Value::as_object) .into_iter() @@ -187,12 +188,12 @@ pub fn write( let previous = previous.cloned().unwrap_or_default(); let mut record = PluginFile::default(); let updated = match format { - PluginFormat::Claude => { + PluginFormat::Claude | PluginFormat::Factory => { let mut enabled = BTreeMap::new(); let mut markets = BTreeMap::new(); for item in items { match of(item) { - Some(Kind::Folder { plugin, .. }) => { + Some(Kind::Folder { plugin, .. }) if format == PluginFormat::Claude => { enabled.insert(format!("{plugin}@{MARKETPLACE}"), json!(true)); markets.insert( MARKETPLACE.to_string(), @@ -210,7 +211,7 @@ pub fn write( } enabled.insert(id, json!(true)); } - None => {} + Some(Kind::Folder { .. }) | None => {} } } record.enabled = enabled.keys().cloned().collect(); @@ -335,7 +336,7 @@ pub fn added( ids.into_iter() .filter(|id| !previous.is_some_and(|file| file.seen.contains(id))) .filter_map(|id| match format { - PluginFormat::Claude => { + PluginFormat::Claude | PluginFormat::Factory => { let (plugin, market) = split_id(&id)?; if market == MARKETPLACE { return None; diff --git a/crates/sp-sync/src/project.rs b/crates/sp-sync/src/project.rs index 45fc3a6..9d657e0 100644 --- a/crates/sp-sync/src/project.rs +++ b/crates/sp-sync/src/project.rs @@ -12,6 +12,7 @@ //! that the pouch no longer needs are removed. Assistants whose folder is //! part of a git checkout are only read. Nothing else is touched. +use crate::bundle::{self, Unpacked}; use crate::error::io; use crate::hooks::{self, HookFile}; use crate::item::{self, LocalItem}; @@ -54,6 +55,14 @@ pub struct Projection { /// Config file → the plugins SkillPouch turned on there. #[serde(default)] pub plugins: BTreeMap, + /// Links and copies of a plugin's parts: never items of their own, so + /// what's found there isn't brought into the pouch. + #[serde(default)] + pub plugin_parts: BTreeSet, + /// MCP servers that come from a plugin: an edit to one is undone, not + /// brought into the pouch. + #[serde(default)] + pub plugin_servers: BTreeSet, } /// How a copy is made from its pouch file. @@ -82,10 +91,15 @@ fn hash(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() } -/// The item a pouch file belongs to: `…/agents//AGENT.md` → ``. +/// The item a pouch file belongs to: `…/agents//AGENT.md` → ``; +/// a plugin's `…/agents/.md` → ``. fn item_name(from: &Path) -> String { - from.parent() - .and_then(Path::file_name) + let named = if from.file_name().is_some_and(|file| file == "AGENT.md") { + from.parent().and_then(Path::file_name) + } else { + from.file_stem() + }; + named .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_default() } @@ -158,6 +172,9 @@ impl Projection { }; let current = mcp::read(path, *format); for (name, ours) in written { + if self.plugin_servers.contains(name) { + continue; + } if let Some(now) = current.get(name) && !same_server(now, ours) { @@ -278,16 +295,13 @@ pub fn project( .values() .filter(|item| item.kind == "instructions") .min_by_key(|item| item.name != item::INSTRUCTIONS); - let file = |to: PathBuf| { - if file_links { - Place::Link { to, dir: false } - } else { - Place::Copy { - from: to, - render: Render::Plain, - } - } - }; + let file = |to: PathBuf| file_place(file_links, to); + let unpacked: Vec = items + .values() + .filter(|item| item.kind == "plugin") + .filter_map(|item| bundle::unpack(item, &base.join("plugins").join(&item.name))) + .collect(); + let mut part_places: Vec<(PathBuf, Place, &String)> = Vec::new(); let writable: Vec<&Target> = targets .iter() .filter(|target| match &target.read_only { @@ -316,40 +330,17 @@ pub fn project( }, ) }), - "agent" => target.agents.as_ref().map(|(dir, format)| { - let from = source(&base, item, "AGENT.md"); - let path = dir.join(format!("{}.md", item.name)); - match format { - AgentFormat::Claude => (path, file(from)), - format => ( - path, - Place::Copy { - from, - render: Render::Agent(*format), - }, - ), - } + "agent" => target.agents.as_ref().map(|agents| { + agent_place( + file_links, + agents, + &item.name, + source(&base, item, "AGENT.md"), + ) }), - "command" => target.commands.as_ref().map(|(dir, format)| { + "command" => target.commands.as_ref().map(|commands| { let from = source(&base, item, "COMMAND.md"); - let path = dir.join(format!("{}.{}", item.name, format.extension())); - match format { - CommandFormat::Markdown => (path, file(from)), - CommandFormat::Toml => ( - path, - Place::Copy { - from, - render: Render::TomlCommand, - }, - ), - format => ( - path, - Place::Copy { - from, - render: Render::Command(*format), - }, - ), - } + command_place(file_links, commands, &item.name, from) }), _ => None, }; @@ -357,6 +348,57 @@ pub fn project( wanted.insert(path, place); } } + if installs_plugins(target) { + continue; + } + for plugin in &unpacked { + let mut parts: Vec<(PathBuf, Place)> = Vec::new(); + if let Some(dir) = &target.skills { + parts.extend(plugin.skills.iter().map(|(name, to)| { + let place = Place::Link { + to: to.clone(), + dir: true, + }; + (dir.join(name), place) + })); + } + if let Some(agents) = &target.agents { + parts.extend( + plugin + .agents + .iter() + .map(|(name, from)| agent_place(file_links, agents, name, from.clone())), + ); + } + if let Some(commands) = &target.commands { + parts.extend( + plugin.commands.iter().map(|(name, from)| { + command_place(file_links, commands, name, from.clone()) + }), + ); + } + part_places.extend( + parts + .into_iter() + .map(|(path, place)| (path, place, &plugin.plugin)), + ); + } + } + // The pouch's own items win over a plugin's of the same name, and the + // first plugin over the next. + let mut plugin_parts = BTreeSet::new(); + for (path, place, plugin) in part_places { + match wanted.get(&path) { + None => { + plugin_parts.insert(path.clone()); + wanted.insert(path, place); + } + Some(there) if *there == place => {} + Some(_) => report.problems.push(format!( + "{}: left out of plugin {plugin}, something else of that name is already there.", + path.display() + )), + } } for item in items.values().filter(|item| item.kind == "mcp_server") { if let Some(file) = item.files.get("server.json") @@ -365,8 +407,27 @@ pub fn project( servers.insert(item.name.clone(), server); } } + let mut with_plugins = servers.clone(); + let mut plugin_servers = BTreeSet::new(); + for plugin in &unpacked { + for (name, server) in &plugin.servers { + if with_plugins.contains_key(name) { + report.problems.push(format!( + "MCP server {name}: left out of plugin {}, another server has that name.", + plugin.plugin + )); + continue; + } + with_plugins.insert(name.clone(), server.clone()); + plugin_servers.insert(name.clone()); + } + } - let mut next = Projection::default(); + let mut next = Projection { + plugin_parts, + plugin_servers, + ..Projection::default() + }; for (path, place) in &wanted { let placed = match place { Place::Link { to, dir } => place_link(home, path, to, *dir, backups).inspect(|_| { @@ -419,7 +480,12 @@ pub fn project( .get(path) .map(|written| written.keys().cloned().collect()) .unwrap_or_default(); - let written = mcp::write(path, *format, &servers, &managed)?; + let servers = if installs_plugins(target) { + &servers + } else { + &with_plugins + }; + let written = mcp::write(path, *format, servers, &managed)?; if let Some(problem) = written.skipped { report.problems.push(problem); if let Some(old) = previous.mcp.get(path) { @@ -447,11 +513,22 @@ pub fn project( }) }) .collect(); + let mut with_plugin_hooks = hooks_to_run.clone(); + for hook in unpacked.iter().flat_map(|plugin| &plugin.hooks) { + if !with_plugin_hooks.iter().any(|have| have.same_action(hook)) { + with_plugin_hooks.push(hook.clone()); + } + } for target in &writable { let Some((path, format)) = &target.hooks else { continue; }; - match hooks::write(path, *format, &hooks_to_run, previous.hooks.get(path))? { + let hooks_to_run = if installs_plugins(target) { + &hooks_to_run + } else { + &with_plugin_hooks + }; + match hooks::write(path, *format, hooks_to_run, previous.hooks.get(path))? { Ok(record) => { next.hooks.insert(path.clone(), record); } @@ -500,7 +577,59 @@ pub fn project( Ok((next, report)) } +/// Claude Code installs plugin folders itself; everyone else gets their +/// parts. +fn installs_plugins(target: &Target) -> bool { + matches!(target.plugins, Some((_, PluginFormat::Claude))) +} + +fn file_place(file_links: bool, to: PathBuf) -> Place { + if file_links { + Place::Link { to, dir: false } + } else { + Place::Copy { + from: to, + render: Render::Plain, + } + } +} + +fn agent_place( + file_links: bool, + (dir, format): &(PathBuf, AgentFormat), + name: &str, + from: PathBuf, +) -> (PathBuf, Place) { + let path = dir.join(format!("{name}.md")); + match format { + AgentFormat::Claude => (path, file_place(file_links, from)), + format => ( + path, + Place::Copy { + from, + render: Render::Agent(*format), + }, + ), + } +} + +fn command_place( + file_links: bool, + (dir, format): &(PathBuf, CommandFormat), + name: &str, + from: PathBuf, +) -> (PathBuf, Place) { + let path = dir.join(format!("{name}.{}", format.extension())); + let render = match format { + CommandFormat::Markdown => return (path, file_place(file_links, from)), + CommandFormat::Toml => Render::TomlCommand, + format => Render::Command(*format), + }; + (path, Place::Copy { from, render }) +} + /// What goes at one place in an assistant's folder. +#[derive(PartialEq)] enum Place { Link { to: PathBuf, dir: bool }, Copy { from: PathBuf, render: Render }, @@ -693,6 +822,7 @@ mod tests { id: "claude-code", name: "Claude Code", root: root.to_path_buf(), + settings: root.to_path_buf(), instructions: Some(root.join("CLAUDE.md")), skills: Some(root.join("skills")), agents: Some((root.join("agents"), sp_harness::AgentFormat::Claude)), @@ -743,6 +873,7 @@ mod tests { id: "gemini-cli", name: "Gemini CLI", root: gemini.clone(), + settings: gemini.clone(), instructions: None, skills: None, agents: None, @@ -786,6 +917,279 @@ mod tests { Ok(()) } + fn json_at(path: &Path) -> Result { + serde_json::from_str(&fs::read_to_string(path).map_err(io(path))?) + .map_err(|e| SyncError::Invalid(e.to_string())) + } + + #[cfg(unix)] + #[test] + fn a_plugin_folder_reaches_assistants_without_plugins_in_parts() -> Result<()> { + let tmp = tempfile::tempdir().map_err(io(Path::new(".")))?; + let home = Home::new(tmp.path().join(".skillpouch")); + let pouch = home.root().join("pouches/p1"); + let dir = crate::item::PouchDir::new(&pouch); + let claude = tmp.path().join(".claude"); + let opencode = tmp.path().join(".config/opencode"); + let gemini = tmp.path().join(".gemini"); + for folder in [&claude, &opencode, &gemini] { + fs::create_dir_all(folder).map_err(io(folder))?; + } + let items: BTreeMap<_, _> = [ + item( + "plugin", + "ponytail", + &[ + ( + ".claude-plugin/plugin.json", + r#"{"name":"ponytail","hooks":"./hooks/claude.json"}"#, + ), + ("skills/lazy/SKILL.md", "---\nname: lazy\n---\nfrom the plugin"), + ("skills/terse/SKILL.md", "---\nname: terse\n---\n"), + ( + "agents/reviewer.md", + "---\nname: reviewer\ndescription: Reviews\ntools: Read, Grep\n---\nReview.", + ), + ("commands/simplify.md", "---\ndescription: Simplify\n---\nSimplify $ARGUMENTS"), + ( + "hooks/claude.json", + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"node \"${CLAUDE_PLUGIN_ROOT}/hooks/start.js\"","timeout":5}]}]}}"#, + ), + ("hooks/start.js", "console.log('hi')"), + (".mcp.json", r#"{"mcpServers":{"docs":{"type":"http","url":"https://x/mcp"}}}"#), + ], + ), + item("skill", "lazy", &[("SKILL.md", "---\nname: lazy\n---\nthe pouch's own")]), + ] + .into_iter() + .collect(); + for item in items.values() { + dir.write_item(&item.kind, &item.name, &item.files)?; + } + set_current(&home, &pouch)?; + let settings = claude.join("settings.json"); + let mut claude_target = target(&claude, &tmp.path().join(".claude.json")); + claude_target.hooks = Some((settings.clone(), sp_harness::HookFormat::Claude)); + claude_target.plugins = Some((settings.clone(), PluginFormat::Claude)); + let opencode_json = opencode.join("opencode.json"); + let opencode_target = Target { + id: "opencode", + name: "OpenCode", + root: opencode.clone(), + settings: opencode.clone(), + instructions: None, + skills: Some(opencode.join("skills")), + agents: Some((opencode.join("agents"), AgentFormat::OpenCode)), + commands: Some((opencode.join("commands"), CommandFormat::OpenCode)), + mcp: Some((opencode_json.clone(), McpFormat::OpenCode)), + hooks: None, + plugins: Some((opencode_json.clone(), PluginFormat::OpenCode)), + read_only: None, + }; + let gemini_settings = gemini.join("settings.json"); + let gemini_target = Target { + id: "gemini-cli", + name: "Gemini CLI", + root: gemini.clone(), + settings: gemini.clone(), + instructions: None, + skills: None, + agents: None, + commands: Some((gemini.join("commands"), CommandFormat::Toml)), + mcp: Some((gemini_settings.clone(), McpFormat::Gemini)), + hooks: Some((gemini_settings.clone(), sp_harness::HookFormat::Gemini)), + plugins: None, + read_only: None, + }; + let targets = [claude_target, opencode_target, gemini_target]; + let backups = home.root().join("backups/t1"); + let (first, report) = project(&home, &items, &targets, &backups, &Projection::default())?; + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!( + report.problems[0].contains("skills/lazy: left out of plugin ponytail"), + "{:?}", + report.problems + ); + assert!(first.plugin_parts.contains(&opencode.join("skills/terse"))); + assert!( + first + .plugin_parts + .contains(&gemini.join("commands/simplify.toml")) + ); + assert!(!first.plugin_parts.contains(&opencode.join("skills/lazy"))); + assert_eq!(first.plugin_servers, BTreeSet::from(["docs".to_string()])); + + // Claude Code installs the plugin itself and gets none of its parts. + assert_eq!( + json_at(&settings)?["enabledPlugins"]["ponytail@skillpouch"], + true + ); + assert!(json_at(&settings)?.get("hooks").is_none()); + assert!(!claude.join("skills/terse").exists()); + assert!(!claude.join("agents/reviewer.md").exists()); + + // OpenCode gets every part in its own dialect. + let root = current(&home).join("plugins/ponytail"); + assert_eq!( + fs::read_link(opencode.join("skills/terse")).map_err(io(&opencode))?, + root.join("skills/terse") + ); + // A pouch skill of the same name wins. + assert_eq!( + fs::read_link(opencode.join("skills/lazy")).map_err(io(&opencode))?, + current(&home).join("skills/lazy") + ); + let agent = opencode.join("agents/reviewer.md"); + let agent = fs::read_to_string(&agent).map_err(io(&agent))?; + assert!(agent.contains("mode: subagent"), "{agent}"); + assert!(opencode.join("commands/simplify.md").exists()); + assert_eq!( + mcp::read(&opencode_json, McpFormat::OpenCode)["docs"]["url"], + "https://x/mcp" + ); + + // Gemini CLI: a TOML command, the MCP server and the hook with the + // plugin's folder filled in. + let toml = gemini.join("commands/simplify.toml"); + let toml = fs::read_to_string(&toml).map_err(io(&toml))?; + assert!(toml.contains("{{args}}"), "{toml}"); + let value = json_at(&gemini_settings)?; + assert_eq!(value["mcpServers"]["docs"]["httpUrl"], "https://x/mcp"); + let command = value["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap_or_default() + .to_string(); + assert_eq!( + command, + format!( + "export CLAUDE_PLUGIN_ROOT='{}'; node \"${{CLAUDE_PLUGIN_ROOT}}/hooks/start.js\"", + root.display() + ) + ); + + // A plugin's server edited by hand isn't the user's own server. + let text = fs::read_to_string(&gemini_settings).map_err(io(&gemini_settings))?; + fs::write( + &gemini_settings, + text.replace("https://x/mcp", "https://y/mcp"), + ) + .map_err(io(&gemini_settings))?; + assert!(first.edited_servers(&targets).is_empty()); + + // Taken out of the pouch: every part goes too. + let rest: BTreeMap<_, _> = items + .into_iter() + .filter(|(_, item)| item.kind != "plugin") + .collect(); + let (_, report) = project(&home, &rest, &targets, &backups, &first)?; + assert!(report.problems.is_empty(), "{:?}", report.problems); + assert!(fs::symlink_metadata(opencode.join("skills/terse")).is_err()); + assert!(!opencode.join("agents/reviewer.md").exists()); + assert!(!gemini.join("commands/simplify.toml").exists()); + assert!(mcp::read(&opencode_json, McpFormat::OpenCode).is_empty()); + let value = json_at(&gemini_settings)?; + assert!(value["mcpServers"].get("docs").is_none(), "{value}"); + assert!(value["hooks"].get("SessionStart").is_none(), "{value}"); + Ok(()) + } + + // Droid ignores marketplaces it didn't add itself (and rejects a + // `directory` source), so Factory gets a plugin folder's parts. + #[cfg(unix)] + #[test] + fn factory_gets_a_plugin_folders_parts_and_keeps_its_references() -> Result<()> { + let tmp = tempfile::tempdir().map_err(io(Path::new(".")))?; + let home = Home::new(tmp.path().join(".skillpouch")); + let pouch = home.root().join("pouches/p1"); + let dir = crate::item::PouchDir::new(&pouch); + let factory = tmp.path().join(".factory"); + fs::create_dir_all(&factory).map_err(io(&factory))?; + let items: BTreeMap<_, _> = [ + item( + "plugin", + "kit", + &[ + (".claude-plugin/plugin.json", r#"{"name":"kit"}"#), + ("skills/tidy/SKILL.md", "---\nname: tidy\n---\n"), + ( + "hooks/hooks.json", + r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"sh \"${CLAUDE_PLUGIN_ROOT}/mark.sh\""}]}]}}"#, + ), + (".mcp.json", r#"{"mcpServers":{"kit":{"command":"node","args":["${CLAUDE_PLUGIN_ROOT}/mcp.mjs"]}}}"#), + ], + ), + item( + "plugin", + "review", + &[( + "plugin.json", + r#"{"name":"review","source":"review@acme","marketplace":"acme/plugins"}"#, + )], + ), + ] + .into_iter() + .collect(); + for item in items.values() { + dir.write_item(&item.kind, &item.name, &item.files)?; + } + set_current(&home, &pouch)?; + let settings = factory.join("settings.json"); + let mcp_json = factory.join("mcp.json"); + let factory_target = Target { + id: "factory", + name: "Factory", + root: factory.clone(), + settings: factory.clone(), + instructions: None, + skills: Some(factory.join("skills")), + agents: None, + commands: None, + mcp: Some((mcp_json.clone(), McpFormat::Claude)), + hooks: Some((settings.clone(), sp_harness::HookFormat::Claude)), + plugins: Some((settings.clone(), PluginFormat::Factory)), + read_only: None, + }; + let backups = home.root().join("backups/t1"); + let (first, report) = project( + &home, + &items, + &[factory_target], + &backups, + &Projection::default(), + )?; + assert!(report.problems.is_empty(), "{:?}", report.problems); + + let value = json_at(&settings)?; + assert_eq!( + value["enabledPlugins"], + serde_json::json!({ "review@acme": true }) + ); + assert_eq!( + value["extraKnownMarketplaces"], + serde_json::json!({ "acme": { "source": { "source": "github", "repo": "acme/plugins" } } }) + ); + let root = current(&home).join("plugins/kit"); + assert_eq!( + fs::read_link(factory.join("skills/tidy")).map_err(io(&factory))?, + root.join("skills/tidy") + ); + let command = value["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap_or_default() + .to_string(); + assert!( + command.starts_with("export CLAUDE_PLUGIN_ROOT="), + "{command}" + ); + assert_eq!( + mcp::read(&mcp_json, McpFormat::Claude)["kit"]["args"][0], + root.join("mcp.mjs").to_string_lossy().as_ref() + ); + assert!(first.plugin_servers.contains("kit")); + assert!(!pouch.join("plugins/.claude-plugin").exists()); + Ok(()) + } + #[cfg(unix)] #[test] fn links_everything_backs_up_what_was_there_and_cleans_up() -> Result<()> { @@ -964,6 +1368,7 @@ mod tests { id: "gemini-cli", name: "Gemini CLI", root: gemini.clone(), + settings: gemini.clone(), instructions: Some(gemini.join("GEMINI.md")), skills: None, agents: None, @@ -977,6 +1382,7 @@ mod tests { id: "opencode", name: "OpenCode", root: tmp.path().join("oc"), + settings: tmp.path().join("oc"), instructions: Some(tmp.path().join("oc/AGENTS.md")), skills: None, agents: None, diff --git a/crates/sp-sync/src/release.rs b/crates/sp-sync/src/release.rs index 8e3a22e..88cf059 100644 --- a/crates/sp-sync/src/release.rs +++ b/crates/sp-sync/src/release.rs @@ -206,6 +206,7 @@ mod tests { id: "claude-code", name: "Claude Code", root: claude.clone(), + settings: claude.clone(), instructions: Some(claude.join("CLAUDE.md")), skills: Some(claude.join("skills")), agents: Some((claude.join("agents"), sp_harness::AgentFormat::Claude)), diff --git a/crates/sp-sync/src/settings.rs b/crates/sp-sync/src/settings.rs index a79e1f8..bd18543 100644 --- a/crates/sp-sync/src/settings.rs +++ b/crates/sp-sync/src/settings.rs @@ -372,7 +372,7 @@ pub fn files(target: &Target) -> Vec { } names .into_iter() - .map(|name| target.root.join(name)) + .map(|name| target.settings.join(name)) .collect() } @@ -718,6 +718,13 @@ fn merge( } fn apply(root: &Path, id: &str, entries: &Entries, backups: &Path) -> Result> { + if let Some(repo) = sp_harness::git_checkout(root) { + return Err(invalid(format!( + "{} is part of the git checkout {}, so SkillPouch only reads it", + root.display(), + repo.display() + ))); + } let mut backed_up = Vec::new(); let mut staged: BTreeMap = BTreeMap::new(); for (entry, value) in entries { @@ -823,8 +830,8 @@ pub fn capture(dir: &PouchDir, targets: &[Target], state: &mut DeviceState) -> R if sp_harness::settings(target.id).is_empty() || target.read_only.is_some() { continue; } - let format = (target.id == "opencode").then(|| local_format(&target.root)); - let (local, readable) = read_local(&target.root, target.id, format); + let format = (target.id == "opencode").then(|| local_format(&target.settings)); + let (local, readable) = read_local(&target.settings, target.id, format); let Some(previous) = items.get(&format!("setting/{}", target.id)) else { if state.deleted.contains(target.id) { continue; @@ -853,7 +860,7 @@ pub fn capture(dir: &PouchDir, targets: &[Target], state: &mut DeviceState) -> R }; let device = state.harnesses.entry(target.id.into()).or_default(); // Installed again between two syncs: start over, like a new computer. - if let (Some(before), Some(now)) = (device.folder_created, created(&target.root)) + if let (Some(before), Some(now)) = (device.folder_created, created(&target.settings)) && before != now { *device = HarnessState::default(); @@ -925,7 +932,10 @@ pub fn project( .harnesses .retain(|id, _| installed.contains(id.as_str())); for target in targets { - if sp_harness::settings(target.id).is_empty() || target.read_only.is_some() { + if sp_harness::settings(target.id).is_empty() + || target.read_only.is_some() + || sp_harness::git_checkout(&target.settings).is_some() + { continue; } let Some(item) = items.get(&format!("setting/{}", target.id)) else { @@ -933,8 +943,8 @@ pub fn project( }; let setting = Setting::from_item(item)?; state.deleted.remove(target.id); - let format = (target.id == "opencode").then(|| local_format(&target.root)); - let (local, readable) = read_local(&target.root, target.id, format); + let format = (target.id == "opencode").then(|| local_format(&target.settings)); + let (local, readable) = read_local(&target.settings, target.id, format); let state = state.harnesses.entry(target.id.into()).or_default(); let mut pouch = setting.entries(); if setting.format != format { @@ -960,8 +970,8 @@ pub fn project( state.baseline.insert(key.clone(), value.clone()); } } - backed_up.extend(apply(&target.root, target.id, &writes, backups)?); - state.folder_created = created(&target.root); + backed_up.extend(apply(&target.settings, target.id, &writes, backups)?); + state.folder_created = created(&target.settings); } Ok(backed_up) } @@ -1146,6 +1156,8 @@ mod tests { ("qwen-code", "channels"), ("crush", "recent_models"), ("factory", "enabledPlugins"), + ("cursor", "version"), + ("antigravity", "trustedWorkspaces"), ] { assert!( !sp_harness::settings(id) @@ -1154,7 +1166,6 @@ mod tests { "{id}" ); } - assert!(sp_harness::settings("cursor").is_empty()); assert!(sp_harness::settings("windsurf").is_empty()); } @@ -1347,6 +1358,7 @@ mod tests { id, name: id, root: PathBuf::from(root), + settings: PathBuf::from(root).join("settings"), instructions: None, skills: None, agents: None, @@ -1359,16 +1371,146 @@ mod tests { let opencode = files(&target("opencode", "/oc")); for name in ["opencode.json", "opencode.jsonc", "tui.json", "cli.json"] { assert!( - opencode.contains(&PathBuf::from("/oc").join(name)), + opencode.contains(&PathBuf::from("/oc/settings").join(name)), "{name}" ); } - assert!(files(&target("gemini-cli", "/g")).contains(&PathBuf::from("/g/.env"))); + assert!(files(&target("gemini-cli", "/g")).contains(&PathBuf::from("/g/settings/.env"))); assert_eq!( files(&target("copilot", "/c")), - [PathBuf::from("/c/settings.json")] + [PathBuf::from("/c/settings/settings.json")] + ); + assert_eq!( + files(&target("cursor", "/x")), + [PathBuf::from("/x/settings/cli-config.json")] + ); + assert!(files(&target("windsurf", "/w")).is_empty()); + } + + #[test] + fn cursor_and_antigravity_settings_travel_from_their_cli_folders() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let write = |path: PathBuf, text: &str| -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(io(parent))?; + } + fs::write(&path, text).map_err(io(&path)) + }; + let computer = |name: &str, color: &str, vim: bool| -> Result> { + let home = temp.path().join(name); + write( + home.join(".gemini/antigravity-cli/settings.json"), + &format!( + r#"{{"colorScheme": "{color}", "trustedWorkspaces": ["/home/{name}/code"]}}"# + ), + )?; + write( + home.join(".cursor/cli-config.json"), + &format!( + r#"{{"version": 1, "editor": {{"vimMode": {vim}}}, "permissions": {{"allow": [], "deny": []}}, "hasChangedDefaultModel": false}}"# + ), + )?; + fs::create_dir_all(home.join(".gemini/config")).map_err(io(&home))?; + Ok(sp_harness::targets(&sp_harness::Places::plain( + &home, + &home.join(".skillpouch"), + ))) + }; + let a = computer("a", "tokyo night", true)?; + let b = computer("b", "terminal", false)?; + let antigravity = a.iter().find(|target| target.id == "antigravity"); + assert_eq!( + antigravity.map(|target| target.settings.clone()), + Some(temp.path().join("a/.gemini/antigravity-cli")) + ); + + let dir = PouchDir::new(temp.path().join("pouch")); + capture(&dir, &a, &mut DeviceState::default())?; + let items = dir.read()?.0; + let entries = Setting::from_item(&items["setting/antigravity"])?.entries(); + assert_eq!(entries["settings.json:colorScheme"], json!("tokyo night")); + assert!(!entries.contains_key("settings.json:trustedWorkspaces")); + let entries = Setting::from_item(&items["setting/cursor"])?.entries(); + assert_eq!(entries["cli-config.json:editor"], json!({"vimMode": true})); + assert!(!entries.contains_key("cli-config.json:version")); + + project( + &dir, + &items, + &b, + &mut DeviceState::default(), + &temp.path().join("backups"), + )?; + let read = |path: PathBuf| -> Result { + let text = fs::read_to_string(&path).map_err(io(&path))?; + serde_json::from_str(&text).map_err(|e| invalid(e.to_string())) + }; + let settings = read(temp.path().join("b/.gemini/antigravity-cli/settings.json"))?; + assert_eq!(settings["colorScheme"], json!("tokyo night")); + assert_eq!(settings["trustedWorkspaces"], json!(["/home/b/code"])); + let config = read(temp.path().join("b/.cursor/cli-config.json"))?; + assert_eq!(config["editor"]["vimMode"], json!(true)); + assert_eq!(config["version"], json!(1)); + assert_eq!(config["hasChangedDefaultModel"], json!(false)); + Ok(()) + } + + #[test] + fn a_moved_cursor_config_is_found_and_left_alone_in_a_checkout() -> Result<()> { + let temp = tempfile::tempdir().map_err(io(Path::new("temp")))?; + let home = temp.path().join("home"); + let dotfiles = temp.path().join("dotfiles"); + let config = dotfiles.join("cursor"); + fs::create_dir_all(dotfiles.join(".git")).map_err(io(&dotfiles))?; + fs::create_dir_all(&config).map_err(io(&config))?; + let file = config.join("cli-config.json"); + let before = r#"{"version": 1, "editor": {"vimMode": false}}"#; + fs::write(&file, before).map_err(io(&file))?; + let mut places = sp_harness::Places::plain(&home, &home.join(".skillpouch")); + places.cursor_config = config.clone(); + let found = sp_harness::targets(&places); + let cursor = found.iter().find(|target| target.id == "cursor"); + assert_eq!(cursor.map(|target| target.settings.clone()), Some(config)); + // With the editor's folder outside the checkout, only the settings + // folder is in it. + fs::create_dir_all(home.join(".cursor")).map_err(io(&home))?; + let targets = sp_harness::targets(&places); + let cursor = targets.iter().find(|target| target.id == "cursor"); + assert!(cursor.is_some_and(|target| target.read_only.is_none())); + + let mut setting = Setting { + harness: "cursor".into(), + format: None, + files: BTreeMap::new(), + }; + setting.set("cli-config.json:editor", json!({"vimMode": true})); + let item = setting.item()?; + let dir = PouchDir::new(temp.path().join("pouch")); + dir.write_item("setting", "cursor", &item.files)?; + let backups = temp.path().join("backups"); + project( + &dir, + &dir.read()?.0, + &targets, + &mut DeviceState::default(), + &backups, + )?; + assert_eq!(fs::read_to_string(&file).map_err(io(&file))?, before); + let root = cursor + .map(|target| target.settings.clone()) + .unwrap_or_default(); + let mut state = HarnessState::default(); + let included = include( + &root, + "cursor", + "cli-config.json:editor", + &item, + &backups, + &mut state, ); - assert!(files(&target("cursor", "/x")).is_empty()); + assert!(included.is_err()); + assert_eq!(fs::read_to_string(&file).map_err(io(&file))?, before); + Ok(()) } #[test] @@ -1401,6 +1543,7 @@ mod tests { id: "claude-code", name: "Claude", root: root.clone(), + settings: root.clone(), instructions: None, skills: None, agents: None, @@ -1453,6 +1596,7 @@ mod tests { id: "claude-code", name: "Claude", root: root.clone(), + settings: root.clone(), instructions: None, skills: None, agents: None, @@ -1519,6 +1663,7 @@ mod tests { id: "gemini-cli", name: "Gemini CLI", root: root.clone(), + settings: root.clone(), instructions: None, skills: None, agents: None, diff --git a/crates/sp-sync/tests/harnesses.rs b/crates/sp-sync/tests/harnesses.rs index eca8b18..7a92555 100644 --- a/crates/sp-sync/tests/harnesses.rs +++ b/crates/sp-sync/tests/harnesses.rs @@ -153,6 +153,7 @@ fn home(home: &Path, opencode_config: &str) -> std::io::Result<()> { let qwen = home.join(".qwen"); write(&qwen.join("QWEN.md"), "# Rules")?; + skill(&qwen.join("skills"), "notes")?; write(&qwen.join("agents/reviewer.md"), AGENT)?; write(&qwen.join("commands/ship.toml"), TOML_COMMAND)?; write( @@ -183,6 +184,12 @@ fn home(home: &Path, opencode_config: &str) -> std::io::Result<()> { let crush = home.join(".config/crush"); write(&crush.join("CRUSH.md"), "# Rules")?; skill(&crush.join("skills"), "pdf")?; + write( + &crush.join("crush.json"), + r#"{"options": {"tui": {"compact_mode": true}}, + "mcp": {"github": {"type": "stdio", "command": "npx", "args": ["-y", "gh-mcp"]}, + "docs": {"type": "http", "url": "https://docs.example/mcp"}}}"#, + )?; Ok(()) } @@ -327,6 +334,8 @@ fn every_assistant_is_read_whole() -> Res { "instructions/instructions", "mcp_server/docs", "mcp_server/github", + "skill/notes", + "skill/shared", ]) ); assert_eq!( @@ -341,7 +350,12 @@ fn every_assistant_is_read_whole() -> Res { ); assert_eq!( found["crush"], - items(&["instructions/instructions", "skill/pdf"]) + items(&[ + "instructions/instructions", + "mcp_server/docs", + "mcp_server/github", + "skill/pdf" + ]) ); Ok(()) } @@ -364,6 +378,7 @@ fn every_mcp_dialect_becomes_the_same_server() -> Res { "windsurf", "qwen-code", "factory", + "crush", ] { assert_eq!(server(home, id, "github"), gh(json!({})), "{id}"); } @@ -383,6 +398,7 @@ fn every_mcp_dialect_becomes_the_same_server() -> Res { "windsurf", "qwen-code", "factory", + "crush", ] { assert_eq!( server(home, id, "docs"), diff --git a/scripts/e2e-plugins.sh b/scripts/e2e-plugins.sh new file mode 100755 index 0000000..59403f0 --- /dev/null +++ b/scripts/e2e-plugins.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: AGPL-3.0-only +# End-to-end test of plugins in every assistant: two Docker "computers" with +# every assistant really installed, one pouch holding two plugin folders, and +# each assistant's own CLI saying what it loaded. +# +# scripts/e2e-plugins.sh build, run, print the result table +# KEEP=1 scripts/e2e-plugins.sh leave the computers and servers up +# +# The plugins: `kit` (scripts/e2e-plugins/kit), written in the web app's +# editor, has one of each part; its MCP server and hooks note in /tmp/marks +# that they ran and found their plugin folder. ponytail +# (github.com/dietrichgebert/ponytail) comes from GitHub as a user would add +# it, so a change upstream can fail the run. Claude Code installs the plugins +# itself; every other assistant gets their parts. A runs OpenCode 1, B +# OpenCode 2. Windsurf has no Linux CLI, so it isn't checked. +# +# Needs what scripts/e2e-settings.sh needs, and GitHub reachable. Ports: +# E2E_API_PORT (18182), E2E_WEB_PORT (15182), E2E_MOCK_PORT (4998). +set -Eeuo pipefail +trap 'echo "failed at line $LINENO: $BASH_COMMAND" >&2' ERR + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cli="$(dirname "$here")" +repo="${SKILLPOUCH_REPO:-$(dirname "$cli")/skillpouch}" +api_port="${E2E_API_PORT:-18182}" +web_port="${E2E_WEB_PORT:-15182}" +mock_port="${E2E_MOCK_PORT:-4998}" +image="${E2E_IMAGE:-sp-e2e-plugins:latest}" +origin="http://localhost:$web_port" +M="http://127.0.0.1:$mock_port" +export TMPDIR="$cli/target/e2e-tmp" +mkdir -p "$TMPDIR" +work="$(mktemp -d -p "$cli/target/e2e-tmp" sp-e2e-plugins.XXXXXX)" +pcs=(sp-e2e-pa sp-e2e-pb) +pids=() +source "$here/e2e-settings/harnesses.sh" +source "$here/e2e-settings/stack.sh" +trap cleanup EXIT + +{ +build_cli +start_servers +log "building $image" +docker build -q -t sp-e2e-settings:latest "$here/e2e-settings" >/dev/null +docker build -q -t "$image" "$here/e2e-plugins" >/dev/null +start_computers +docker exec sp-e2e-pb ln -sf /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode +start_browser +read -r email pouch <<<"$(ask account)" +log "account $email, pouch $pouch" + +log "the plugins: kit written in the web app, ponytail from GitHub" +python3 - "$here/e2e-plugins/kit" "$work/kit.json" <<'EOF' +import json, os, sys +root, out = sys.argv[1:] +files = {} +for folder, _, names in os.walk(root): + for name in names: + path = os.path.join(folder, name) + files[os.path.relpath(path, root)] = open(path).read() +json.dump(files, open(out, 'w')) +EOF +answer="$(ask write-plugin kit "$work/kit.json")" +[ "$answer" = ok ] || { echo "adding kit: $answer" >&2; exit 1; } +answer="$(ask github-plugin dietrichgebert/ponytail)" +[ "$answer" = ok ] || { echo "adding ponytail: $answer" >&2; exit 1; } + +log "A and B: every assistant set up, then the first syncs" +for pc in "${pcs[@]}"; do + for id in "${IDS[@]}"; do on "$pc" "mkdir -p \$HOME/${ROOT[$id]} && seed_$id"; done + on "$pc" 'mkdir -p $HOME/.cursor $HOME/.gemini/antigravity-cli' +done +connect sp-e2e-pa fill +sync_on sp-e2e-pa +connect sp-e2e-pb use_pouch +sync_on sp-e2e-pb +sync_on sp-e2e-pa + +fail() { echo "FAIL: $*"; } +looks=(claude-code factory codex opencode gemini-cli qwen-code copilot crush cursor antigravity) +parts=(skill agent command mcp hook) +for pc in "${pcs[@]}"; do + log "$pc: what each assistant loaded" + docker cp "$here/e2e-plugins/checks.sh" "$pc:/e2e/checks.sh" >/dev/null + # ponytail's plugin parts came, and its copies for other assistants didn't. + ponytail=/root/.skillpouch/current/plugins/ponytail + for path in .claude-plugin/plugin.json skills/ponytail/SKILL.md hooks/ponytail-activate.js; do + on "$pc" "[ -f $ponytail/$path ]" || fail "$pc: ponytail came without $path" + done + for path in .cursor .opencode .windsurf docs; do + on "$pc" "[ ! -e $ponytail/$path ]" || fail "$pc: ponytail brought $path" + done + # ponytail's own SessionStart hook, as Codex would run it. + on "$pc" "python3 -c \"import json; print(next(h['command'] for e in json.load(open('/root/.codex/hooks.json'))['hooks']['SessionStart'] for h in e['hooks'] if 'ponytail-activate.js' in h['command']))\" >/tmp/ponytail-hook" && + on "$pc" 'echo "{}" | sh /tmp/ponytail-hook' | grep -q 'PONYTAIL MODE ACTIVE' || + fail "$pc: ponytail's SessionStart hook didn't run" + + printf '%-12s' assistant + for part in "${parts[@]}"; do printf ' %-8s' "$part"; done + echo + for id in "${looks[@]}"; do + printf '%-12s' "$id" + if ! rows="$(on "$pc" "source /e2e/checks.sh; look_$id" 2>"$work/$pc-$id.err")"; then + echo + fail "$pc: checking $id failed; see $work/$pc-$id.err" + continue + fi + missing=() seen=() + while read -r part result; do + printf ' %-8s' "$result" + seen+=("$part") + case "$result" in + ok | listed | -) ;; + *) missing+=("$part") ;; + esac + done <<<"$rows" + echo + if [ "${seen[*]}" != "${parts[*]}" ]; then fail "$pc: $id answered '${seen[*]}', not '${parts[*]}'"; fi + if [ "${#missing[@]}" -gt 0 ]; then fail "$pc: $id is missing ${missing[*]}"; fi + done +done | tee "$work/table.txt" +if grep -q '^FAIL' "$work/table.txt"; then + log "something didn't load; see above" + exit 1 +fi +log "every assistant with a Linux CLI loaded both plugins (Windsurf has none)" +} diff --git a/scripts/e2e-plugins/Dockerfile b/scripts/e2e-plugins/Dockerfile new file mode 100644 index 0000000..164748e --- /dev/null +++ b/scripts/e2e-plugins/Dockerfile @@ -0,0 +1,11 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# One "computer" for scripts/e2e-plugins.sh: the settings test's computer +# (scripts/e2e-settings/Dockerfile, built first) plus Cursor's and +# Antigravity's CLIs, which install from their own scripts. +ARG BASE=sp-e2e-settings:latest +FROM ${BASE} + +RUN curl -fsS https://cursor.com/install | bash \ + && curl -fsSL https://antigravity.google/cli/install.sh | bash \ + && ln -s /root/.local/bin/cursor-agent /usr/local/bin/cursor-agent \ + && ln -s /root/.local/bin/agy /usr/local/bin/agy diff --git a/scripts/e2e-plugins/checks.sh b/scripts/e2e-plugins/checks.sh new file mode 100644 index 0000000..15b5cb7 --- /dev/null +++ b/scripts/e2e-plugins/checks.sh @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# What each assistant loaded of the two plugins in scripts/e2e-plugins.sh +# (sourced on a computer after harnesses.sh; $M is the mock provider). +# `look_` runs the assistant once, then prints one ` ` +# line for each of PARTS: +# +# ok the assistant itself shows it (its listing, or the part ran and +# found its plugin's folder); a file on disk only where the +# assistant has no way to list that part +# listed in the assistant's config, but it runs only once the user says +# so: Codex asks to trust new hooks, and Cursor runs nothing +# without an account +# - the assistant has no such part +# missing +# +# The kit plugin's hooks and MCP server write to /tmp/marks; a skill counts +# only when both kit's and ponytail's arrived. + +PARTS=(skill agent command mcp hook) + +cell() { + local part="$1" + shift + if eval "$*" >/dev/null 2>&1; then echo "$part ok"; else echo "$part missing"; fi +} +written() { + local part="$1" + shift + if eval "$*" >/dev/null 2>&1; then echo "$part listed"; else echo "$part missing"; fi +} +none() { echo "$1 -"; } +fresh() { + rm -rf /tmp/marks + mkdir -p /tmp/marks +} +ran() { grep -q "^$1 found" /tmp/marks/*.log; } +hooks_ran() { ran SessionStart && ran UserPromptSubmit; } +skills_in() { + grep -q 'kit skill loaded' "$1/kit-skill/SKILL.md" && grep -q '^name: ponytail$' "$1/ponytail/SKILL.md" +} +agent_in() { grep -q 'kit agent loaded' "$1"; } +command_in() { grep -q 'kit command loaded' "$1"; } +# A TOML command must parse, with the body as its prompt. +toml_command_in() { + python3 -c 'import sys, tomllib; sys.exit("kit command loaded" not in tomllib.load(open(sys.argv[1], "rb"))["prompt"])' "$1" +} +lists() { grep -q -- "$1" /tmp/listing && grep -q -- "$2" /tmp/listing; } +kit_hook_in() { grep -q "plugins/kit'; sh .*/hooks/mark.sh" "$1"; } + +# Claude Code reports what it loaded when a session starts. +init_has() { + python3 - "$1" "$2" <<'EOF' +import json, sys +key, name = sys.argv[1:] +for line in open('/tmp/init.json'): + message = json.loads(line) + if message.get('subtype') == 'init': + names = [entry if isinstance(entry, str) else entry['name'] for entry in message[key]] + sys.exit(0 if name in names else 1) +sys.exit(1) +EOF +} +look_claude-code() { + fresh + (cd /tmp && timeout 90 claude -p hi --output-format stream-json --verbose 2>/dev/null) | + grep '"subtype":"init"' >/tmp/init.json || true + cell skill 'init_has skills kit:kit-skill && init_has skills ponytail:ponytail' + cell agent 'init_has agents kit:kit-agent' + cell command 'init_has slash_commands kit:kit-command' + cell mcp 'init_has mcp_servers plugin:kit:kit-mcp && ran tools' + cell hook hooks_ran +} + +look_codex() { + fresh + timeout 90 codex exec --skip-git-repo-check hi >/dev/null 2>&1 || true + timeout 60 codex mcp list >/tmp/listing 2>&1 || true + cell skill 'skills_in ~/.agents/skills' + none agent + cell command 'command_in ~/.codex/prompts/kit-command.md' + cell mcp 'lists kit-mcp kit-mcp && ran tools' + written hook 'kit_hook_in ~/.codex/hooks.json' +} + +look_opencode() { + fresh + run_opencode + cell skill 'skills_in ~/.config/opencode/skills' + cell agent 'agent_in ~/.config/opencode/agents/kit-agent.md' + cell command 'command_in ~/.config/opencode/commands/kit-command.md' + cell mcp 'ran tools' + none hook +} + +look_gemini-cli() { + fresh + run_gemini-cli + timeout 60 gemini skills list >/tmp/listing 2>&1 || true + cell skill 'lists "^kit-skill " "^ponytail "' + cell agent 'agent_in ~/.gemini/agents/kit-agent.md' + cell command 'toml_command_in ~/.gemini/commands/kit-command.toml' + cell mcp 'ran tools' + cell hook hooks_ran +} + +look_qwen-code() { + fresh + run_qwen-code + timeout 60 qwen mcp list >/tmp/listing 2>&1 || true + cell skill 'skills_in ~/.agents/skills' + cell agent 'agent_in ~/.qwen/agents/kit-agent.md' + cell command 'toml_command_in ~/.qwen/commands/kit-command.toml' + # Its listing connects without asking for tools, and a prompt that the + # mock provider refuses ends before any server starts. + cell mcp 'grep "kit-mcp" /tmp/listing | grep -q Connected && ran init' + cell hook hooks_ran +} + +look_copilot() { + fresh + (cd /tmp && COPILOT_PROVIDER_BASE_URL="$M/v1" COPILOT_MODEL=m1 \ + timeout 120 copilot -p hi --allow-all-tools >/dev/null 2>&1) || true + timeout 60 copilot skill list >/tmp/listing 2>&1 || true + cell skill 'lists "kit-skill - " "ponytail - "' + cell agent 'agent_in ~/.copilot/agents/kit-agent.md' + none command + cell mcp 'ran tools' + cell hook hooks_ran +} + +look_crush() { + fresh + run_crush || true + cell skill 'skills_in ~/.config/crush/skills' + none agent + none command + cell mcp 'ran tools' + none hook +} + +look_factory() { + fresh + (cd /tmp && timeout 90 droid exec hi >/dev/null 2>&1) || true + timeout 60 droid mcp list >/tmp/listing 2>&1 || true + cell skill 'skills_in ~/.factory/skills' + cell agent 'agent_in ~/.factory/droids/kit-agent.md' + cell command 'command_in ~/.factory/commands/kit-command.md' + cell mcp 'lists kit-mcp kit-mcp && ran tools' + # `droid exec` runs only SessionStart hooks. + cell hook 'ran SessionStart && grep -q "mark.sh.* UserPromptSubmit" ~/.factory/settings.json' +} + +# Cursor and Antigravity need an account for a prompt: their MCP listing +# starts the servers, and Antigravity starts them before it asks to sign in. +look_cursor() { + fresh + # Run in the home folder, Cursor asks before it starts a new server. + (cd /tmp && timeout 120 cursor-agent mcp list >/tmp/listing 2>&1) || true + cell skill 'skills_in ~/.cursor/skills' + none agent + cell command 'command_in ~/.cursor/commands/kit-command.md' + cell mcp 'lists "kit-mcp: ready" "kit-mcp: ready" && ran tools' + written hook 'kit_hook_in ~/.cursor/hooks.json' +} + +look_antigravity() { + fresh + timeout 60 agy mcp list >/tmp/listing 2>&1 || true + (cd /tmp && timeout 30 agy -p hi --print-timeout 20s >/dev/null 2>&1) || true + cell skill 'skills_in ~/.gemini/config/skills' + cell agent 'agent_in ~/.gemini/config/agents/kit-agent.md' + none command + cell mcp 'lists kit-mcp kit-mcp && ran tools' + none hook +} diff --git a/scripts/e2e-plugins/kit/.claude-plugin/plugin.json b/scripts/e2e-plugins/kit/.claude-plugin/plugin.json new file mode 100644 index 0000000..8701e71 --- /dev/null +++ b/scripts/e2e-plugins/kit/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "kit", + "description": "The SkillPouch end-to-end test plugin", + "version": "1.0.0" +} diff --git a/scripts/e2e-plugins/kit/.mcp.json b/scripts/e2e-plugins/kit/.mcp.json new file mode 100644 index 0000000..f69603b --- /dev/null +++ b/scripts/e2e-plugins/kit/.mcp.json @@ -0,0 +1,5 @@ +{ + "mcpServers": { + "kit-mcp": { "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/mcp.mjs"] } + } +} diff --git a/scripts/e2e-plugins/kit/agents/kit-agent.md b/scripts/e2e-plugins/kit/agents/kit-agent.md new file mode 100644 index 0000000..8431847 --- /dev/null +++ b/scripts/e2e-plugins/kit/agents/kit-agent.md @@ -0,0 +1,6 @@ +--- +name: kit-agent +description: The kit plugin's agent, for the kit test. +--- + +You are the kit test agent. Answer "kit agent loaded". diff --git a/scripts/e2e-plugins/kit/commands/kit-command.md b/scripts/e2e-plugins/kit/commands/kit-command.md new file mode 100644 index 0000000..dd67fed --- /dev/null +++ b/scripts/e2e-plugins/kit/commands/kit-command.md @@ -0,0 +1,5 @@ +--- +description: The kit plugin's command, for the kit test. +--- + +Answer "kit command loaded". diff --git a/scripts/e2e-plugins/kit/hooks/hooks.json b/scripts/e2e-plugins/kit/hooks/hooks.json new file mode 100644 index 0000000..9d40815 --- /dev/null +++ b/scripts/e2e-plugins/kit/hooks/hooks.json @@ -0,0 +1,10 @@ +{ + "hooks": { + "SessionStart": [ + { "hooks": [{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/mark.sh\" SessionStart" }] } + ], + "UserPromptSubmit": [ + { "hooks": [{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/mark.sh\" UserPromptSubmit" }] } + ] + } +} diff --git a/scripts/e2e-plugins/kit/hooks/mark.sh b/scripts/e2e-plugins/kit/hooks/mark.sh new file mode 100644 index 0000000..36dd36a --- /dev/null +++ b/scripts/e2e-plugins/kit/hooks/mark.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# A hook ran: which event, and whether it found the plugin's folder. +cat >/dev/null +mkdir -p /tmp/marks +if [ -f "$CLAUDE_PLUGIN_ROOT/.claude-plugin/plugin.json" ]; then root=found; else root="missing:$CLAUDE_PLUGIN_ROOT"; fi +echo "$1 $root" >>/tmp/marks/hooks.log diff --git a/scripts/e2e-plugins/kit/mcp.mjs b/scripts/e2e-plugins/kit/mcp.mjs new file mode 100644 index 0000000..0268ea3 --- /dev/null +++ b/scripts/e2e-plugins/kit/mcp.mjs @@ -0,0 +1,48 @@ +// A stdio MCP server with one tool. It notes in /tmp/marks/mcp.log each +// start, handshake and request for its tools, and whether it found the +// plugin's folder. +import { appendFileSync, existsSync, mkdirSync } from 'node:fs'; +import { createInterface } from 'node:readline'; + +const root = process.env.CLAUDE_PLUGIN_ROOT ?? ''; +const found = existsSync(`${root}/.claude-plugin/plugin.json`); +const mark = (what) => { + mkdirSync('/tmp/marks', { recursive: true }); + appendFileSync('/tmp/marks/mcp.log', `${what} ${found ? 'found' : `missing:${root}`}\n`); +}; +mark('start'); + +const reply = (id, result) => + process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); +for await (const line of createInterface({ input: process.stdin })) { + let message; + try { + message = JSON.parse(line); + } catch { + continue; + } + if (message.id === undefined) continue; + if (message.method === 'initialize') { + mark('init'); + reply(message.id, { + protocolVersion: message.params?.protocolVersion ?? '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'kit-mcp', version: '1.0.0' }, + }); + } else if (message.method === 'tools/list') { + mark('tools'); + reply(message.id, { + tools: [ + { + name: 'kit_ping', + description: 'Answers pong.', + inputSchema: { type: 'object', properties: {} }, + }, + ], + }); + } else if (message.method === 'tools/call') { + reply(message.id, { content: [{ type: 'text', text: 'pong' }] }); + } else { + reply(message.id, {}); + } +} diff --git a/scripts/e2e-plugins/kit/plugin.json b/scripts/e2e-plugins/kit/plugin.json new file mode 100644 index 0000000..781b817 --- /dev/null +++ b/scripts/e2e-plugins/kit/plugin.json @@ -0,0 +1 @@ +{ "name": "kit" } diff --git a/scripts/e2e-plugins/kit/skills/kit-skill/SKILL.md b/scripts/e2e-plugins/kit/skills/kit-skill/SKILL.md new file mode 100644 index 0000000..140daa1 --- /dev/null +++ b/scripts/e2e-plugins/kit/skills/kit-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: kit-skill +description: The kit plugin's skill. Use it when asked about the kit test. +--- + +Answer "kit skill loaded". diff --git a/scripts/e2e-settings.sh b/scripts/e2e-settings.sh index 64ed275..f640a47 100755 --- a/scripts/e2e-settings.sh +++ b/scripts/e2e-settings.sh @@ -37,142 +37,9 @@ work="$(mktemp -d -p "$cli/target/e2e-tmp" sp-e2e-settings.XXXXXX)" pcs=(sp-e2e-a sp-e2e-b sp-e2e-c) pids=() source "$here/e2e-settings/harnesses.sh" - -log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; } - -cleanup() { - set +e - if [ -n "${WEB_PID:-}" ]; then - { echo quit >&"${WEB[1]}"; } 2>/dev/null - sleep 2 - kill "$WEB_PID" 2>/dev/null - fi - if [ "${KEEP:-0}" = 1 ]; then - log "kept: ${pcs[*]}, API :$api_port, web :$web_port, logs in $work" - return - fi - for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null; done - wait 2>/dev/null - docker rm -f "${pcs[@]}" >/dev/null 2>&1 - log "logs in $work" -} +source "$here/e2e-settings/stack.sh" trap cleanup EXIT -# --- the CLI, the computers, the servers ------------------------------------ - -build_cli() { - if [ -n "${SKILLPOUCH_BIN:-}" ]; then - cp "$SKILLPOUCH_BIN" "$work/skillpouch" - return - fi - log "building the CLI for Debian bookworm" - local toolchain - toolchain="$(sed -n 's/^rust-version = "\(.*\)"/\1/p' "$cli/Cargo.toml")" - docker run --rm -v "$cli":/src:ro -v sp-e2e-cargo:/usr/local/cargo/registry \ - -v sp-e2e-target:/target -e CARGO_TARGET_DIR=/target -w /src \ - "rust:$toolchain-bookworm" cargo build --release --locked -p skillpouch >"$work/build.log" 2>&1 - docker run --rm -v sp-e2e-target:/target -v "$work":/out "rust:$toolchain-bookworm" \ - cp /target/release/skillpouch /out/skillpouch -} - -start_servers() { - for port in "$api_port" "$web_port" "$mock_port"; do - if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then - echo "port $port is taken: stop what listens there or pick another (see the top of this file)" >&2 - exit 1 - fi - done - log "starting the API (:$api_port) and the web app (:$web_port)" - (cd "$repo/apps/web" && E2E_API_PORT=$api_port E2E_WEB_PORT=$web_port \ - exec node --import tsx e2e/stack.ts >"$work/api.log" 2>&1) & - pids+=($!) - (cd "$repo/apps/web" && SKILLPOUCH_API_URL=http://127.0.0.1:$api_port WEB_PORT=$web_port \ - exec node_modules/.bin/vite --port "$web_port" --strictPort >"$work/web.log" 2>&1) & - pids+=($!) - node "$here/e2e-settings/mock-llm.mjs" "$mock_port" "$work/mock.log" & - pids+=($!) - for _ in $(seq 120); do - curl -sf "http://127.0.0.1:$api_port/readyz" >/dev/null && - curl -sf -o /dev/null "$origin/sign-in" && return - sleep 1 - done - echo "the servers didn't start; see $work/api.log and $work/web.log" >&2 - exit 1 -} - -start_computers() { - log "building $image and starting ${pcs[*]}" - docker build -q -t "$image" "$here/e2e-settings" >/dev/null - for pc in "${pcs[@]}"; do - docker rm -f "$pc" >/dev/null 2>&1 || true - docker run -d --name "$pc" --network host -e SKILLPOUCH_API_URL="$origin" \ - -e M="$M" "$image" sleep infinity >/dev/null - docker exec "$pc" mkdir -p /e2e - docker cp "$work/skillpouch" "$pc:/usr/local/bin/skillpouch" >/dev/null - for file in harnesses.sh probe.py; do - docker cp "$here/e2e-settings/$file" "$pc:/e2e/$file" >/dev/null - done - done - # B runs OpenCode 2. - docker exec sp-e2e-b ln -sf /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode -} - -# Runs bash on a computer, with the fixtures loaded. Never with this -# script's stdin: the headless CLIs would wait on it, and loops reading -# here-strings would lose their lines. `on_stdin` passes it on. -on() { - local pc="$1" - shift - docker exec -w /root "$pc" bash -c "source /e2e/harnesses.sh; $*" >"$work/sync-$1.log" 2>&1; then - echo "skillpouch sync failed on $1:" >&2 - tail -5 "$work/sync-$1.log" >&2 - return 1 - fi -} - -# The browser: commands to web.mjs, one line back for each. -start_browser() { - coproc WEB { - SKILLPOUCH_WEB_DIR="$repo/apps/web" SKILLPOUCH_WEB_ORIGIN="$origin" \ - exec node "$here/e2e-settings/web.mjs" 2>"$work/browser.log" - } - WEB_PID=$! -} -ask() { - echo "$*" >&"${WEB[1]}" - local line - read -r -t 300 line <&"${WEB[0]}" - echo "$line" -} - -# Logs a computer in and sets it up for the pouch, as the TUI would. -connect() { - local pc="$1" mode="$2" token - token="$(ask token)" - on "$pc" skillpouch login --name "$pc" "$token" >>"$work/login.log" 2>&1 - [ "$(ask connected "$pc")" = ok ] - on_stdin "$pc" python3 - "$pouch" "$mode" <<'EOF' -import json, sys, time -pouch, mode = sys.argv[1:] -device = json.load(open('/root/.skillpouch/state/credentials.json'))['device_id'] -first = {'mode': 'fill', 'assistant': None} if mode == 'fill' else {'mode': 'use_pouch'} -json.dump({'format': 1, 'device_id': device, 'pouch_id': pouch, 'first_sync': first, - 'chosen_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'done': False}, - open('/root/.skillpouch/state/setup.json', 'w')) -EOF - # Syncs happen when the test says so; the background sync has its own step. - on "$pc" skillpouch daemon stop >/dev/null 2>&1 || true -} - # --- checks ----------------------------------------------------------------- declare -A RESULT @@ -271,7 +138,11 @@ reinstall() { { build_cli start_servers +log "building $image" +docker build -q -t "$image" "$here/e2e-settings" >/dev/null start_computers +# B runs OpenCode 2. +docker exec sp-e2e-b ln -sf /opt/opencode2/node_modules/.bin/opencode /usr/local/bin/opencode start_browser read -r email pouch <<<"$(ask account)" log "account $email, pouch $pouch" diff --git a/scripts/e2e-settings/stack.sh b/scripts/e2e-settings/stack.sh new file mode 100644 index 0000000..410147d --- /dev/null +++ b/scripts/e2e-settings/stack.sh @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# What scripts/e2e-settings.sh and scripts/e2e-plugins.sh share (sourced): +# the CLI built for the computers, the API, web app and mock provider, the +# Docker computers, and the browser. The caller sets `cli`, `repo`, `work`, +# `image`, `pcs`, the ports, `origin` and `M`, and an empty `pids` array. + +fixtures="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +log() { printf '\033[1;35m==>\033[0m %s\n' "$*"; } + +cleanup() { + set +eu + # Bash unsets WEB and WEB_PID once the browser exits. + local web_pid="${WEB_PID:-}" web_in="${WEB[1]:-}" + if [ -n "$web_pid" ]; then + [ -n "$web_in" ] && { echo quit >&"$web_in"; } 2>/dev/null + sleep 2 + kill "$web_pid" 2>/dev/null + fi + if [ "${KEEP:-0}" = 1 ]; then + log "kept: ${pcs[*]}, API :$api_port, web :$web_port, logs in $work" + return + fi + for pid in "${pids[@]}"; do kill "$pid" 2>/dev/null; done + wait 2>/dev/null + docker rm -f "${pcs[@]}" >/dev/null 2>&1 + log "logs in $work" +} + +build_cli() { + if [ -n "${SKILLPOUCH_BIN:-}" ]; then + cp "$SKILLPOUCH_BIN" "$work/skillpouch" + return + fi + log "building the CLI for Debian bookworm" + local toolchain + toolchain="$(sed -n 's/^rust-version = "\(.*\)"/\1/p' "$cli/Cargo.toml")" + docker run --rm -v "$cli":/src:ro -v sp-e2e-cargo:/usr/local/cargo/registry \ + -v sp-e2e-target:/target -e CARGO_TARGET_DIR=/target -w /src \ + "rust:$toolchain-bookworm" cargo build --release --locked -p skillpouch >"$work/build.log" 2>&1 + docker run --rm -v sp-e2e-target:/target -v "$work":/out "rust:$toolchain-bookworm" \ + cp /target/release/skillpouch /out/skillpouch +} + +start_servers() { + for port in "$api_port" "$web_port" "$mock_port"; do + # ss, not a test connection: under WSL that can hang on a free port. + if [ -n "$(ss -ltnH "sport = :$port")" ]; then + echo "port $port is taken: stop what listens there or pick another (see the top of this file)" >&2 + exit 1 + fi + done + log "starting the API (:$api_port) and the web app (:$web_port)" + (cd "$repo/apps/web" && E2E_API_PORT=$api_port E2E_WEB_PORT=$web_port \ + exec node --import tsx e2e/stack.ts >"$work/api.log" 2>&1) & + pids+=($!) + (cd "$repo/apps/web" && SKILLPOUCH_API_URL=http://127.0.0.1:$api_port WEB_PORT=$web_port \ + exec node_modules/.bin/vite --port "$web_port" --strictPort >"$work/web.log" 2>&1) & + pids+=($!) + node "$fixtures/mock-llm.mjs" "$mock_port" "$work/mock.log" & + pids+=($!) + for _ in $(seq 120); do + curl -sf "http://127.0.0.1:$api_port/readyz" >/dev/null && + curl -sf -o /dev/null "$origin/sign-in" && return + sleep 1 + done + echo "the servers didn't start; see $work/api.log and $work/web.log" >&2 + exit 1 +} + +# Starts the computers from $image, with the CLI and the fixtures in /e2e. +start_computers() { + log "starting ${pcs[*]}" + for pc in "${pcs[@]}"; do + docker rm -f "$pc" >/dev/null 2>&1 || true + docker run -d --name "$pc" --network host -e SKILLPOUCH_API_URL="$origin" \ + -e M="$M" "$image" sleep infinity >/dev/null + docker exec "$pc" mkdir -p /e2e + docker cp "$work/skillpouch" "$pc:/usr/local/bin/skillpouch" >/dev/null + for file in harnesses.sh probe.py; do + docker cp "$fixtures/$file" "$pc:/e2e/$file" >/dev/null + done + done +} + +# Runs bash on a computer, with the fixtures loaded. Never with this +# script's stdin: the headless CLIs would wait on it, and loops reading +# here-strings would lose their lines. `on_stdin` passes it on. +on() { + local pc="$1" + shift + docker exec -w /root "$pc" bash -c "source /e2e/harnesses.sh; $*" >"$work/sync-$1.log" 2>&1; then + echo "skillpouch sync failed on $1:" >&2 + tail -n 5 "$work/sync-$1.log" >&2 + return 1 + fi +} + +# The browser: commands to web.mjs, one line back for each. +start_browser() { + coproc WEB { + SKILLPOUCH_WEB_DIR="$repo/apps/web" SKILLPOUCH_WEB_ORIGIN="$origin" E2E_SHOTS="$work" \ + exec node "$fixtures/web.mjs" 2>"$work/browser.log" + } + WEB_PID=$! +} +ask() { + echo "$*" >&"${WEB[1]}" + local line + read -r -t 300 line <&"${WEB[0]}" + echo "$line" +} + +# Logs a computer in and sets it up for the pouch, as the TUI would. +connect() { + local pc="$1" mode="$2" token + token="$(ask token)" + on "$pc" skillpouch login --name "$pc" "$token" >>"$work/login.log" 2>&1 + [ "$(ask connected "$pc")" = ok ] + on_stdin "$pc" python3 - "$pouch" "$mode" <<'EOF' +import json, sys, time +pouch, mode = sys.argv[1:] +device = json.load(open('/root/.skillpouch/state/credentials.json'))['device_id'] +first = {'mode': 'fill', 'assistant': None} if mode == 'fill' else {'mode': 'use_pouch'} +json.dump({'format': 1, 'device_id': device, 'pouch_id': pouch, 'first_sync': first, + 'chosen_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), 'done': False}, + open('/root/.skillpouch/state/setup.json', 'w')) +EOF + # Syncs happen when the test says so; the background sync has its own step. + on "$pc" skillpouch daemon stop >/dev/null 2>&1 || true +} diff --git a/scripts/e2e-settings/web.mjs b/scripts/e2e-settings/web.mjs index e6e9349..0edc6fd 100755 --- a/scripts/e2e-settings/web.mjs +++ b/scripts/e2e-settings/web.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node // SPDX-License-Identifier: AGPL-3.0-only -// The browser half of scripts/e2e-settings.sh: one signed-in Chromium page in +// The browser half of scripts/e2e-settings.sh and scripts/e2e-plugins.sh: one +// signed-in Chromium page in // the web app, driven one command per line on stdin, answering one line on // stdout. It stays open while a computer logs in, because the browser seals // the account keys to the new device. @@ -12,11 +13,17 @@ // share shares the Settings item , opens the link in a // fresh browser; answers `ok ` when none // of the secrets show, else `leak ` +// write-plugin +// adds plugin folder written in the editor; +// files.json maps each path to its text +// github-plugin +// adds the plugin in that GitHub repository // quit // // Needs the web app's dependencies: SKILLPOUCH_WEB_DIR (apps/web of the // skillpouch repository) and its Playwright Chromium. import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { join } from 'node:path'; import { createInterface } from 'node:readline'; @@ -69,6 +76,77 @@ async function connected(name) { return 'ok'; } +async function openKind(label) { + await page.goto(`/app/p/${pouchId}`); + await page + .getByRole('navigation') + .getByRole('link', { name: new RegExp(`^${label}`) }) + .first() + .click(); + await page.getByRole('heading', { level: 1, name: label }).waitFor(); +} + +// Typed text would meet the editor's closing brackets and indentation. +async function fillEditor(sheet, path, text) { + const editor = sheet.getByLabel(`Contents of ${path}`); + await editor.click(); + await page.keyboard.press('ControlOrMeta+a'); + await page.keyboard.press('Delete'); + await page.keyboard.insertText(text); +} + +async function writePlugin(name, filesJson) { + const files = JSON.parse(readFileSync(filesJson, 'utf8')); + await openKind('Plugins'); + await page.getByRole('button', { name: 'Add plugin' }).first().click(); + await page.getByRole('button', { name: /Write it yourself/ }).click(); + const sheet = page.getByRole('dialog'); + await sheet.getByLabel('Item name').fill(name); + // The sheet opens on plugin.json; a new file takes the editor from it. + const paths = Object.keys(files).sort((a, b) => (b === 'plugin.json') - (a === 'plugin.json')); + for (const path of paths) { + const text = files[path]; + if (path !== 'plugin.json') { + await sheet.getByRole('button', { name: 'New file' }).click(); + const prompt = page.getByRole('dialog', { name: 'New file' }); + await prompt.getByLabel('Path').fill(path); + await prompt.getByRole('button', { name: 'Create' }).click(); + await prompt.waitFor({ state: 'hidden' }); + } + await fillEditor(sheet, path, text); + } + await sheet.getByRole('button', { name: 'Add plugin' }).click(); + await page.getByText('Plugin added').waitFor(); + return 'ok'; +} + +async function githubPlugin(repo, plugin = repo.split('/')[1]) { + await openKind('Plugins'); + await page.getByRole('button', { name: 'Add plugin' }).first().click(); + await page.getByRole('button', { name: /From GitHub/ }).click(); + const dialog = page.getByRole('dialog', { name: 'Add plugin from GitHub' }); + await dialog.getByLabel('Repository', { exact: true }).fill(repo); + await dialog.getByRole('button', { name: 'Find' }).click(); + // A repository with a marketplace file is added as a marketplace, and + // its plugin picked there, as a user would. + const market = dialog.getByRole('button', { name: /Add to my marketplaces/ }); + const add = page.getByRole('button', { name: 'Add to pouch' }); + await market.or(add).first().waitFor({ timeout: 120_000 }); + if (await market.isVisible()) { + await market.click(); + const browse = page.getByRole('dialog', { name: 'Add plugin from a marketplace' }); + await browse + .getByRole('button', { name: `Add ${plugin}`, exact: true }) + .click({ timeout: 120_000 }); + await browse.waitFor({ state: 'hidden', timeout: 120_000 }); + } else { + await add.click(); + await page.getByRole('button', { name: 'Remove' }).waitFor({ timeout: 120_000 }); + } + await page.keyboard.press('Escape'); + return 'ok'; +} + async function openSettings() { await page.goto(`/app/p/${pouchId}`); await page.getByRole('navigation').getByRole('link', { name: /^Settings/ }).first().click(); @@ -118,12 +196,21 @@ for await (const line of createInterface({ input: process.stdin })) { ? await items() : command === 'share' ? await share(args[0], args[1] ?? '') - : command === 'quit' - ? null - : `error unknown command ${command}`; + : command === 'write-plugin' + ? await writePlugin(args[0], args[1]) + : command === 'github-plugin' + ? await githubPlugin(args[0]) + : command === 'quit' + ? null + : `error unknown command ${command}`; if (answer === null) break; process.stdout.write(`${answer}\n`); } catch (error) { + if (process.env.E2E_SHOTS) { + await page + .screenshot({ path: join(process.env.E2E_SHOTS, `${command}.png`), fullPage: true }) + .catch(() => {}); + } process.stdout.write(`error ${String(error.message ?? error).split('\n')[0]}\n`); } }