From 5b7ccc333ac13f53ac59fb03a2634c1406eca028 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 9 Oct 2026 11:05:15 -0300 Subject: [PATCH 1/6] feat(sync): install plugins enabled with use --global for Claude Code --- src/agents/mod.rs | 47 +- src/agents/plugin_registration/antigravity.rs | 16 + src/agents/plugin_registration/claude.rs | 83 +++ .../plugin_registration/claude/tests.rs | 149 ++++++ src/agents/plugin_registration/codex.rs | 16 + src/agents/plugin_registration/copilot.rs | 16 + src/agents/plugin_registration/goose.rs | 16 + src/agents/plugin_registration/mod.rs | 5 + src/compile.rs | 267 ++++++++++ src/compile/tests.rs | 492 ++++++++++++++++++ src/config.rs | 29 ++ src/init.rs | 1 + src/lib.rs | 1 + src/report.rs | 13 + src/skills.rs | 13 +- src/sync.rs | 235 ++++++--- symposium-testlib/src/lib.rs | 91 ++++ tests/init_sync.rs | 9 +- 18 files changed, 1434 insertions(+), 65 deletions(-) create mode 100644 src/agents/plugin_registration/antigravity.rs create mode 100644 src/agents/plugin_registration/claude.rs create mode 100644 src/agents/plugin_registration/claude/tests.rs create mode 100644 src/agents/plugin_registration/codex.rs create mode 100644 src/agents/plugin_registration/copilot.rs create mode 100644 src/agents/plugin_registration/goose.rs create mode 100644 src/agents/plugin_registration/mod.rs create mode 100644 src/compile.rs create mode 100644 src/compile/tests.rs diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 5c79917e8..fbcd3b509 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,7 @@ //! that knowledge. mod mcp_server_registration; +mod plugin_registration; use std::fs; use std::path::{Path, PathBuf}; @@ -15,6 +16,13 @@ use serde_json::json; use crate::config::Symposium; use crate::output::{Output, display_path}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CompiledPlugin { + pub name: String, + pub version: String, + pub dir: PathBuf, +} + /// Which of an agent's two MCP configuration levels to write. /// /// Distinct from [`crate::config::HookScope`]: an agent may support one level @@ -354,6 +362,32 @@ impl Agent { } } + /// Returns whether the agent installs `plugins` itself, in which case its + /// per-skill copies of their skills are not written. + pub(crate) fn sync_user_plugins( + &self, + sym: &Symposium, + root: &Path, + plugins: &[CompiledPlugin], + out: &Output, + ) -> Result { + match self { + Agent::Antigravity => { + plugin_registration::antigravity::sync_user_plugins(sym, root, plugins, out) + } + Agent::Claude => { + plugin_registration::claude::sync_user_plugins(sym, root, plugins, out) + } + Agent::Codex => plugin_registration::codex::sync_user_plugins(sym, root, plugins, out), + Agent::Copilot => { + plugin_registration::copilot::sync_user_plugins(sym, root, plugins, out) + } + Agent::Goose => plugin_registration::goose::sync_user_plugins(sym, root, plugins, out), + Agent::Kiro => Ok(false), + Agent::OpenCode => Ok(false), + } + } + /// Remove hooks from the project-level agent config. pub fn unregister_project_hooks(&self, project_root: &Path, _sym: &Symposium, out: &Output) { match self { @@ -1017,7 +1051,7 @@ fn event_to_cli_arg(event: &str) -> &str { } } -fn load_json_or_empty(path: &Path) -> Result { +pub(crate) fn load_json_or_empty(path: &Path) -> Result { if path.exists() { let contents = fs::read_to_string(path)?; if contents.trim().is_empty() { @@ -1037,7 +1071,7 @@ fn load_json_or_empty(path: &Path) -> Result { /// /// Bounds torn reads, not lost updates. What keeps that window from mattering is /// that registration writes only when an entry actually differs. -fn save_json(path: &Path, value: &serde_json::Value) -> Result<()> { +pub(crate) fn save_json(path: &Path, value: &serde_json::Value) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } @@ -1053,6 +1087,15 @@ fn save_json(path: &Path, value: &serde_json::Value) -> Result<()> { Ok(()) } +pub(crate) fn save_json_if_changed(path: &Path, value: &serde_json::Value) -> Result { + let contents = serde_json::to_string_pretty(value)?; + if fs::read_to_string(path).is_ok_and(|existing| existing == contents) { + return Ok(false); + } + save_json(path, value)?; + Ok(true) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agents/plugin_registration/antigravity.rs b/src/agents/plugin_registration/antigravity.rs new file mode 100644 index 000000000..beaeaaa76 --- /dev/null +++ b/src/agents/plugin_registration/antigravity.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::agents::CompiledPlugin; +use crate::config::Symposium; +use crate::output::Output; + +pub(crate) fn sync_user_plugins( + _sym: &Symposium, + _root: &Path, + _plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + Ok(false) +} diff --git a/src/agents/plugin_registration/claude.rs b/src/agents/plugin_registration/claude.rs new file mode 100644 index 000000000..8324e6d2d --- /dev/null +++ b/src/agents/plugin_registration/claude.rs @@ -0,0 +1,83 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Result; + +use crate::agents::{Agent, CompiledPlugin}; +use crate::config::Symposium; +use crate::output::{Output, display_path}; +use crate::report::ReportEvent; + +pub(crate) fn sync_user_plugins( + sym: &Symposium, + _root: &Path, + plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + let debounce = Duration::from_secs(sym.config.sync_debounce_secs); + sync_skills_dir_plugins(&user_skills_dir(sym), plugins, debounce); + Ok(true) +} + +/// When set, `CLAUDE_CONFIG_DIR` replaces `~/.claude` and Claude Code ignores +/// `~/.claude/skills`. +fn user_skills_dir(sym: &Symposium) -> PathBuf { + sym.env_dir("CLAUDE_CONFIG_DIR") + .unwrap_or_else(|| sym.home_dir().join(".claude")) + .join("skills") +} + +fn sync_skills_dir_plugins(skills_dir: &Path, plugins: &[CompiledPlugin], debounce: Duration) { + let mut installed = BTreeSet::new(); + for plugin in plugins { + let Some(dest) = destination(skills_dir, &plugin.name) else { + warn(format!( + "skipping plugin {} for {}: {} is not managed by symposium", + plugin.name, + Agent::Claude.config_name(), + display_path(&skills_dir.join(&plugin.name)), + )); + continue; + }; + match crate::sync::sync_plugin_dir(&plugin.dir, &dest, debounce) { + Ok(changed) => { + if changed { + tracing::info!( + report = %ReportEvent::PluginInstalled { + plugin: plugin.name.clone(), + agent: Agent::Claude.config_name().to_string(), + dest: display_path(&dest), + }, + ); + } + installed.insert(dest); + } + Err(e) => warn(format!( + "failed to install plugin {} for {}: {e:#}", + plugin.name, + Agent::Claude.config_name(), + )), + } + } + crate::sync::reap_unlisted(skills_dir, |dir| { + installed.contains(dir) || !crate::compile::is_plugin_dir(dir) + }); +} + +fn destination(skills_dir: &Path, name: &str) -> Option { + [ + name.to_string(), + format!("{name}-{}", crate::skills::hash_origin_key(&name)), + ] + .into_iter() + .map(|dir| skills_dir.join(dir)) + .find(|dir| !dir.exists() || crate::sync::has_symposium_marker(dir)) +} + +fn warn(message: String) { + tracing::info!(report = %ReportEvent::Warning { message }); +} + +#[cfg(test)] +mod tests; diff --git a/src/agents/plugin_registration/claude/tests.rs b/src/agents/plugin_registration/claude/tests.rs new file mode 100644 index 000000000..cb0d462e8 --- /dev/null +++ b/src/agents/plugin_registration/claude/tests.rs @@ -0,0 +1,149 @@ +use super::*; + +use std::fs; +use std::time::SystemTime; + +fn compiled(root: &Path, name: &str, body: &str) -> CompiledPlugin { + let dir = root.join(name); + fs::create_dir_all(dir.join(".claude-plugin")).unwrap(); + fs::create_dir_all(dir.join("skills/guide")).unwrap(); + let manifest = format!(r#"{{"name": "{name}"}}"#); + fs::write(dir.join("plugin.json"), &manifest).unwrap(); + fs::write(dir.join(".claude-plugin/plugin.json"), &manifest).unwrap(); + fs::write(dir.join("skills/guide/SKILL.md"), body).unwrap(); + fs::write(dir.join(crate::sync::MARKER_FILE), "").unwrap(); + CompiledPlugin { + name: name.into(), + version: "0.0.0".into(), + dir, + } +} + +fn mkdir(dir: &Path, files: &[&str]) { + for file in files { + let path = dir.join(file); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "").unwrap(); + } +} + +#[test] +fn the_user_skills_directory_follows_claude_config_dir() { + let tmp = tempfile::tempdir().unwrap(); + let mut sym = Symposium::from_dir(tmp.path()); + let default = tmp.path().join(".claude/skills"); + assert_eq!(user_skills_dir(&sym), default); + + sym.set_env(|name| (name == "CLAUDE_CONFIG_DIR").then(|| "/elsewhere/claude".into())); + assert_eq!(user_skills_dir(&sym), Path::new("/elsewhere/claude/skills")); + + sym.set_env(|name| (name == "CLAUDE_CONFIG_DIR").then(|| "".into())); + assert_eq!( + user_skills_dir(&sym), + default, + "an empty value does not relocate" + ); +} + +#[test] +fn delivery_copies_plugins_and_reaps_only_what_symposium_installed() { + let tmp = tempfile::tempdir().unwrap(); + let staging = tmp.path().join("installed"); + let skills_dir = tmp.path().join("skills"); + mkdir(&skills_dir, &["users-skill/SKILL.md"]); + mkdir( + &skills_dir, + &[ + "users-plugin/.claude-plugin/plugin.json", + "users-plugin/skills/x/SKILL.md", + ], + ); + mkdir( + &skills_dir, + &["per-skill-copy/SKILL.md", "per-skill-copy/.symposium"], + ); + mkdir( + &skills_dir, + &[ + "stale-plugin/.claude-plugin/plugin.json", + "stale-plugin/.symposium", + ], + ); + mkdir(&skills_dir, &["taken/SKILL.md"]); + let fresh = compiled(&staging, "fresh", "v1"); + let taken = compiled(&staging, "taken", "v1"); + + sync_skills_dir_plugins(&skills_dir, &[fresh.clone(), taken.clone()], Duration::ZERO); + + assert_eq!( + fs::read_to_string(skills_dir.join("fresh/skills/guide/SKILL.md")).unwrap(), + "v1" + ); + assert!(skills_dir.join("fresh/.symposium").is_file()); + let taken_copy = skills_dir.join(format!( + "taken-{}", + crate::skills::hash_origin_key(&"taken") + )); + assert!( + taken_copy.join(".claude-plugin/plugin.json").is_file(), + "a user's directory in the way moves the copy aside" + ); + assert!(skills_dir.join("taken/SKILL.md").is_file()); + assert!(!skills_dir.join("stale-plugin").exists()); + for kept in ["users-skill", "users-plugin", "per-skill-copy"] { + assert!(skills_dir.join(kept).exists(), "{kept} must be left alone"); + } + + let copied = skills_dir.join("fresh/skills/guide/SKILL.md"); + let an_hour_ago = SystemTime::now() - Duration::from_secs(3600); + fs::File::options() + .write(true) + .open(&copied) + .unwrap() + .set_modified(an_hour_ago) + .unwrap(); + sync_skills_dir_plugins(&skills_dir, &[fresh.clone(), taken.clone()], Duration::ZERO); + assert_eq!( + fs::metadata(&copied).unwrap().modified().unwrap(), + an_hour_ago + ); + + let fresh = compiled(&staging, "fresh", "v2"); + sync_skills_dir_plugins(&skills_dir, &[fresh, taken], Duration::ZERO); + assert_eq!(fs::read_to_string(&copied).unwrap(), "v2"); + + sync_skills_dir_plugins(&skills_dir, &[], Duration::ZERO); + assert!(!skills_dir.join("fresh").exists()); + assert!(!taken_copy.exists()); + for kept in ["users-skill", "users-plugin", "per-skill-copy", "taken"] { + assert!(skills_dir.join(kept).exists(), "{kept} must be left alone"); + } +} + +#[test] +fn delivery_never_writes_claude_settings() { + let tmp = tempfile::tempdir().unwrap(); + let sym = Symposium::from_dir(tmp.path()); + let settings = tmp.path().join(".claude/settings.json"); + mkdir(tmp.path(), &[".claude/settings.json"]); + fs::write( + &settings, + r#"{"enabledPlugins": {"fresh@skills-dir": false}}"#, + ) + .unwrap(); + let fresh = compiled(&tmp.path().join("installed"), "fresh", "v1"); + + let delivered = sync_user_plugins(&sym, tmp.path(), &[fresh], &Output::quiet()).unwrap(); + + assert!(delivered); + assert!( + tmp.path() + .join(".claude/skills/fresh/plugin.json") + .is_file() + ); + assert_eq!( + fs::read_to_string(&settings).unwrap(), + r#"{"enabledPlugins": {"fresh@skills-dir": false}}"#, + "a user's own `false` stays" + ); +} diff --git a/src/agents/plugin_registration/codex.rs b/src/agents/plugin_registration/codex.rs new file mode 100644 index 000000000..beaeaaa76 --- /dev/null +++ b/src/agents/plugin_registration/codex.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::agents::CompiledPlugin; +use crate::config::Symposium; +use crate::output::Output; + +pub(crate) fn sync_user_plugins( + _sym: &Symposium, + _root: &Path, + _plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + Ok(false) +} diff --git a/src/agents/plugin_registration/copilot.rs b/src/agents/plugin_registration/copilot.rs new file mode 100644 index 000000000..beaeaaa76 --- /dev/null +++ b/src/agents/plugin_registration/copilot.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::agents::CompiledPlugin; +use crate::config::Symposium; +use crate::output::Output; + +pub(crate) fn sync_user_plugins( + _sym: &Symposium, + _root: &Path, + _plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + Ok(false) +} diff --git a/src/agents/plugin_registration/goose.rs b/src/agents/plugin_registration/goose.rs new file mode 100644 index 000000000..beaeaaa76 --- /dev/null +++ b/src/agents/plugin_registration/goose.rs @@ -0,0 +1,16 @@ +use std::path::Path; + +use anyhow::Result; + +use crate::agents::CompiledPlugin; +use crate::config::Symposium; +use crate::output::Output; + +pub(crate) fn sync_user_plugins( + _sym: &Symposium, + _root: &Path, + _plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + Ok(false) +} diff --git a/src/agents/plugin_registration/mod.rs b/src/agents/plugin_registration/mod.rs new file mode 100644 index 000000000..794250ed7 --- /dev/null +++ b/src/agents/plugin_registration/mod.rs @@ -0,0 +1,5 @@ +pub(super) mod antigravity; +pub(super) mod claude; +pub(super) mod codex; +pub(super) mod copilot; +pub(super) mod goose; diff --git a/src/compile.rs b/src/compile.rs new file mode 100644 index 000000000..973ea1966 --- /dev/null +++ b/src/compile.rs @@ -0,0 +1,267 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde_json::json; + +use crate::agents::CompiledPlugin; +use crate::config::PluginsConfig; +use crate::output::display_path; +use crate::plugins::Plugin; +use crate::report::ReportEvent; +use crate::skills::SkillWithGroupContext; +use crate::sync::{self, TreeContents}; + +const PLUGIN_VERSION: &str = "0.0.0"; +const AGENT_PLUGINS_SCHEMA: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; +const MANIFEST_FILE: &str = "plugin.json"; +const CLAUDE_MANIFEST_DIR: &str = ".claude-plugin"; +const MARKETPLACE_FILE: &str = "marketplace.json"; +const MARKETPLACE_NAME: &str = "symposium"; +const SKILLS_DIR: &str = "skills"; +const MAX_NAME_LEN: usize = 64; + +fn installs_globally(config: &PluginsConfig, plugin: &Plugin) -> bool { + !plugin.workspace_member + && [&plugin.manifest.name, &plugin.canonical.name] + .into_iter() + .any(|name| config.is_used_globally(name)) +} + +pub(crate) fn is_plugin_dir(dir: &Path) -> bool { + dir.join(CLAUDE_MANIFEST_DIR).join(MANIFEST_FILE).is_file() +} + +pub(crate) struct GlobalPlugin<'a> { + name: &'a str, + skills: Vec<&'a SkillWithGroupContext>, +} + +pub(crate) fn global_plugins<'a>( + config: &PluginsConfig, + active: &'a [Plugin], + skills: &[&'a SkillWithGroupContext], +) -> Vec> { + let global: Vec = active + .iter() + .map(|plugin| installs_globally(config, plugin)) + .collect(); + let mut grouped: BTreeMap> = BTreeMap::new(); + for skill in skills { + if global[skill.plugin_index] { + grouped.entry(skill.plugin_index).or_default().push(skill); + } + } + grouped + .into_iter() + .map(|(index, skills)| GlobalPlugin { + name: &active[index].manifest.name, + skills, + }) + .collect() +} + +#[derive(Default)] +pub(crate) struct Compiled { + pub plugins: Vec, + pub skill_origins: BTreeSet, +} + +pub(crate) fn compile(root: &Path, plugins: &[GlobalPlugin], debounce: Duration) -> Compiled { + let mut compiled = Compiled::default(); + for (plugin, name) in plugins.iter().zip(plugin_dir_names(root, plugins)) { + let Some(name) = name else { + warn(format!( + "skipping plugin {:?}: its name has no letters or digits to name a plugin directory", + plugin.name + )); + continue; + }; + let skills = skill_dirs(plugin); + if skills.is_empty() { + continue; + } + let dir = root.join(&name); + match write_plugin(&dir, &name, &skills, debounce) { + Ok(()) => { + compiled + .skill_origins + .extend(skills.iter().map(|(_, skill)| skill.origin_hash.clone())); + compiled.plugins.push(CompiledPlugin { + name, + version: PLUGIN_VERSION.to_string(), + dir, + }); + } + Err(e) => warn(format!("failed to compile plugin {name}: {e:#}")), + } + } + if let Err(e) = write_marketplace(root, &compiled.plugins) { + warn(format!( + "failed to write the plugin index in {}: {e:#}", + display_path(root) + )); + } + let keep: BTreeSet<&Path> = compiled.plugins.iter().map(|p| p.dir.as_path()).collect(); + sync::reap_unlisted(root, |dir| keep.contains(dir)); + compiled +} + +/// Stricter than Agent Plugins names: Antigravity rejects `.`. +fn normalize_name(name: &str) -> Option { + let mut out = String::new(); + for c in name.chars().map(|c| c.to_ascii_lowercase()) { + if c.is_ascii_alphanumeric() { + out.push(c); + } else if !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + } + out.truncate(MAX_NAME_LEN); + let out = out.trim_end_matches('-'); + (!out.is_empty()).then(|| out.to_string()) +} + +fn plugin_dir_names(root: &Path, plugins: &[GlobalPlugin]) -> Vec> { + let names: Vec> = plugins.iter().map(|p| normalize_name(p.name)).collect(); + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for name in names.iter().flatten() { + *counts.entry(name).or_default() += 1; + } + plugins + .iter() + .zip(&names) + .map(|(plugin, name)| { + let name = name.as_deref()?; + let slot = root.join(name); + let user_managed = slot.exists() && !sync::has_symposium_marker(&slot); + if counts[name] == 1 && !user_managed { + return Some(name.to_string()); + } + let mut origins: Vec<&str> = plugin + .skills + .iter() + .map(|s| s.origin_hash.as_str()) + .collect(); + origins.sort(); + Some(suffixed(name, &crate::skills::hash_origin_key(&origins))) + }) + .collect() +} + +fn suffixed(name: &str, hash: &str) -> String { + let base = &name[..name.len().min(MAX_NAME_LEN - hash.len() - 1)]; + format!("{}-{hash}", base.trim_end_matches('-')) +} + +fn skill_dirs<'a>(plugin: &GlobalPlugin<'a>) -> Vec<(String, &'a SkillWithGroupContext)> { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for skill in &plugin.skills { + *counts.entry(skill.skill.name()).or_default() += 1; + } + plugin + .skills + .iter() + .filter_map(|&skill| { + let name = skill.skill.name(); + if name.trim_end_matches(['.', ' ']).is_empty() || name.contains(['/', '\\', ':']) { + warn(format!( + "skipping skill {name:?} in plugin {}: not a plain directory name", + plugin.name + )); + return None; + } + let dir = if counts[name] > 1 { + format!("{name}-{}", skill.origin_hash) + } else { + name.to_string() + }; + Some((dir, skill)) + }) + .collect() +} + +fn write_plugin( + dir: &Path, + name: &str, + skills: &[(String, &SkillWithGroupContext)], + debounce: Duration, +) -> Result<()> { + if sync::recently_synced(dir, debounce) { + return Ok(()); + } + sync::sync_tree(dir, &plugin_contents(name, skills)?)?; + Ok(()) +} + +fn plugin_contents( + name: &str, + skills: &[(String, &SkillWithGroupContext)], +) -> Result { + let manifest = serde_json::to_vec_pretty(&manifest(name))?; + let mut contents: TreeContents = vec![ + (PathBuf::from(MANIFEST_FILE), Some(manifest.clone())), + (PathBuf::from(CLAUDE_MANIFEST_DIR), None), + ( + Path::new(CLAUDE_MANIFEST_DIR).join(MANIFEST_FILE), + Some(manifest), + ), + (PathBuf::from(SKILLS_DIR), None), + ]; + for (dir, skill) in skills { + let source = skill + .skill + .path + .parent() + .with_context(|| format!("skill {dir} has no source directory"))?; + let base = Path::new(SKILLS_DIR).join(dir); + contents.push((base.clone(), None)); + for (rel, data) in sync::collect_skill_tree(source)? { + contents.push((base.join(rel), data)); + } + } + contents.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(contents) +} + +fn manifest(name: &str) -> serde_json::Value { + json!({ + "$schema": AGENT_PLUGINS_SCHEMA, + "name": name, + "version": PLUGIN_VERSION, + }) +} + +fn marketplace(plugins: &[CompiledPlugin]) -> serde_json::Value { + let mut names: Vec<&str> = plugins.iter().map(|p| p.name.as_str()).collect(); + names.sort(); + json!({ + "name": MARKETPLACE_NAME, + "owner": { "name": MARKETPLACE_NAME }, + "plugins": names + .into_iter() + .map(|name| json!({ "name": name, "source": format!("./{name}") })) + .collect::>(), + }) +} + +fn write_marketplace(root: &Path, plugins: &[CompiledPlugin]) -> Result<()> { + let dir = root.join(CLAUDE_MANIFEST_DIR); + let path = dir.join(MARKETPLACE_FILE); + if !plugins.is_empty() { + crate::agents::save_json_if_changed(&path, &marketplace(plugins))?; + } else if path.exists() { + fs::remove_file(&path)?; + let _ = fs::remove_dir(&dir); + } + Ok(()) +} + +fn warn(message: String) { + tracing::info!(report = %ReportEvent::Warning { message }); +} + +#[cfg(test)] +mod tests; diff --git a/src/compile/tests.rs b/src/compile/tests.rs new file mode 100644 index 000000000..c8f4f1557 --- /dev/null +++ b/src/compile/tests.rs @@ -0,0 +1,492 @@ +use super::*; + +use std::collections::BTreeMap; +use std::time::SystemTime; + +use expect_test::expect; +use symposium_sdk::manifest::RawPluginManifest; + +use crate::config::UseEntry; +use crate::pm::{ANY_VERSION, PackageId, PluginKind, UnvalidatedPlugin}; +use crate::predicate::PredicateSet; +use crate::skills::Skill; + +fn validated(kind: PluginKind, pm: &str, name: &str, manifest: &str) -> Plugin { + crate::plugins::validate_plugin( + UnvalidatedPlugin::new( + PackageId::new(pm, name, ANY_VERSION), + "/nonexistent", + RawPluginManifest::parse(manifest).unwrap(), + ), + kind, + ) + .unwrap() +} + +fn registry(name: &str, gate: &str) -> Plugin { + validated( + PluginKind::Registry, + "user-plugins", + name, + &format!("name = \"{name}\"\n{gate}"), + ) +} + +fn global(name: &str) -> UseEntry { + UseEntry::Global(name.into()) +} + +fn skill(name: &str, origin: &str, skill_md: &Path, plugin_index: usize) -> SkillWithGroupContext { + SkillWithGroupContext { + skill: Skill { + frontmatter: BTreeMap::from([("name".to_string(), name.to_string())]), + predicates: PredicateSet::default(), + path: skill_md.to_path_buf(), + }, + origin_hash: origin.into(), + plugin_index, + } +} + +fn write_skill(dir: &Path, name: &str) -> PathBuf { + fs::create_dir_all(dir).unwrap(); + let skill_md = dir.join("SKILL.md"); + fs::write( + &skill_md, + format!("---\nname: {name}\ndescription: {name} guidance\n---\n\nBody of {name}.\n"), + ) + .unwrap(); + skill_md +} + +fn tree(dir: &Path) -> String { + let mut lines = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(current) = stack.pop() { + for entry in fs::read_dir(¤t).unwrap().flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if path.is_dir() { + lines.push(format!("{rel}/")); + stack.push(path); + } else { + lines.push(rel); + } + } + } + lines.sort(); + lines.join("\n") +} + +fn is_valid_plugin_name(name: &str) -> bool { + let grammar = regex::Regex::new("^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$").unwrap(); + (1..=MAX_NAME_LEN).contains(&name.len()) && grammar.is_match(name) && !name.contains("--") +} + +#[test] +fn only_a_global_use_entry_naming_the_plugin_installs_it_for_the_user() { + let mut crate_plugin = validated(PluginKind::Package, "cargo", "crate_a", ""); + crate_plugin.manifest.name = "a-tools".into(); + let mut member = registry("house-style", ""); + member.workspace_member = true; + let workspace_use = UseEntry::Workspace { + name: "dormant-guide".into(), + workspace: "/ws".into(), + }; + + let cases = [ + ( + "global use on a dormant registry plugin", + registry("dormant-guide", ""), + vec![global("dormant-guide")], + true, + ), + ( + "global use on a crate, by its crate name", + crate_plugin, + vec![global("crate-a")], + true, + ), + ( + "workspace use", + registry("dormant-guide", ""), + vec![workspace_use], + false, + ), + ( + "registry plugin active by depends-on(*), no use", + registry("always-guide", "depends-on = [\"*\"]"), + vec![], + false, + ), + ( + "dependency-gated registry plugin, no use", + registry("serde-guide", "depends-on = [\"serde\"]"), + vec![], + false, + ), + ( + "workspace member named by a global use", + member, + vec![global("house-style")], + false, + ), + ]; + for (label, plugin, used, expected) in cases { + let config = PluginsConfig { + used, + ..Default::default() + }; + assert_eq!(installs_globally(&config, &plugin), expected, "{label}"); + } +} + +#[test] +fn names_normalize_to_what_every_agent_accepts() { + let long = "a".repeat(70); + let cases: Vec<(&str, Option)> = vec![ + ("serde_json", Some("serde-json".into())), + ("Serde", Some("serde".into())), + ("pdf.tools", Some("pdf-tools".into())), + ("a--b", Some("a-b".into())), + ("-edges-", Some("edges".into())), + ("café au lait", Some("caf-au-lait".into())), + (&long, Some("a".repeat(64))), + ("___", None), + ("日本", None), + ("", None), + ]; + for (input, expected) in cases { + let normalized = normalize_name(input); + assert_eq!(normalized, expected, "{input:?}"); + if let Some(name) = normalized { + assert!(is_valid_plugin_name(&name), "{name:?}"); + } + } + + let truncated = normalize_name(&format!("{}_tail", "a".repeat(63))).unwrap(); + assert_eq!( + truncated, + "a".repeat(63), + "no trailing `-` after truncation" + ); +} + +#[test] +fn colliding_names_are_all_suffixed_and_unsluggable_ones_skipped() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + fs::create_dir_all(root.join("mine")).unwrap(); + let md = root.join("SKILL.md"); + let skills = [ + skill("s1", "11111111", &md, 0), + skill("s2", "22222222", &md, 1), + skill("s3", "33333333", &md, 2), + skill("s4", "44444444", &md, 3), + skill("s5", "55555555", &md, 4), + skill("s6", "66666666", &md, 5), + skill("s7", "77777777", &md, 6), + ]; + let long_lower = "a".repeat(70); + let long_upper = "A".repeat(70); + let names = [ + "serde_json", + "serde-json", + "___", + "plain", + "mine", + long_lower.as_str(), + long_upper.as_str(), + ]; + let plugins: Vec = names + .iter() + .zip(&skills) + .map(|(name, skill)| GlobalPlugin { + name, + skills: vec![skill], + }) + .collect(); + + let assigned = plugin_dir_names(root, &plugins); + + let serde = assigned[0].as_deref().unwrap(); + let serde_alike = assigned[1].as_deref().unwrap(); + assert!(serde.starts_with("serde-json-") && serde_alike.starts_with("serde-json-")); + assert_ne!(serde, serde_alike); + assert_eq!(assigned[2], None); + assert_eq!(assigned[3].as_deref(), Some("plain")); + assert!( + assigned[4].as_deref().unwrap().starts_with("mine-"), + "a directory the user owns keeps the plain name: {assigned:?}" + ); + let (lower, upper) = ( + assigned[5].as_deref().unwrap(), + assigned[6].as_deref().unwrap(), + ); + assert_ne!(lower, upper); + for name in assigned.iter().flatten() { + assert!(is_valid_plugin_name(name), "{name:?}"); + } +} + +#[test] +fn compile_writes_one_self_contained_directory_per_plugin() { + let tmp = tempfile::tempdir().unwrap(); + let sources = tmp.path().join("sources"); + let root = tmp.path().join("installed"); + let nested = write_skill(&sources.join("deep/nested/alpha"), "alpha"); + fs::create_dir_all(sources.join("deep/nested/alpha/reference")).unwrap(); + fs::write( + sources.join("deep/nested/alpha/reference/notes.md"), + "notes", + ) + .unwrap(); + let dup_one = write_skill(&sources.join("one/dup"), "dup"); + let dup_two = write_skill(&sources.join("two/dup"), "dup"); + let escaping = write_skill(&sources.join("escaping"), "escaping"); + + let skills = [ + skill("alpha", "aaaaaaaa", &nested, 0), + skill("dup", "11111111", &dup_one, 0), + skill("dup", "22222222", &dup_two, 0), + skill("../escape", "eeeeeeee", &escaping, 0), + ]; + let plugins = [GlobalPlugin { + name: "Guide_Tools", + skills: skills.iter().collect(), + }]; + + let compiled = compile(&root, &plugins, Duration::ZERO); + + expect![[r#" + .claude-plugin/ + .claude-plugin/marketplace.json + guide-tools/ + guide-tools/.claude-plugin/ + guide-tools/.claude-plugin/plugin.json + guide-tools/.symposium + guide-tools/plugin.json + guide-tools/skills/ + guide-tools/skills/alpha/ + guide-tools/skills/alpha/SKILL.md + guide-tools/skills/alpha/reference/ + guide-tools/skills/alpha/reference/notes.md + guide-tools/skills/dup-11111111/ + guide-tools/skills/dup-11111111/SKILL.md + guide-tools/skills/dup-22222222/ + guide-tools/skills/dup-22222222/SKILL.md"#]] + .assert_eq(&tree(&root)); + assert_eq!( + compiled.plugins, + vec![CompiledPlugin { + name: "guide-tools".into(), + version: "0.0.0".into(), + dir: root.join("guide-tools"), + }] + ); + assert_eq!( + compiled.skill_origins, + BTreeSet::from(["aaaaaaaa".into(), "11111111".into(), "22222222".into()]), + "the skipped skill is left to the per-skill path" + ); + assert_eq!( + fs::read(root.join("guide-tools/plugin.json")).unwrap(), + fs::read(root.join("guide-tools/.claude-plugin/plugin.json")).unwrap(), + ); + assert_eq!( + fs::read_to_string(root.join("guide-tools/skills/alpha/SKILL.md")).unwrap(), + fs::read_to_string(&nested).unwrap(), + "skill files are copied verbatim" + ); +} + +#[cfg(unix)] +#[test] +fn compile_follows_symlinks_in_skill_content() { + let tmp = tempfile::tempdir().unwrap(); + let skill_md = write_skill(&tmp.path().join("src/linked"), "linked"); + fs::write(tmp.path().join("shared.md"), "shared").unwrap(); + std::os::unix::fs::symlink("../../shared.md", tmp.path().join("src/linked/shared.md")).unwrap(); + let root = tmp.path().join("installed"); + let skills = [skill("linked", "aaaaaaaa", &skill_md, 0)]; + + compile( + &root, + &[GlobalPlugin { + name: "links", + skills: skills.iter().collect(), + }], + Duration::ZERO, + ); + + let copied = root.join("links/skills/linked/shared.md"); + assert!(!fs::symlink_metadata(&copied).unwrap().is_symlink()); + assert_eq!(fs::read_to_string(copied).unwrap(), "shared"); +} + +#[test] +fn recompiling_writes_only_what_changed_and_reaps_what_left() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("installed"); + let keep_md = write_skill(&tmp.path().join("src/keep"), "keep"); + let gone_md = write_skill(&tmp.path().join("src/gone"), "gone"); + let skills = [ + skill("keep", "11111111", &keep_md, 0), + skill("gone", "22222222", &gone_md, 1), + ]; + let keep = GlobalPlugin { + name: "keep", + skills: vec![&skills[0]], + }; + let gone = GlobalPlugin { + name: "gone", + skills: vec![&skills[1]], + }; + fs::create_dir_all(root.join("users-own")).unwrap(); + + compile(&root, &[keep, gone], Duration::ZERO); + let copied = root.join("keep/skills/keep/SKILL.md"); + let an_hour_ago = SystemTime::now() - Duration::from_secs(3600); + fs::File::options() + .write(true) + .open(&copied) + .unwrap() + .set_modified(an_hour_ago) + .unwrap(); + + let keep = GlobalPlugin { + name: "keep", + skills: vec![&skills[0]], + }; + compile(&root, &[keep], Duration::ZERO); + + assert_eq!( + fs::metadata(&copied).unwrap().modified().unwrap(), + an_hour_ago, + "unchanged content is not rewritten" + ); + assert!(!root.join("gone").exists()); + assert!( + root.join("users-own").exists(), + "unmarked directories are not ours" + ); + let index: serde_json::Value = serde_json::from_str( + &fs::read_to_string(root.join(".claude-plugin/marketplace.json")).unwrap(), + ) + .unwrap(); + assert_eq!( + index["plugins"], + json!([{ "name": "keep", "source": "./keep" }]) + ); + + fs::write(&keep_md, "---\nname: keep\ndescription: edited\n---\n").unwrap(); + let keep = GlobalPlugin { + name: "keep", + skills: vec![&skills[0]], + }; + compile(&root, &[keep], Duration::ZERO); + assert!(fs::read_to_string(&copied).unwrap().contains("edited")); + + compile(&root, &[], Duration::ZERO); + assert!(!root.join("keep").exists()); + assert!( + !root.join(".claude-plugin").exists(), + "an empty root has no index" + ); + assert!(root.join("users-own").exists()); +} + +#[test] +fn a_skill_reached_by_two_global_plugins_compiles_into_the_first() { + let tmp = tempfile::tempdir().unwrap(); + let skill_md = write_skill(&tmp.path().join("shared"), "shared"); + let active = [ + registry("first", ""), + registry("second", ""), + registry("project", ""), + ]; + let deduplicated = [skill("shared", "11111111", &skill_md, 0)]; + let config = PluginsConfig { + used: vec![global("first"), global("second")], + ..Default::default() + }; + + let grouped = global_plugins(&config, &active, &deduplicated.iter().collect::>()); + + assert_eq!(grouped.len(), 1, "the plugin dedup emptied is dropped"); + assert_eq!(grouped[0].name, "first"); +} + +#[test] +fn manifests_follow_the_agent_plugins_schema() { + expect![[r#" + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "pdf-tools", + "version": "0.0.0" + }"#]] + .assert_eq(&serde_json::to_string_pretty(&manifest("pdf-tools")).unwrap()); + + let allowed = [ + "$schema", + "name", + "version", + "description", + "author", + "homepage", + "repository", + "license", + "keywords", + "extensions", + ]; + for input in ["serde_json", "Pdf.Tools", "a--b", &"x".repeat(80)] { + let name = normalize_name(input).unwrap(); + let rendered = manifest(&name); + let fields = rendered.as_object().unwrap(); + assert_eq!( + fields["$schema"], AGENT_PLUGINS_SCHEMA, + "required `$schema`" + ); + assert!( + is_valid_plugin_name(fields["name"].as_str().unwrap()), + "required `name`" + ); + assert!( + fields.keys().all(|key| allowed.contains(&key.as_str())), + "closed field set: {fields:?}" + ); + } +} + +#[test] +fn the_marketplace_index_lists_every_plugin_by_relative_path() { + let plugin = |name: &str| CompiledPlugin { + name: name.into(), + version: PLUGIN_VERSION.into(), + dir: PathBuf::from("/installed").join(name), + }; + expect![[r#" + { + "name": "symposium", + "owner": { + "name": "symposium" + }, + "plugins": [ + { + "name": "alpha", + "source": "./alpha" + }, + { + "name": "beta", + "source": "./beta" + } + ] + }"#]] + .assert_eq( + &serde_json::to_string_pretty(&marketplace(&[plugin("beta"), plugin("alpha")])).unwrap(), + ); +} diff --git a/src/config.rs b/src/config.rs index 379cca6f0..e0389f527 100644 --- a/src/config.rs +++ b/src/config.rs @@ -179,6 +179,12 @@ impl PluginsConfig { .any(|entry| name_matches(entry, name)) } + pub(crate) fn is_used_globally(&self, name: &str) -> bool { + self.used + .iter() + .any(|entry| matches!(entry, UseEntry::Global(entry) if name_matches(entry, name))) + } + /// Whether any enablement entry could pull in a crate plugin. With neither /// `auto-enable` nor `use` naming anything, /// [`enabled_dependencies`](crate::discovery::enabled_dependencies) is empty @@ -407,6 +413,8 @@ pub struct RegistryConfig { /// Cache subdirectory holding git registries' unpacked content. pub const REGISTRY_CACHE_SUBDIR: &str = "plugin-sources"; +pub const INSTALLED_PLUGINS_SUBDIR: &str = "installed"; + const BUILTIN_RECOMMENDATIONS_URL: &str = "https://github.com/symposium-dev/recommendations"; /// Full application context: parsed config + resolved directory paths. @@ -417,8 +425,11 @@ pub struct Symposium { pub config: Config, dirs: crate::dirs::SymposiumDirs, home_dir: PathBuf, + env: EnvLookup, } +type EnvLookup = Arc Option + Send + Sync>; + impl Symposium { /// Production constructor: resolves paths from environment. /// @@ -441,6 +452,7 @@ impl Symposium { config, dirs, home_dir, + env: Arc::new(|name| env::var_os(name)), } } @@ -466,6 +478,7 @@ impl Symposium { config, dirs, home_dir, + env: Arc::new(|_| None), } } @@ -539,6 +552,20 @@ impl Symposium { self.dirs.cargo_override = Some(path); } + #[doc(hidden)] + pub fn set_env( + &mut self, + env: impl Fn(&str) -> Option + Send + Sync + 'static, + ) { + self.env = Arc::new(env); + } + + pub(crate) fn env_dir(&self, var: &str) -> Option { + (self.env)(var) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + } + /// Initialize logging with an optional report layer. Call once at startup. /// /// When `report_layer` is `Some`, the layer is composed into the @@ -985,6 +1012,8 @@ mod tests { ); assert!(config.plugins.is_used_in("scoped", Path::new("/ws/a"))); assert!(!config.plugins.is_used_in("scoped", Path::new("/ws/other"))); + assert!(config.plugins.is_used_globally("everywhere")); + assert!(!config.plugins.is_used_globally("scoped")); // Entries survive a round trip through the config file. let reparsed = parse_config(&toml::to_string_pretty(&config).unwrap()); diff --git a/src/init.rs b/src/init.rs index 3bc561ff0..25a7ba602 100644 --- a/src/init.rs +++ b/src/init.rs @@ -102,6 +102,7 @@ pub async fn init(sym: &mut Symposium, out: &Output, opts: &InitOpts) -> Result< // Persist and apply. sym.save_config().context("failed to write user config")?; + crate::sync::unregister_user_plugins(sym, out); let config_path = sym.config_dir().join("config.toml"); diff --git a/src/lib.rs b/src/lib.rs index dcb90c4c1..1f48392fa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ pub mod telemetry; pub mod use_command; pub mod workspace_state; +pub(crate) mod compile; pub(crate) mod crate_metadata; pub(crate) mod dir_walk; pub(crate) mod init; diff --git a/src/report.rs b/src/report.rs index ceead0c03..54544c9fb 100644 --- a/src/report.rs +++ b/src/report.rs @@ -76,6 +76,12 @@ pub enum ReportEvent { /// A stale skill directory was removed. SkillRemoved { path: String }, + PluginInstalled { + plugin: String, + agent: String, + dest: String, + }, + /// A hook was registered for an agent. HookRegistered { agent: String, hook: String }, @@ -234,6 +240,13 @@ impl ReportEvent { Self::SkillRemoved { path } => { format!("âž– removed {path}") } + Self::PluginInstalled { + plugin, + agent, + dest, + } => { + format!("installed plugin {plugin} for {agent} -> {dest}") + } Self::HookRegistered { agent, hook } => { format!("🟢 {hook}: hooks registered for {agent}") } diff --git a/src/skills.rs b/src/skills.rs index 6520aea4d..caecbef57 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -70,7 +70,7 @@ impl Skill { // only a string — not a structured origin — is carried to the sync layer. /// 8-hex-char prefix of SHA-256 over the JSON-serialized origin key. -fn hash_origin_key(key: &T) -> String { +pub(crate) fn hash_origin_key(key: &T) -> String { use sha2::{Digest, Sha256}; let bytes = serde_json::to_vec(key).expect("origin key always serializes"); let digest = Sha256::digest(&bytes); @@ -100,6 +100,7 @@ pub struct SkillWithGroupContext { /// The hash of where the skill was discovered. Drives install-path disambiguation /// and dedup at sync time. pub origin_hash: String, + pub plugin_index: usize, } /// Resolve all applicable skills from the registry. @@ -155,7 +156,7 @@ pub(crate) async fn collect_skills( update: UpdateLevel, ) -> Vec { let mut results = Vec::new(); - for parsed in active { + for (plugin_index, parsed) in active.iter().enumerate() { ctx.set_workspace_member(parsed.workspace_member); for group in &parsed.manifest.skills { let skills = load_skills_for_group(sym, parsed, group, ctx, update).await; @@ -163,6 +164,7 @@ pub(crate) async fn collect_skills( collect_skill_applicable_to( skill, origin_hash, + plugin_index, &parsed.manifest.name, ctx, &mut results, @@ -576,6 +578,7 @@ pub(crate) fn is_safe_dir_name(name: &str) -> bool { fn collect_skill_applicable_to( skill: Skill, origin_hash: String, + plugin_index: usize, plugin_name: &str, ctx: &mut PredicateContext, results: &mut Vec, @@ -600,7 +603,11 @@ fn collect_skill_applicable_to( reason: None, }, ); - results.push(SkillWithGroupContext { skill, origin_hash }); + results.push(SkillWithGroupContext { + skill, + origin_hash, + plugin_index, + }); } /// Raw frontmatter fields extracted from a SKILL.md file. diff --git a/src/sync.rs b/src/sync.rs index 71fd434b0..c7feb882d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -13,7 +13,8 @@ use anyhow::{Context, Result}; use symposium_install::UpdateLevel; use crate::agents::Agent; -use crate::config::Symposium; +use crate::compile; +use crate::config::{INSTALLED_PLUGINS_SUBDIR, Symposium}; use crate::dir_walk::{self, EntryKind}; use crate::output::{Output, display_path}; use crate::plugins; @@ -74,7 +75,7 @@ fn mark_generated_skill_directory(dir: &Path) -> Result<()> { /// Does `dir` contain the `.symposium` marker, i.e. is it a symposium-managed /// skill directory? Returns `false` for user-authored skills and for any /// directory symposium did not create. -fn has_symposium_marker(dir: &Path) -> bool { +pub(crate) fn has_symposium_marker(dir: &Path) -> bool { dir.join(MARKER_FILE).exists() } @@ -174,6 +175,24 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { Ok(()) } +pub(crate) type TreeContents = Vec<(PathBuf, Option>)>; + +fn read_tree(dir: &Path, entries: Vec) -> Result { + entries + .into_iter() + .map(|(rel, kind)| { + let contents = match kind { + EntryKind::Dir => None, + EntryKind::File => { + let path = dir.join(&rel); + Some(fs::read(&path).with_context(|| format!("read {}", path.display()))?) + } + }; + Ok((rel, contents)) + }) + .collect() +} + /// Collect `dir`'s tree for comparison: every directory and regular file under /// it, as a path relative to `dir` paired with the file's contents (`None` for /// a directory). Symlinks resolve exactly as [`copy_dir_recursive`] resolves @@ -183,24 +202,121 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { /// rather than skill content. Entries the walk refuses are dropped silently: /// the copy this comparison guards reports them, and reporting them here too /// would say the same thing twice per sync. -fn collect_dir_contents(dir: &Path) -> Result>)>> { +fn collect_dir_contents(dir: &Path) -> Result { let (entries, _skipped) = walk_tree(dir)?; - let mut out = Vec::with_capacity(entries.len()); - for (rel, kind) in entries { - let name = rel.to_string_lossy(); - if name == MARKER_FILE || name == ".gitignore" { + let mut contents = read_tree(dir, entries)?; + contents.retain(|(rel, _)| rel != Path::new(MARKER_FILE) && rel != Path::new(".gitignore")); + Ok(contents) +} + +pub(crate) fn collect_skill_tree(dir: &Path) -> Result { + let (entries, skipped) = walk_tree(dir)?; + for message in skipped { + tracing::info!(report = %crate::report::ReportEvent::Warning { message }); + } + read_tree(dir, entries) +} + +pub(crate) fn recently_synced(dir: &Path, debounce: Duration) -> bool { + !debounce.is_zero() + && fs::metadata(dir.join(MARKER_FILE)) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|mtime| SystemTime::now().duration_since(mtime).ok()) + .is_some_and(|elapsed| elapsed < debounce) +} + +pub(crate) fn sync_tree(dest: &Path, sorted: &TreeContents) -> Result { + if dest.exists() && !has_symposium_marker(dest) { + anyhow::bail!("{} is not managed by symposium", display_path(dest)); + } + if dest.is_dir() && collect_dir_contents(dest)? == *sorted { + touch_marker(&dest.join(MARKER_FILE))?; + return Ok(false); + } + replace_dir(dest, |fresh| { + for (rel, data) in sorted { + let path = fresh.join(rel); + match data { + None => fs::create_dir(&path), + Some(bytes) => fs::write(&path, bytes), + } + .with_context(|| format!("write {}", path.display()))?; + } + touch_marker(&fresh.join(MARKER_FILE)) + })?; + Ok(true) +} + +pub(crate) fn sync_plugin_dir(source: &Path, dest: &Path, debounce: Duration) -> Result { + if recently_synced(dest, debounce) { + return Ok(false); + } + sync_tree(dest, &collect_dir_contents(source)?) +} + +/// The old tree is moved aside first: Windows cannot rename onto a non-empty +/// directory. +fn replace_dir(dest: &Path, write: impl FnOnce(&Path) -> Result<()>) -> Result<()> { + let (Some(parent), Some(name)) = (dest.parent(), dest.file_name()) else { + anyhow::bail!("{} has no parent directory", dest.display()); + }; + let name = name.to_string_lossy(); + let pid = std::process::id(); + let fresh = parent.join(format!(".{name}.symposium-new-{pid}")); + let stale = parent.join(format!(".{name}.symposium-old-{pid}")); + + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + let _ = fs::remove_dir_all(&fresh); + fs::create_dir(&fresh).with_context(|| format!("create {}", fresh.display()))?; + if let Err(e) = write(&fresh) { + let _ = fs::remove_dir_all(&fresh); + return Err(e); + } + + if !dest.exists() { + return fs::rename(&fresh, dest).with_context(|| format!("create {}", dest.display())); + } + let _ = fs::remove_dir_all(&stale); + if let Err(e) = fs::rename(dest, &stale) { + let _ = fs::remove_dir_all(&fresh); + return Err(e).with_context(|| format!("replace {}", dest.display())); + } + if let Err(e) = fs::rename(&fresh, dest) { + let _ = fs::rename(&stale, dest); + let _ = fs::remove_dir_all(&fresh); + return Err(e).with_context(|| format!("replace {}", dest.display())); + } + let _ = fs::remove_dir_all(&stale); + Ok(()) +} + +pub(crate) fn reap_unlisted(parent: &Path, keep: impl Fn(&Path) -> bool) { + let Ok(entries) = fs::read_dir(parent) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() || !has_symposium_marker(&path) || keep(&path) { continue; } - let contents = match kind { - EntryKind::Dir => None, - EntryKind::File => { - let path = dir.join(&rel); - Some(fs::read(&path).with_context(|| format!("read {}", path.display()))?) + match fs::remove_dir_all(&path) { + Ok(()) => { + tracing::info!( + report = %crate::report::ReportEvent::SkillRemoved { + path: display_path(&path), + }, + ); } - }; - out.push((rel, contents)); + Err(e) => { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to remove stale {}: {e}", display_path(&path)), + }, + ); + } + } } - Ok(out) } /// Returns true if the source directory's content differs from the @@ -243,18 +359,13 @@ fn sync_skill_dir( } // Debounce: if we synced recently, skip the content comparison. - let marker_path = dest_dir.join(MARKER_FILE); - if !debounce.is_zero() - && let Ok(meta) = fs::metadata(&marker_path) - && let Ok(mtime) = meta.modified() - && let Ok(elapsed) = SystemTime::now().duration_since(mtime) - && elapsed < debounce - { + if recently_synced(dest_dir, debounce) { tracing::debug!(dest = %dest_dir.display(), "skill sync debounced"); return Ok(false); } // Compare content (excluding managed metadata). + let marker_path = dest_dir.join(MARKER_FILE); if !dir_contents_differ(source_dir, dest_dir)? { // Content is identical — just touch the marker to reset debounce. touch_marker(&marker_path)?; @@ -401,15 +512,15 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // plain name and their origin hash so we can decide later whether each one // needs an `-` suffix to avoid collisions. let mut seen: BTreeSet<(String, String)> = BTreeSet::new(); - let mut to_install: Vec<(String, String, &std::path::Path)> = Vec::new(); + let mut to_install: Vec<&skills::SkillWithGroupContext> = Vec::new(); let mut name_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); for entry in &applicable { let name = entry.skill.name().to_string(); if seen.insert((name.clone(), entry.origin_hash.clone())) { - *name_counts.entry(name.clone()).or_default() += 1; - to_install.push((name, entry.origin_hash.clone(), &entry.skill.path)); + *name_counts.entry(name).or_default() += 1; + to_install.push(entry); } } @@ -461,6 +572,10 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // we find later that has the marker file but isn't in this set is stale. let mut installed_dirs: BTreeSet = BTreeSet::new(); + let plugins_root = sym.config_dir().join(INSTALLED_PLUGINS_SUBDIR); + let global = compile::global_plugins(&sym.config.plugins, &active, &to_install); + let compiled = compile::compile(&plugins_root, &global, debounce); + for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; @@ -503,10 +618,24 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve .register_mcp_servers(mcp_scope, &project_root, sym.home_dir(), &mcp_servers, out) .context("failed to register MCP servers")?; - for (skill_name, origin_hash, skill_source) in &to_install { - // `skill_source` is the path to the SKILL.md file; the skill - // directory is its parent. - let source_dir = match skill_source.parent() { + let delivered = agent + .sync_user_plugins(sym, &plugins_root, &compiled.plugins, out) + .unwrap_or_else(|e| { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to install plugins for {agent_name}: {e:#}"), + }, + ); + false + }); + + for entry in &to_install { + if delivered && compiled.skill_origins.contains(&entry.origin_hash) { + continue; + } + let skill_name = entry.skill.name(); + let origin_hash = &entry.origin_hash; + let source_dir = match entry.skill.path.parent() { Some(p) => p, None => { out.warn(format!( @@ -539,7 +668,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let unique_name = name_counts.get(skill_name).copied().unwrap_or(0) == 1; let plain_available = !plain_dir.exists() || has_symposium_marker(&plain_dir); let dir_name = if unique_name && plain_available { - skill_name.clone() + skill_name.to_string() } else { format!("{skill_name}-{}", origin_hash) }; @@ -594,34 +723,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve if !scanned.insert(parent.clone()) { continue; } - let Ok(entries) = fs::read_dir(&parent) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() || installed_dirs.contains(&path) { - continue; - } - if !has_symposium_marker(&path) { - continue; - } - match fs::remove_dir_all(&path) { - Ok(()) => { - tracing::info!( - report = %crate::report::ReportEvent::SkillRemoved { - path: display_path(&path), - }, - ); - } - Err(e) => { - tracing::info!( - report = %crate::report::ReportEvent::Warning { - message: format!("failed to remove stale {}: {e}", display_path(&path)), - }, - ); - } - } - } + reap_unlisted(&parent, |dir| { + installed_dirs.contains(dir) || compile::is_plugin_dir(dir) + }); } // Hooks come off at whichever scope they would have been written to; MCP @@ -651,6 +755,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } } + unregister_user_plugins(sym, out); if to_install.is_empty() { tracing::info!( @@ -663,6 +768,20 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve Ok(()) } +pub(crate) fn unregister_user_plugins(sym: &Symposium, out: &Output) { + let root = sym.config_dir().join(INSTALLED_PLUGINS_SUBDIR); + for &agent in Agent::all() { + if !sym + .config + .agents + .iter() + .any(|entry| entry.name == agent.config_name()) + { + let _ = agent.sync_user_plugins(sym, &root, &[], out); + } + } +} + /// Register global hooks for all configured agents. /// Register hooks for all configured agents. Uses `home_dir` (global scope). /// Called from `init` after writing the user config. diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 2ce6012ac..4b13c62c9 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -519,6 +519,97 @@ impl TestContext { .replace(symposium::state::CURRENT_VERSION, "$VERSION") .replace('\\', "/") } + + pub fn normalize_json(&self, json: &str) -> String { + fn normalize(ctx: &TestContext, value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::String(s) => serde_json::Value::String(ctx.normalize_paths(&s)), + serde_json::Value::Array(items) => { + items.into_iter().map(|v| normalize(ctx, v)).collect() + } + serde_json::Value::Object(map) => map + .into_iter() + .map(|(k, v)| (k, normalize(ctx, v))) + .collect(), + other => other, + } + } + let value = serde_json::from_str(json).expect("valid JSON"); + serde_json::to_string_pretty(&normalize(self, value)).unwrap() + } +} + +pub fn tree(dir: &Path) -> String { + let mut lines = Vec::new(); + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + let Ok(entries) = std::fs::read_dir(¤t) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path + .strip_prefix(dir) + .unwrap() + .to_string_lossy() + .replace('\\', "/"); + if path.is_dir() { + lines.push(format!("{rel}/")); + pending.push(path); + } else { + lines.push(rel); + } + } + } + lines.sort(); + lines.join("\n") +} + +pub fn find_installed_skills(dirs: &[PathBuf], skill_name: &str) -> Vec { + let is_copy = |path: &Path| { + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + return false; + }; + let hashed = name + .strip_prefix(skill_name) + .and_then(|rest| rest.strip_prefix('-')) + .is_some_and(|hash| hash.len() == 8 && hash.chars().all(|c| c.is_ascii_hexdigit())); + (name == skill_name || hashed) && path.join("SKILL.md").is_file() + }; + let children = |dir: &Path| -> Vec { + std::fs::read_dir(dir) + .map(|entries| entries.flatten().map(|e| e.path()).collect()) + .unwrap_or_default() + }; + let mut found = Vec::new(); + for dir in dirs { + for child in children(dir) { + if is_copy(&child) { + found.push(child.clone()); + } + let is_plugin = child.join("plugin.json").is_file() + || child.join(".claude-plugin").join("plugin.json").is_file(); + if is_plugin { + found.extend( + children(&child.join("skills")) + .into_iter() + .filter(|s| is_copy(s)), + ); + } + } + } + found.sort(); + found +} + +pub fn find_installed_skill(dirs: &[PathBuf], skill_name: &str) -> PathBuf { + let mut found = find_installed_skills(dirs, skill_name); + assert_eq!( + found.len(), + 1, + "expected exactly one installed `{skill_name}` across {dirs:?}, found {found:?}" + ); + found.pop().unwrap() } /// Directories discovered while copying fixture files. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 6798c1dae..297707ff2 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -690,8 +690,13 @@ async fn dormant_plugin_activates_only_once_used() { ctx.sym.save_config()?; ctx.symposium(&["sync"]).await?; - let dir = find_installed_skill(&skills_dir, "gateless-guidance"); - assert!(dir.join(".symposium").exists()); + let user_plugin = ctx.sym.home_dir().join(".claude/skills/gateless-plugin"); + let dir = symposium_testlib::find_installed_skill( + &[skills_dir, ctx.sym.home_dir().join(".claude/skills")], + "gateless-guidance", + ); + assert_eq!(dir, user_plugin.join("skills/gateless-guidance")); + assert!(user_plugin.join(".symposium").exists()); Ok(()) }, ) From 955b191b1a1a855f2b529eb1ecaae1ef90eda6fc Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 9 Oct 2026 11:05:19 -0300 Subject: [PATCH 2/6] test(sync): cover global plugin delivery end to end --- tests/agent_plugins/antigravity.rs | 1 + tests/agent_plugins/claude.rs | 383 ++++++++++++++++++ tests/agent_plugins/codex.rs | 1 + tests/agent_plugins/copilot.rs | 1 + tests/agent_plugins/goose.rs | 1 + tests/agent_plugins/main.rs | 5 + .../fixtures/agent-plugin-scopes0/Cargo.toml | 4 + .../dot-symposium/config.toml | 9 + .../plugins/always-guide/SKILL.md | 7 + .../plugins/global-guide/SKILL.md | 6 + .../plugins/global-guide/reference.md | 1 + .../plugins/workspace-guide/SKILL.md | 6 + .../fixtures/agent-plugin-scopes0/src/lib.rs | 0 13 files changed, 425 insertions(+) create mode 100644 tests/agent_plugins/antigravity.rs create mode 100644 tests/agent_plugins/claude.rs create mode 100644 tests/agent_plugins/codex.rs create mode 100644 tests/agent_plugins/copilot.rs create mode 100644 tests/agent_plugins/goose.rs create mode 100644 tests/agent_plugins/main.rs create mode 100644 tests/fixtures/agent-plugin-scopes0/Cargo.toml create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/always-guide/SKILL.md create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/SKILL.md create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/reference.md create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/workspace-guide/SKILL.md create mode 100644 tests/fixtures/agent-plugin-scopes0/src/lib.rs diff --git a/tests/agent_plugins/antigravity.rs b/tests/agent_plugins/antigravity.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/agent_plugins/antigravity.rs @@ -0,0 +1 @@ + diff --git a/tests/agent_plugins/claude.rs b/tests/agent_plugins/claude.rs new file mode 100644 index 000000000..3bab9e137 --- /dev/null +++ b/tests/agent_plugins/claude.rs @@ -0,0 +1,383 @@ +use std::path::{Path, PathBuf}; + +use expect_test::expect; +use symposium::config::{HookScope, UseEntry}; +use symposium_testlib::{ + TestContext, TestMode, find_installed_skill, find_installed_skills, tree, with_fixture, +}; + +const SCOPES: &[&str] = &["agent-plugin-scopes0"]; + +fn staging_root(ctx: &TestContext) -> PathBuf { + ctx.sym.config_dir().join("installed") +} + +fn user_skills(ctx: &TestContext) -> PathBuf { + ctx.sym.home_dir().join(".claude").join("skills") +} + +fn project_skills(ctx: &TestContext, agent_dir: &str) -> PathBuf { + ctx.workspace_root + .as_ref() + .unwrap() + .join(agent_dir) + .join("skills") +} + +fn claude_skill_dirs(ctx: &TestContext) -> Vec { + vec![project_skills(ctx, ".claude"), user_skills(ctx)] +} + +fn drop_use(ctx: &mut TestContext, name: &str) -> anyhow::Result<()> { + ctx.sym + .config + .plugins + .used + .retain(|entry| entry.name() != name); + ctx.sym.save_config() +} + +fn write_workspace(dir: &Path) { + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::write( + dir.join("Cargo.toml"), + "[package]\nname = \"other-host\"\nversion = \"0.1.0\"\nedition = \"2021\"\n", + ) + .unwrap(); + std::fs::write(dir.join("src/lib.rs"), "").unwrap(); +} + +#[tokio::test] +async fn a_global_plugin_reaches_claude_once_and_other_agents_keep_per_skill_copies() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&[ + "init", + "--add-agent", + "claude", + "--add-agent", + "codex", + "--add-agent", + "kiro", + ]) + .await?; + ctx.symposium(&["sync"]).await?; + + expect![[r#" + .claude-plugin/ + .claude-plugin/marketplace.json + global-guide/ + global-guide/.claude-plugin/ + global-guide/.claude-plugin/plugin.json + global-guide/.symposium + global-guide/plugin.json + global-guide/skills/ + global-guide/skills/global-guide/ + global-guide/skills/global-guide/SKILL.md + global-guide/skills/global-guide/reference.md"#]] + .assert_eq(&tree(&staging_root(&ctx))); + expect![[r#" + global-guide/ + global-guide/.claude-plugin/ + global-guide/.claude-plugin/plugin.json + global-guide/.symposium + global-guide/plugin.json + global-guide/skills/ + global-guide/skills/global-guide/ + global-guide/skills/global-guide/SKILL.md + global-guide/skills/global-guide/reference.md"#]] + .assert_eq(&tree(&user_skills(&ctx))); + expect![[r#" + { + "name": "symposium", + "owner": { + "name": "symposium" + }, + "plugins": [ + { + "name": "global-guide", + "source": "./global-guide" + } + ] + }"#]] + .assert_eq(&ctx.normalize_json(&std::fs::read_to_string( + staging_root(&ctx).join(".claude-plugin/marketplace.json"), + )?)); + expect![[r#" + { + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "global-guide", + "version": "0.0.0" + }"#]] + .assert_eq(&ctx.normalize_json(&std::fs::read_to_string( + user_skills(&ctx).join("global-guide/.claude-plugin/plugin.json"), + )?)); + + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), "global-guide"), + user_skills(&ctx).join("global-guide/skills/global-guide") + ); + for skill in ["workspace-guide", "always-guide"] { + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), skill), + project_skills(&ctx, ".claude").join(skill) + ); + } + for agent_dir in [".agents", ".kiro"] { + for skill in ["global-guide", "workspace-guide", "always-guide"] { + find_installed_skill(&[project_skills(&ctx, agent_dir)], skill); + } + } + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn hook_scope_does_not_decide_where_a_global_plugin_installs() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.sym.config.hook_scope = HookScope::Global; + ctx.sym.save_config()?; + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), "global-guide"), + user_skills(&ctx).join("global-guide/skills/global-guide") + ); + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), "workspace-guide"), + project_skills(&ctx, ".claude").join("workspace-guide") + ); + let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( + ctx.sym.home_dir().join(".claude/settings.json"), + )?)?; + assert!(settings.get("hooks").is_some(), "global hooks registered"); + assert!( + settings.get("enabledPlugins").is_none() + && settings.get("extraKnownMarketplaces").is_none(), + "plugin delivery writes no settings: {settings}" + ); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn another_workspace_shares_the_global_plugin_and_gets_no_project_copy() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + let staged = tree(&staging_root(&ctx)); + let delivered = tree(&user_skills(&ctx)); + + let other = ctx.tempdir.join("other-workspace"); + write_workspace(&other); + symposium::sync::sync( + &ctx.sym, + &ctx.sym.workspace_deps(&other), + symposium::UpdateLevel::None, + ) + .await?; + + assert_eq!(tree(&staging_root(&ctx)), staged); + assert_eq!(tree(&user_skills(&ctx)), delivered); + let other_skills = other.join(".claude/skills"); + assert!( + find_installed_skills(std::slice::from_ref(&other_skills), "global-guide").is_empty() + ); + assert!( + find_installed_skills(std::slice::from_ref(&other_skills), "workspace-guide") + .is_empty() + ); + find_installed_skill(&[other_skills], "always-guide"); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn dropping_the_global_use_removes_what_symposium_wrote_and_reports_it() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + + let installed = ctx.sync_with_report(tracing::Level::INFO).await?; + assert!( + installed.iter().any(|e| e["kind"] == "plugin_installed" + && e["plugin"] == "global-guide" + && e["agent"] == "claude"), + "{installed:#?}" + ); + + drop_use(&mut ctx, "global-guide")?; + let removed = ctx.sync_with_report(tracing::Level::INFO).await?; + + for path in [ + staging_root(&ctx).join("global-guide"), + user_skills(&ctx).join("global-guide"), + ] { + assert!(!path.exists(), "{} is left behind", path.display()); + let shown = ctx.normalize_paths(&symposium::output::display_path(&path)); + assert!( + removed.iter().any(|e| e["kind"] == "skill_removed" + && ctx.normalize_paths(e["path"].as_str().unwrap()) == shown), + "{shown} removal not reported: {removed:#?}" + ); + } + assert!(!staging_root(&ctx).join(".claude-plugin").exists()); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn a_global_use_turned_workspace_use_returns_to_per_skill_copies() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + ctx.symposium(&["use", "--remove", "--global", "global-guide"]) + .await?; + ctx.symposium(&["use", "global-guide"]).await?; + + assert_eq!(tree(&staging_root(&ctx)), ""); + assert_eq!(tree(&user_skills(&ctx)), ""); + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), "global-guide"), + project_skills(&ctx, ".claude").join("global-guide") + ); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn removing_claude_removes_its_user_plugins() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude", "--add-agent", "codex"]) + .await?; + ctx.symposium(&["sync"]).await?; + assert!(user_skills(&ctx).join("global-guide").exists()); + + ctx.symposium(&["init", "--remove-agent", "claude"]).await?; + + assert!(!user_skills(&ctx).join("global-guide").exists()); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn directories_symposium_does_not_own_are_left_alone() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + let users_staged = staging_root(&ctx).join("global-guide"); + std::fs::create_dir_all(&users_staged)?; + std::fs::write(users_staged.join("notes.txt"), "mine")?; + let users_skill = user_skills(&ctx).join("global-guide"); + std::fs::create_dir_all(&users_skill)?; + std::fs::write( + users_skill.join("SKILL.md"), + "---\nname: global-guide\ndescription: mine\n---\n", + )?; + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + assert_eq!(tree(&users_staged), "notes.txt"); + assert_eq!(tree(&users_skill), "SKILL.md"); + let compiled: Vec = tree(&staging_root(&ctx)) + .lines() + .filter(|line| line.ends_with("/plugin.json") && !line.contains(".claude-plugin")) + .map(str::to_string) + .collect(); + assert_eq!(compiled.len(), 1, "{compiled:?}"); + let name = compiled[0].trim_end_matches("/plugin.json"); + assert!(name.starts_with("global-guide-"), "{name}"); + assert!( + user_skills(&ctx) + .join(name) + .join("skills/global-guide/SKILL.md") + .is_file() + ); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn editing_a_global_plugins_skill_updates_claudes_copy() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let source = ctx.sym.config_dir().join("plugins/global-guide/SKILL.md"); + let edited = std::fs::read_to_string(&source)?.replace("Global guidance.", "Edited."); + std::fs::write(&source, &edited)?; + ctx.symposium(&["sync"]).await?; + + assert_eq!( + std::fs::read_to_string( + user_skills(&ctx).join("global-guide/skills/global-guide/SKILL.md") + )?, + edited + ); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn claude_config_dir_relocates_the_delivery() { + with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { + let relocated = ctx.tempdir.join("relocated-claude"); + let value = relocated.clone().into_os_string(); + ctx.sym + .set_env(move |name| (name == "CLAUDE_CONFIG_DIR").then(|| value.clone())); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + assert!( + relocated + .join("skills/global-guide/.claude-plugin/plugin.json") + .is_file() + ); + assert!(!user_skills(&ctx).exists()); + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test] +async fn a_skill_shared_by_two_global_plugins_is_delivered_once() { + with_fixture( + TestMode::SimulationOnly, + &["dedup-source-origin0", "workspace0"], + async |mut ctx| { + ctx.sym.config.plugins.used = vec![ + UseEntry::Global("plugin-a".into()), + UseEntry::Global("plugin-b".into()), + ]; + ctx.sym.save_config()?; + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + assert_eq!( + find_installed_skill(&claude_skill_dirs(&ctx), "shared-skill"), + user_skills(&ctx).join("plugin-a/skills/shared-skill") + ); + assert!(!staging_root(&ctx).join("plugin-b").exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} diff --git a/tests/agent_plugins/codex.rs b/tests/agent_plugins/codex.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/agent_plugins/codex.rs @@ -0,0 +1 @@ + diff --git a/tests/agent_plugins/copilot.rs b/tests/agent_plugins/copilot.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/agent_plugins/copilot.rs @@ -0,0 +1 @@ + diff --git a/tests/agent_plugins/goose.rs b/tests/agent_plugins/goose.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/agent_plugins/goose.rs @@ -0,0 +1 @@ + diff --git a/tests/agent_plugins/main.rs b/tests/agent_plugins/main.rs new file mode 100644 index 000000000..205973b14 --- /dev/null +++ b/tests/agent_plugins/main.rs @@ -0,0 +1,5 @@ +mod antigravity; +mod claude; +mod codex; +mod copilot; +mod goose; diff --git a/tests/fixtures/agent-plugin-scopes0/Cargo.toml b/tests/fixtures/agent-plugin-scopes0/Cargo.toml new file mode 100644 index 000000000..2630fe352 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "scopes-host" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml b/tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml new file mode 100644 index 000000000..23da959b8 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml @@ -0,0 +1,9 @@ +hook-scope = "project" +sync-debounce-secs = 0 + +[defaults] +symposium-recommendations = false +user-plugins = true + +[plugins] +use = ["global-guide", { name = "workspace-guide", workspace = "$TEST_DIR" }] diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/always-guide/SKILL.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/always-guide/SKILL.md new file mode 100644 index 000000000..90aa33e66 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/always-guide/SKILL.md @@ -0,0 +1,7 @@ +--- +name: always-guide +description: Guidance active everywhere through `depends-on` +depends-on: "*" +--- + +Always-on guidance. diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/SKILL.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/SKILL.md new file mode 100644 index 000000000..ce05e92a0 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/SKILL.md @@ -0,0 +1,6 @@ +--- +name: global-guide +description: Guidance enabled with `use --global` +--- + +Global guidance. diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/reference.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/reference.md new file mode 100644 index 000000000..bccf4fb01 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-guide/reference.md @@ -0,0 +1 @@ +Reference material that travels with global-guide. diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/workspace-guide/SKILL.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/workspace-guide/SKILL.md new file mode 100644 index 000000000..ae26994d1 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/workspace-guide/SKILL.md @@ -0,0 +1,6 @@ +--- +name: workspace-guide +description: Guidance enabled with a workspace-scoped `use` +--- + +Workspace guidance. diff --git a/tests/fixtures/agent-plugin-scopes0/src/lib.rs b/tests/fixtures/agent-plugin-scopes0/src/lib.rs new file mode 100644 index 000000000..e69de29bb From 8bb524a1a915fd0ef07c54426a824792b7fa54b0 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 9 Oct 2026 11:05:23 -0300 Subject: [PATCH 3/6] docs: document global plugin delivery and the Claude Code probe --- md/design/agent-details/claude-code.md | 45 ++++++++++++++++++++++++++ md/design/agents.md | 42 +++++++++++++----------- md/design/important-flows.md | 12 +++++++ md/design/module-structure.md | 18 +++++++++-- md/design/sync-agent-flow.md | 11 +++++++ md/reference/agents/claude.md | 11 ++++++- md/reference/cargo-agents-sync.md | 18 +++++++++++ md/reference/cargo-agents-use.md | 9 ++++-- md/reference/configuration.md | 5 +-- md/rfds/agent-plugins/README.md | 29 ++++++++++++++++- 10 files changed, 173 insertions(+), 27 deletions(-) diff --git a/md/design/agent-details/claude-code.md b/md/design/agent-details/claude-code.md index e9641f108..dd2e83548 100644 --- a/md/design/agent-details/claude-code.md +++ b/md/design/agent-details/claude-code.md @@ -217,3 +217,48 @@ user's behalf); approve once via `/mcp`, or add the name to Registration is idempotent — if the entry already exists with the correct values, no changes are made. If the entry exists but has stale values (e.g. the binary moved), it is updated in place. + +## Plugin Delivery + +A plugin enabled with `use --global` reaches Claude Code as a +**skills-directory plugin**: symposium copies the compiled plugin directory +into the user skills directory, `~/.claude/skills//` +(`$CLAUDE_CONFIG_DIR/skills//` when that variable is set, in which case +Claude Code ignores `~/.claude/skills/`). Claude Code loads every folder there +that holds `.claude-plugin/plugin.json` as the plugin `@skills-dir`, in +place, in every project. Its skills are invoked as `/:`. + +Nothing is written to `settings.json`, `known_marketplaces.json` or +`installed_plugins.json`. A user who runs `claude plugin disable +@skills-dir` gets an `enabledPlugins` entry that symposium never +touches. + +Verified against Claude Code 2.1.284 by asking the agent (`claude plugin list +--json`, `claude plugin details`, and the `system/init` event of `claude -p`): + +- The plugin is listed in the first session after `cargo agents sync`, and + `claude plugin list` shows it without a prior session. +- An edit at the same version is picked up by the next session: the copy is + read in place, and nothing is cached under `~/.claude/plugins/cache/`. +- Removing the directory removes the plugin, with no residue and no error. +- The plugin takes its identity from its manifest, not its directory name, so + a copy moved to a hashed directory name keeps its name. +- A plugin written during the `SessionStart` hook (symposium's auto-sync) loads + in the next session, or after `/reload-plugins` in the current one. +- A plain `SKILL.md` folder of the user's own next to it is unaffected. + +The alternative, registering the staging root as a directory marketplace +(`extraKnownMarketplaces` plus `enabledPlugins`), also loads in place, but +Claude Code then writes `~/.claude/plugins/known_marketplaces.json` and +`installed_plugins.json` entries of its own that outlive the registration, and +at project scope two repositories using the same marketplace name loaded each +other's content. + +### Project scope + +A `/.claude/skills//` folder with the same layout loads as a +project-scoped `@skills-dir`, only in a folder the user has trusted (an +untrusted folder lists it as `(suppressed)@skills-dir`; plain project skills +still load). A user-level plugin of the same name shadows the project one, and +`claude plugin list` reports it. Symposium does not write project-scoped +plugins yet. diff --git a/md/design/agents.md b/md/design/agents.md index 80cafddf2..a89609bdb 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -21,33 +21,37 @@ The agent name is stored in `[agent] name` in either the user or project config. For each agent, `cargo agents` needs to know how to: 1. **Register hooks** — write the hook configuration so the agent calls `cargo-agents hook` on the right events. -2. **Install extensions** — place skill files (and eventually workflow/MCP definitions) where the agent expects them. +2. **Install extensions**: place skills where the agent loads them, as per-skill directories in the project or as a compiled plugin directory for the user. -Where these files go depends on whether the agent is configured at the user level or the project level (see [`sync --agent`](./sync-agent-flow.md)). +Hooks and MCP servers follow `hook-scope`. Skills follow the scope of whatever enabled them (see [`sync`](./sync-agent-flow.md)). ## Extension locations -When installing skills, `cargo agents` prefers vendor-neutral paths where possible: +Skills reach an agent in one of two units. -| Scope | Path | Supported by | -|-------|------|-------------| -| Project skills | `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose | -| Project skills | `.claude/skills//SKILL.md` | Claude Code (does not support `.agents/skills/`) | -| Project skills | `.kiro/skills//SKILL.md` | Kiro (uses its own path) | +**Per-skill directories in the project.** Everything that applies to the workspace (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) installs one directory per skill, preferring the vendor-neutral path: -At the project level, Claude Code requires `.claude/skills/`, Kiro requires `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose all support `.agents/skills/`. `cargo agents` uses the vendor-neutral `.agents/skills/` path whenever the agent supports it. +| Path | Agents | +|------|--------| +| `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose | +| `.claude/skills//SKILL.md` | Claude Code (does not read `.agents/skills/`) | +| `.kiro/skills//SKILL.md` | Kiro | -At the global level, each agent has its own path: +Symposium never writes per-skill directories at user scope. -| Agent | Global skills path | -|-------|-------------------| -| Antigravity CLI | `~/.gemini/config/skills//SKILL.md` | -| Claude Code | `~/.claude/skills//SKILL.md` | -| Copilot | *(no global skills path)* | -| Codex | `~/.agents/skills//SKILL.md` | -| Kiro | `~/.kiro/skills//SKILL.md` | -| OpenCode | `~/.agents/skills//SKILL.md` | -| Goose | `~/.agents/skills//SKILL.md` | +**Compiled plugin directories for the user.** A plugin that a `use --global` entry names, whatever `hook-scope` says, is compiled once into `~/.symposium/installed//`, an [Agent Plugins](https://agent-plugins.org/) directory carrying `plugin.json`, `.claude-plugin/plugin.json` with the same content, the `.symposium` marker and `skills//`. Each agent that can install a plugin for the user receives it through `Agent::sync_user_plugins`, and then gets no per-skill copies of that plugin's skills: + +| Agent | Delivery of a compiled plugin | +|-------|-------------------------------| +| Antigravity CLI | not yet: per-skill copies in the project | +| Claude Code | copied to `~/.claude/skills//` (`$CLAUDE_CONFIG_DIR/skills/` when set), loaded as `@skills-dir` | +| Codex CLI | not yet: per-skill copies in the project | +| GitHub Copilot | not yet: per-skill copies in the project | +| Goose | not yet: per-skill copies in the project | +| Kiro | no plugin unit: per-skill copies in the project | +| OpenCode | no plugin unit: per-skill copies in the project | + +Symposium writes these files itself and never runs an agent's own plugin command: delivery also runs inside the `SessionStart` hook, where nothing can answer a prompt. See [Claude Code plugin delivery](./agent-details/claude-code.md#plugin-delivery) for what was verified. --- diff --git a/md/design/important-flows.md b/md/design/important-flows.md index ad7410e1e..116f7a358 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -30,6 +30,18 @@ The consent prompt and the `use` / `search` / `status` commands that record deci The key code paths are in `discovery.rs`, `config.rs` (`PluginsConfig`, `UseEntry`), `pm/cargo/mod.rs` (`active_plugins`, `load_plugin`), `plugins.rs` (`PluginManifest::requires_use`), `predicate.rs` (`PredicateContext::is_used`), and `skills.rs` (`active_plugins`, `record_active`). +## Global plugin delivery + +A plugin named by a `use --global` entry is installed for the user rather than copied into each project, on every agent that can take a plugin directory. It runs inside `sync`, so `cargo agents sync`, `use`, and the hook-triggered auto-sync all do it. + +1. `skills::collect_skills` stamps each applicable skill with the index of its plugin in the active set, and `sync` deduplicates the skills as before, the first plugin to reach a skill keeping it. +2. `compile::global_plugins` groups the deduplicated skills by plugin and keeps the plugins `compile::installs_globally` accepts: named by a `[plugins] use` entry without a workspace (`PluginsConfig::is_used_globally`), and not workspace members. `hook-scope` is not consulted. +3. `compile::compile` writes one Agent Plugins directory per plugin under `/installed/`, writing only what changed and swapping it in by rename (`sync::sync_tree`), maintains the root's `.claude-plugin/marketplace.json`, and reaps the marked directories it did not compile (`sync::reap_unlisted`). It returns the `CompiledPlugin`s and the origins of the skills they carry. +4. For each configured agent, `Agent::sync_user_plugins` hands it the compiled set; `true` means the agent installed it, and the per-skill loop skips those skills for that agent. Claude Code copies each directory into its user skills directory (`agents/plugin_registration/claude.rs`); the other agents' modules return `false` until their delivery lands. +5. `sync::unregister_user_plugins` calls every unconfigured agent with an empty set, from `sync` and from `init`. + +The key code paths are in `compile.rs`, `agents/mod.rs` (`CompiledPlugin`, `Agent::sync_user_plugins`), `agents/plugin_registration/`, `sync.rs` (`sync`, `sync_tree`, `sync_plugin_dir`, `reap_unlisted`, `unregister_user_plugins`), `skills.rs` (`SkillWithGroupContext::plugin_index`), and `config.rs` (`PluginsConfig::is_used_globally`, `INSTALLED_PLUGINS_SUBDIR`, `Symposium::env_dir`). + ## Help rendering `cargo agents --help` (and `-h`, the bare `help` keyword, or no subcommand) is rendered by `help_render`, not by clap's default help. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index f81d20da4..21cdcc54c 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -15,11 +15,11 @@ What deliberately stays in Symposium is *validation*: defaults, inline-installat ### `config.rs` — application context -Everything hangs off the `Symposium` struct, which wraps the parsed `Config` with resolved paths for config, cache, and log directories. Two constructors: `from_environment()` for production and `from_dir()` for tests. +Everything hangs off the `Symposium` struct, which wraps the parsed `Config` with resolved paths for config, cache, and log directories. Two constructors: `from_environment()` for production and `from_dir()` for tests. Agent relocation variables (`CLAUDE_CONFIG_DIR`, ...) are read through `env_dir`, which consults the process environment only for a `from_environment()` instance, so a test can never reach a developer's real agent directories; tests inject one with `set_env`. Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, `[[registry]]` entries (`[[plugin-source]]` is the retired spelling, still accepted), defaults, `auto-update` (off/warn/on, default on), and the `[plugins]` enablement section. User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `registry_instances()` to build the effective registry `PmInstance`s directly (the builtin recommendations entry, the builtin `user-plugins` entry, then the configured ones): a git `[[registry]]` entry becomes a `GitPm`, a path entry a `PathPm`, each a trust root named for its registry (that name is what its plugins are attributed to). There is no `ResolvedRegistry`/`content_dir` intermediate — a git registry's cache directory and its refresh live on the `GitPm` itself. `package_managers(deps)` prepends the fixed cargo transport (a `CargoPm` built over the shared `deps` resolver) to those to make the active `PmRegistry`. `detached_managers()` is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs `cargo metadata`. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching, and returns it as an `Arc` so a `CargoPm` can hold one. -`PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }` — the durable record of a deliberate enablement, scoped to one workspace or to all), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods — `used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in` — all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. +`PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }`: the durable record of a deliberate enablement, scoped to one workspace or to all; a global entry also installs the plugin for the user, see [`compile.rs`](#compilers-compiled-plugins-for-the-user)), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods (`used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in`, `is_used_globally`) all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. ### `pm/cargo/workspace.rs` — cargo workspace resolution @@ -30,6 +30,8 @@ The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's m Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). +Plugin delivery is the third axis: `Agent::sync_user_plugins(sym, root, plugins, out)` reconciles one agent's user-level installation with the [compiled plugins](#compilers-compiled-plugins-for-the-user) (`CompiledPlugin { name, version, dir }`, all under the staging `root`), an empty slice removing everything symposium installed for it, and returns whether the agent took delivery. Each agent's logic lives in its own `agents/plugin_registration/.rs`, reached through one explicit match arm; Claude Code copies the directory into its user skills directory, where it loads as `@skills-dir`, and the agents without a delivery yet return `false`, which keeps their per-skill copies. + Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes `.agents/hooks.json` but `~/.gemini/config/hooks.json`; Copilot `.github/hooks/` but `~/.copilot/settings.json`), so `sync` calls the project-scoped functions at project scope rather than rooting the global path at the workspace. MCP registration is its own axis, and the one place where guessing is expensive: an agent's MCP file is usually *not* its hooks file, so a wrong target fails **silently** - symposium reports success for a file the agent never reads. `Agent::mcp_config_path(scope, project_root, home)` is the single seam holding that per-agent truth (the table lives in [agent details](./agent-details/README.md#mcp-server-configuration)), with `register_mcp_servers` / `unregister_mcp_servers` as the only entry points; there is no separate project/global pair of functions to keep in sync. `McpScope` is deliberately distinct from `HookScope`: Antigravity, Claude, OpenCode and Kiro have a project-level MCP file, while Codex, the Copilot CLI and Goose read only a user-level one, so `supports_project_mcp_scope` reports when project scope is downgraded rather than writing a file nobody reads. Entry *shape* varies too - OpenCode needs `{type, command: [bin, ...args], enabled}` and rejects its whole config file otherwise, which is why it has its own serializer (`opencode_server_to_json`) instead of the shared `{command, args}` one. @@ -46,6 +48,16 @@ One entry point, `sync(sym, deps, update)`. The caller supplies the `WorkspaceDe `sync` takes an `UpdateLevel` that it threads into skill resolution (`skills::collect_skills`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`. +Before the per-agent loop, `sync` [compiles](#compilers-compiled-plugins-for-the-user) the plugins enabled with `use --global`, and each agent's `sync_user_plugins` runs after its MCP registration; an agent that took delivery skips its per-skill copies of those plugins' skills. `unregister_user_plugins` hands an empty set to every agent no longer configured, and is also called by `init`, whichever hook scope it runs at. The directory helpers the agent deliveries share are `pub(crate)`: `sync_tree` (make a symposium-owned directory hold exactly a given tree plus the marker, writing a sibling and swapping it in by rename only when the content differs, and refusing a directory it does not own), `sync_plugin_dir` (the same from a source directory, debounced on the marker), `reap_unlisted` (remove the marked subdirectories a predicate does not keep), `collect_dir_contents`, `collect_skill_tree`, `recently_synced` and `has_symposium_marker`. The per-skill cleanup leaves compiled plugin directories to their agent's own reaping. + +### `compile.rs`: compiled plugins for the user + +Turns the plugins a `use --global` entry names into [Agent Plugins](https://agent-plugins.org/) directories under the staging root `/installed/` (`INSTALLED_PLUGINS_SUBDIR`), which each agent's delivery installs. + +- `installs_globally` is the eligibility rule: a `UseEntry::Global` names the plugin, by manifest name or by package name (a crate's `use` entry names the crate, whatever its manifest calls itself), and the plugin is not a workspace member. `hook-scope` is not an input. Registry plugins active by their own gate, workspace members, dependencies and workspace-scoped `use` entries stay project-scoped. +- `global_plugins` groups the deduplicated applicable skills by owning plugin, using `SkillWithGroupContext::plugin_index` rather than `Plugin::canonical`, which two registry entries declaring the same name share. +- `compile` names each directory with `normalize_name` (lowercase `[a-z0-9-]`, 1 to 64 characters, alphanumeric at both ends, no `--`: what every agent's loader accepts), suffixing a hash of the plugin's skill origins when two plugins normalize alike or a directory symposium does not own holds the slot. It writes `plugin.json` and `.claude-plugin/plugin.json` (`$schema` of Agent Plugins 1.0.0, `name`, `version` `0.0.0`), the marker, and `skills//` with each skill's whole directory, skipping a skill whose name is not a plain directory name and suffixing skill names a plugin carries twice. It maintains `.claude-plugin/marketplace.json` at the root and reaps marked directories it did not compile this run. + ### `plugins.rs` — plugin registry Loads plugin manifests from the configured registries and validates them into `Plugin`s. Loading goes through the [package-manager layer](#pm--package-managers): `load_registry` asks each **trusted** `PmRegistry` instance (the configured registries — the cargo transport is not a trust root) for its plugins via `active_plugins`. A `PathPm` returns each entry through `entry_plugin`: a `Symposium.toml` manifest read as-is, or a bare `SKILL.md` synthesized into a manifest (`standalone_skill_manifest`); dependency-embedded crate plugins never load here. Refreshing a git registry's content is the `GitPm`'s `refresh` operation, driven by `ensure_registries` (startup) and `sync_registries` (`plugin sync`). `scan_source_dir` remains as the offline form used by the `plugin validate` CLI, which points at an arbitrary directory rather than a configured registry; it walks the same [layout](#pm--package-managers) rules and synthesizes bare skills the same way. @@ -136,6 +148,8 @@ The enabled-dependency ids seed the same worklist: `discovery::enabled_dependenc Production `sync` shares one `PredicateContext` across the skill and MCP passes, so it calls `active_plugins` then `collect_skills` directly rather than the `skills_applicable_to` convenience wrapper (which builds its own context and is test-only). +Each applicable skill also carries `plugin_index`, the position of the plugin it was collected for in the active set, which is how [`compile.rs`](#compilers-compiled-plugins-for-the-user) attributes skills to the plugin they compile into. + Each applicable skill carries an **origin hash** (a `String`) describing *where its bytes live*, used at sync time for dedup and install-path disambiguation. `skill_origin_hash` computes it as an 8-hex-char prefix of SHA-256 over the `SKILL.md`'s **canonical** on-disk path — nothing else. Identity is the file's location, not which plugin manifest pointed at it: two references that resolve to the same file (the same crate reached through two chained plugins, or two `source.path` groups landing on the same bundle) produce the same hash and dedupe; skills at different paths stay distinct. Canonicalizing inside the hash is what makes that hold across discovery paths, since group scan dirs are canonicalized inconsistently — so on a platform whose temp prefix is a symlink (macOS `/var` → `/private/var`) the same file would otherwise hash two ways and install twice. Because the hash is the dedup key itself, a 32-bit collision between two genuinely distinct paths would silently drop one skill (rather than clashing loudly at install time) — a deliberate trade for carrying only a string, not a structured origin, to the sync layer. diff --git a/md/design/sync-agent-flow.md b/md/design/sync-agent-flow.md index fcc74d6cc..d890cbfee 100644 --- a/md/design/sync-agent-flow.md +++ b/md/design/sync-agent-flow.md @@ -25,6 +25,17 @@ Scans workspace dependencies, installs applicable skills into agent directories, 8. **Register hooks** — ensure symposium's global hook handler and MCP servers are registered for all configured agents. Unregister hooks for agents no longer in the config. Only symposium's own handler is registered (e.g., `cargo-agents hook claude pre-tool-use`) — individual plugin hooks are never written into agent configs. See [Hooks](./hooks.md) for the dispatch model. +## Plugins enabled for every workspace + +Between matching skills (step 4) and installing them (step 5), the plugins a `use --global` entry names are compiled for the user. `hook-scope` plays no part: it decides where symposium registers its own hook and MCP servers, not where plugins go. + +1. **Attribute and select**: every applicable skill knows the index of the plugin in the active set it was collected for (`SkillWithGroupContext::plugin_index`). After the usual dedup, where the first plugin to reach a skill keeps it, the skills of each plugin that `compile::installs_globally` accepts are grouped per plugin: a `[plugins] use` entry without a workspace names it (by manifest name or package name, hyphen/underscore-insensitively), and it is not a workspace member. A plugin left without skills is not compiled. +2. **Compile**: each selected plugin becomes one directory under the staging root `~/.symposium/installed/` (the `installed` subdirectory of the config dir): `plugin.json` and `.claude-plugin/plugin.json` with the same `$schema`, `name` and `version` (`0.0.0`), the `.symposium` marker, and each skill's whole directory, symlinks followed, under `skills//`. The plugin name is normalized to lowercase `[a-z0-9-]`, at most 64 characters; a name two plugins share, or whose slot holds a directory symposium does not own, gets a hash suffix. The expected tree is compared with what is on disk and written to a sibling and renamed into place only when it differs. The root also carries `.claude-plugin/marketplace.json` listing every compiled plugin, for agents pointed at it as a marketplace. Marked directories not compiled this run are removed; unmarked ones are left alone. +3. **Deliver**: after its hooks and MCP servers, each configured agent's `Agent::sync_user_plugins` reconciles its user-level copy with the compiled set. When it reports that it took delivery, the per-skill loop skips those plugins' skills for that agent, so the stale-skill cleanup removes copies a previous sync left. Agents that cannot take a user-level plugin keep receiving per-skill copies in the project. +4. **Unregister**: every agent that is no longer configured is called with an empty set, by `sync` and by `init` alike, so a removed agent loses the plugins symposium installed for it. + +The selection is user-level state, so every workspace compiles the same plugins, but their content is resolved in the workspace being synced: a plugin or skill that does not apply there is removed from user scope by that sync, and comes back when a workspace where it applies syncs. + ## Marker file Each skill directory symposium installs contains an empty `.symposium` file. Cleanup walks every agent's skills parent directory (`.claude/skills/`, `.agents/skills/`, `.kiro/skills/`) and reaps any subdirectory whose marker is present but which wasn't installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker. diff --git a/md/reference/agents/claude.md b/md/reference/agents/claude.md index 570d761fc..764698fd4 100644 --- a/md/reference/agents/claude.md +++ b/md/reference/agents/claude.md @@ -7,10 +7,19 @@ Config name: `claude` | Scope | Path | |-------|------| | Project | `.claude/skills//SKILL.md` | -| Global | `~/.claude/skills//SKILL.md` | Claude Code does not support the vendor-neutral `.agents/skills/` path. +## Plugins + +A plugin enabled with `cargo agents use --global` is installed for you as a plugin directory: + +| Scope | Path | +|-------|------| +| Global | `~/.claude/skills//` (`$CLAUDE_CONFIG_DIR/skills//` when set) | + +Claude Code loads it in every project as `@skills-dir`, and its skills are invoked as `/:`. Symposium writes no settings for it; `claude plugin disable @skills-dir` turns it off, and symposium leaves that choice alone. A plugin installed while a session runs appears in the next session, or after `/reload-plugins`. + ## Hooks Symposium merges hook entries into Claude Code's `settings.json`. diff --git a/md/reference/cargo-agents-sync.md b/md/reference/cargo-agents-sync.md index 2f244b69d..25fb47030 100644 --- a/md/reference/cargo-agents-sync.md +++ b/md/reference/cargo-agents-sync.md @@ -28,6 +28,24 @@ Must be run from within a Rust workspace. Performs the following steps: 7. **Register hooks** — ensures hooks and MCP servers are registered for all configured agents. Registers both global hooks (for all projects) and project-specific hooks (for the current project). Unregisters hooks for agents no longer in the config. +## Plugins enabled for every workspace + +A plugin you enabled with [`cargo agents use --global`](./cargo-agents-use.md) is installed for you, not copied into each project. Sync compiles it into one plugin directory under `~/.symposium/installed//` and gives that directory to every configured agent that can install a plugin for the user, whatever [`hook-scope`](./configuration.md) says: + +| Agent | Where the plugin goes | +|-------|-----------------------| +| Antigravity CLI | its skills, per project, as above | +| Claude Code | `~/.claude/skills//` (or `$CLAUDE_CONFIG_DIR/skills//`), loaded as the plugin `` in every project | +| Codex CLI | its skills, per project, as above | +| GitHub Copilot | its skills, per project, as above | +| Goose | its skills, per project, as above | +| Kiro | its skills, per project, as above | +| OpenCode | its skills, per project, as above | + +An agent that received the plugin gets no per-project copy of its skills, and copies an earlier sync left are cleaned up. Agents load plugins when a session starts, so a plugin installed by the automatic sync at session start appears in the next session (in Claude Code, `/reload-plugins` loads it right away). + +The plugin's skills are still selected in the workspace being synced, so a skill that does not apply there is left out until a workspace where it applies syncs. Dropping the `--global` entry, or removing the agent with `cargo agents init --remove-agent`, removes what symposium installed. + ## Consent prompt Before syncing, an interactive `cargo agents sync` asks about each dependency diff --git a/md/reference/cargo-agents-use.md b/md/reference/cargo-agents-use.md index 7d5db09c8..3fe7ba024 100644 --- a/md/reference/cargo-agents-use.md +++ b/md/reference/cargo-agents-use.md @@ -14,7 +14,7 @@ cargo agents use --remove [--global] | Flag | Description | |------|-------------| | `` | Plugin or crate name to enable | -| `--global` | Enable for every workspace instead of just the current one | +| `--global` | Enable for every workspace instead of just the current one, and install it for you rather than per project | | `--remove` | Drop a previously recorded enablement instead of adding one | ## Behavior @@ -31,7 +31,12 @@ use = [ ``` Then it runs a sync, so the plugin's skills install right away rather than -waiting for the next one. +waiting for the next one. A workspace entry installs them into the project. A +`--global` entry installs the plugin once for you, as a plugin directory, on +every agent that supports that (Claude Code today), and per project on the +others; see [`cargo agents sync`](./cargo-agents-sync.md#plugins-enabled-for-every-workspace). +Outside a Rust workspace, `--global` records the entry and the next sync in a +workspace installs it. Two things a `use` entry can enable: diff --git a/md/reference/configuration.md b/md/reference/configuration.md index 6b2706e44..550a4da87 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -38,7 +38,7 @@ path = "my-plugins" |-----|------|---------|-------------| | `auto-sync` | bool | `true` | Automatically run `cargo agents sync` during hook invocations. When enabled, skills are kept in sync with workspace dependencies without manual intervention. | | `agents-syncing` | bool | `true` | Include each workspace plugin's `.agents/skills/` default skill group, so skills you author there install into every configured agent's skill directory (such as `.claude/skills/` or `.kiro/skills/`). Skills that symposium itself installed — identified by the `.symposium` marker file — are never treated as sources. See [Workspace skills](../workspace-skills.md) for the user-guide overview, or [Agents syncing](#agents-syncing-mirror-user-authored-skills) below for details. | -| `hook-scope` | string | `"global"` | Where agent hooks are installed. `"global"` writes to the user's home directory (e.g., `~/`). `"project"` writes to the project directory, keeping hooks local to the workspace. | +| `hook-scope` | string | `"global"` | Where agent hooks are installed. `"global"` writes to the user's home directory (e.g., `~/`). `"project"` writes to the project directory, keeping hooks local to the workspace. It does not decide where plugins install: a `use` entry without a workspace installs its plugin for you under either value. | | `auto-update` | string | `"on"` | Controls automatic update behavior. `"off"` disables update checks entirely. `"warn"` checks the registry (at most once per 24 hours) and prints a message when a newer version is available. `"on"` automatically installs the update via `cargo install` and re-executes the command with the new binary. | ### Agents syncing: mirror user-authored skills @@ -133,7 +133,7 @@ Trust follows whoever supplies the *content*, not the package the content is abo | Key | Type | Default | Description | |-----|------|---------|-------------| | `auto-enable` | array of strings | `[]` | Dependency names whose embedded plugins load without being asked about. `"*"` pre-consents to every dependency. | -| `use` | array | `[]` | Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace) or `{ name = "...", workspace = "/path" }` (enabled only while working in that workspace root). | +| `use` | array | `[]` | Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace, and installed for you as a plugin directory on the agents that support it; see [`cargo agents sync`](./cargo-agents-sync.md#plugins-enabled-for-every-workspace)) or `{ name = "...", workspace = "/path" }` (enabled only while working in that workspace root). | | `disable` | array of strings | `[]` | Names that must never be enabled. Takes precedence over `auto-enable`, including over `"*"`. | Names are matched hyphen/underscore-insensitively, like crate names: `widget-lib` and `widget_lib` are the same entry. @@ -169,5 +169,6 @@ User-wide data lives under `~/.symposium/` by default. Override with environment | `~/.symposium/state.toml` | Persistent state (binary version stamp, last update check) | | `~/.symposium/telemetry/` | Telemetry event log, one JSONL file per day (created when `[telemetry] enabled = true` and events are recorded) | | `~/.symposium/plugins/` | User-defined plugins | +| `~/.symposium/installed/` | Plugins enabled with `use --global`, compiled for the agents to install | | `~/.symposium/cache/` | Cache directory (crate sources, plugin sources) | | `~/.symposium/logs/` | Log files (one per invocation, timestamped) | diff --git a/md/rfds/agent-plugins/README.md b/md/rfds/agent-plugins/README.md index 05841bb62..643731218 100644 --- a/md/rfds/agent-plugins/README.md +++ b/md/rfds/agent-plugins/README.md @@ -256,6 +256,8 @@ Both follow from evaluating a workspace-dependent gate once and keeping the resu The test is conservative. `depends-on()` and `workspace-member()` are workspace-dependent by definition, `shell(...)` and a relative `path_exists(...)` resolve against the workspace as their working directory, and a custom predicate is opaque. Only the empty set and `depends-on(*)` qualify; widening the test per predicate kind later is additive. +The implementation does not apply this test yet; see [Which plugins install for the user](#which-plugins-install-for-the-user). + ### One directory, several manifests This is the evidence for the single directory [Change in a nutshell](#change-in-a-nutshell) shows. An earlier draft treated Claude Code and Gemini CLI as separate emitters; probing the installed agents shows they do not need to be. Claude Code ignores a `plugin.json` at a package root and falls back to the directory name for identity, Agent Plugins clients ignore `.claude-plugin/`, and Gemini CLI reads only its own file. So one compiled directory carrying all three manifests, with the same content in each, satisfies every one of them: @@ -296,8 +298,33 @@ Codex CLI's install step copies a package into a version-keyed cache under `$COD Gemini CLI needs no configuration write at all: a package present at `~/.gemini/extensions//` is listed and enabled. Its own `extensions link` command blocks waiting on input, which is another reason the file is written directly rather than shelling out. +### Which plugins install for the user + +A plugin installs for the user when a `[plugins] use` entry without a workspace names it, by manifest name or by package name, and it is not a workspace member, whatever `hook-scope` says: `hook-scope` decides only where Symposium registers its own hook and MCP servers. No predicate analysis is done. Everything else (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) keeps the per-skill path. + +Predicates are still evaluated in the workspace being synced, so what a global plugin carries reflects the workspace that synced last, and a plugin that does not apply in that workspace leaves user scope until a workspace where it applies syncs. The plugin is compiled from the skills that survive the usual deduplication, the first plugin to reach a skill keeping it. + +The compiled manifests carry `"version": "0.0.0"` until packages declare a version of their own. Claude Code reads its copy in place, so the version does not decide when an edit reaches it. + +### How Claude Code receives a global plugin + +Claude Code gets a copy of the compiled directory in its user skills directory, `~/.claude/skills//` (`$CLAUDE_CONFIG_DIR/skills//` when set), where it loads in every project as the skills-directory plugin `@skills-dir`. This replaces the directory marketplace [Installation](#installation) describes for it. Both were tried against Claude Code 2.1.284, asking the agent what it loaded (`claude plugin list --json`, `claude plugin details`, and the `system/init` event of `claude -p`): + +| | Directory marketplace | Skills-directory plugin | +|---|---|---| +| First session after a sync | loaded in place; `claude plugin list` shows it from the second session | loaded in place; listed at once | +| Same-version edit | picked up by the next session | picked up by the next session | +| Writes to agent configuration | `settings.json`, plus `known_marketplaces.json` and `installed_plugins.json` entries Claude Code adds itself | none | +| After removal | `plugin list` keeps a disabled entry and `plugin marketplace list` the marketplace, unless Symposium edits files Claude Code manages | nothing listed, no error | +| Project scope, two repositories with the same plugin name | each loaded the other's content, through the one marketplace name they share | each loads its own, once the folder is trusted | + +Neither copies into `~/.claude/plugins/cache/`, and with either a plugin written during the `SessionStart` hook loads in the next session or after `/reload-plugins`. At user scope neither form is picked up by the Copilot CLI (1.0.89), and Goose (1.53.0) does not load the skills inside the copied directory. + ## Implementation status -This RFD describes proposed work. Implementation has not begun. +In progress. + +- Step 2: plugins enabled with `use --global` are compiled into `~/.symposium/installed//`, carrying `plugin.json`, `.claude-plugin/plugin.json`, the ownership marker and name disambiguation; no `gemini-extension.json` is emitted, since Gemini CLI support was replaced by Antigravity CLI. Project-scoped compilation is not implemented. +- Step 3: done for Claude Code ([How Claude Code receives a global plugin](#how-claude-code-receives-a-global-plugin)) under the eligibility rule in [Which plugins install for the user](#which-plugins-install-for-the-user). The other agents keep per-skill delivery until their delivery lands. See [Proposed: Agent Plugins packages](./proposed-reference.md) for the intended authoring reference and [Proposed: How extensions are installed](./proposed-install.md) for the resulting install locations. From 69d5293fcd2af6d4376b2826e9a2af9ae5004635 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 16 Oct 2026 15:33:07 -0300 Subject: [PATCH 4/6] feat(sync): install project plugins for Claude Code --- src/agents/mod.rs | 25 ++++ src/agents/plugin_registration/claude.rs | 24 +++- .../plugin_registration/claude/tests.rs | 35 +++++ src/compile.rs | 60 ++++++--- src/compile/tests.rs | 123 +++++++++++++++--- src/skills.rs | 2 +- src/sync.rs | 58 ++++++++- 7 files changed, 282 insertions(+), 45 deletions(-) diff --git a/src/agents/mod.rs b/src/agents/mod.rs index fbcd3b509..1275e65a0 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -388,6 +388,31 @@ impl Agent { } } + pub(crate) fn sync_project_plugins( + &self, + sym: &Symposium, + project_root: &Path, + root: &Path, + plugins: &[CompiledPlugin], + out: &Output, + ) -> Result { + match self { + Agent::Antigravity => Ok(false), + Agent::Claude => plugin_registration::claude::sync_project_plugins( + sym, + project_root, + root, + plugins, + out, + ), + Agent::Codex => Ok(false), + Agent::Copilot => Ok(false), + Agent::Goose => Ok(false), + Agent::Kiro => Ok(false), + Agent::OpenCode => Ok(false), + } + } + /// Remove hooks from the project-level agent config. pub fn unregister_project_hooks(&self, project_root: &Path, _sym: &Symposium, out: &Output) { match self { diff --git a/src/agents/plugin_registration/claude.rs b/src/agents/plugin_registration/claude.rs index 8324e6d2d..b0520be38 100644 --- a/src/agents/plugin_registration/claude.rs +++ b/src/agents/plugin_registration/claude.rs @@ -20,6 +20,23 @@ pub(crate) fn sync_user_plugins( Ok(true) } +pub(crate) fn sync_project_plugins( + sym: &Symposium, + project_root: &Path, + _root: &Path, + plugins: &[CompiledPlugin], + _out: &Output, +) -> Result { + let debounce = Duration::from_secs(sym.config.sync_debounce_secs); + let skills_dir = project_root.join(".claude").join("skills"); + for dir in sync_skills_dir_plugins(&skills_dir, plugins, debounce) { + if let Err(e) = crate::sync::ignore_in_git(&dir) { + warn(format!("{e:#}")); + } + } + Ok(true) +} + /// When set, `CLAUDE_CONFIG_DIR` replaces `~/.claude` and Claude Code ignores /// `~/.claude/skills`. fn user_skills_dir(sym: &Symposium) -> PathBuf { @@ -28,7 +45,11 @@ fn user_skills_dir(sym: &Symposium) -> PathBuf { .join("skills") } -fn sync_skills_dir_plugins(skills_dir: &Path, plugins: &[CompiledPlugin], debounce: Duration) { +fn sync_skills_dir_plugins( + skills_dir: &Path, + plugins: &[CompiledPlugin], + debounce: Duration, +) -> BTreeSet { let mut installed = BTreeSet::new(); for plugin in plugins { let Some(dest) = destination(skills_dir, &plugin.name) else { @@ -63,6 +84,7 @@ fn sync_skills_dir_plugins(skills_dir: &Path, plugins: &[CompiledPlugin], deboun crate::sync::reap_unlisted(skills_dir, |dir| { installed.contains(dir) || !crate::compile::is_plugin_dir(dir) }); + installed } fn destination(skills_dir: &Path, name: &str) -> Option { diff --git a/src/agents/plugin_registration/claude/tests.rs b/src/agents/plugin_registration/claude/tests.rs index cb0d462e8..8efc1c308 100644 --- a/src/agents/plugin_registration/claude/tests.rs +++ b/src/agents/plugin_registration/claude/tests.rs @@ -120,6 +120,41 @@ fn delivery_copies_plugins_and_reaps_only_what_symposium_installed() { } } +#[test] +fn project_delivery_lands_in_the_project_and_stays_out_of_git() { + let tmp = tempfile::tempdir().unwrap(); + let mut sym = Symposium::from_dir(&tmp.path().join("home")); + sym.config.sync_debounce_secs = 0; + let project = tmp.path().join("project"); + let staging = project.join(".symposium/plugins"); + let delivered = project.join(".claude/skills/fresh"); + let deliver = |plugins: &[CompiledPlugin]| { + sync_project_plugins(&sym, &project, &staging, plugins, &Output::quiet()).unwrap() + }; + + assert!(deliver(&[compiled(&staging, "fresh", "v1")])); + assert!(delivered.join(".claude-plugin/plugin.json").is_file()); + assert_eq!( + fs::read_to_string(delivered.join(".gitignore")).unwrap(), + "*\n" + ); + assert!(!sym.home_dir().join(".claude").exists()); + + deliver(&[compiled(&staging, "fresh", "v2")]); + assert_eq!( + fs::read_to_string(delivered.join("skills/guide/SKILL.md")).unwrap(), + "v2" + ); + assert_eq!( + fs::read_to_string(delivered.join(".gitignore")).unwrap(), + "*\n", + "replacing the content keeps the directory out of git" + ); + + deliver(&[]); + assert!(!delivered.exists()); +} + #[test] fn delivery_never_writes_claude_settings() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/compile.rs b/src/compile.rs index 973ea1966..b2ea63c8e 100644 --- a/src/compile.rs +++ b/src/compile.rs @@ -22,6 +22,8 @@ const MARKETPLACE_FILE: &str = "marketplace.json"; const MARKETPLACE_NAME: &str = "symposium"; const SKILLS_DIR: &str = "skills"; const MAX_NAME_LEN: usize = 64; +const PROJECT_DIR: &str = ".symposium"; +const PROJECT_PLUGINS_SUBDIR: &str = "plugins"; fn installs_globally(config: &PluginsConfig, plugin: &Plugin) -> bool { !plugin.workspace_member @@ -34,33 +36,38 @@ pub(crate) fn is_plugin_dir(dir: &Path) -> bool { dir.join(CLAUDE_MANIFEST_DIR).join(MANIFEST_FILE).is_file() } -pub(crate) struct GlobalPlugin<'a> { +pub(crate) fn project_plugins_root(project_root: &Path) -> PathBuf { + project_root.join(PROJECT_DIR).join(PROJECT_PLUGINS_SUBDIR) +} + +pub(crate) struct PluginSkills<'a> { name: &'a str, skills: Vec<&'a SkillWithGroupContext>, } -pub(crate) fn global_plugins<'a>( +pub(crate) fn plugins_by_scope<'a>( config: &PluginsConfig, active: &'a [Plugin], skills: &[&'a SkillWithGroupContext], -) -> Vec> { - let global: Vec = active - .iter() - .map(|plugin| installs_globally(config, plugin)) - .collect(); +) -> (Vec>, Vec>) { let mut grouped: BTreeMap> = BTreeMap::new(); for skill in skills { - if global[skill.plugin_index] { - grouped.entry(skill.plugin_index).or_default().push(skill); - } + grouped.entry(skill.plugin_index).or_default().push(skill); } - grouped - .into_iter() - .map(|(index, skills)| GlobalPlugin { - name: &active[index].manifest.name, + let (mut global, mut project) = (Vec::new(), Vec::new()); + for (index, skills) in grouped { + let plugin = &active[index]; + let scope = if installs_globally(config, plugin) { + &mut global + } else { + &mut project + }; + scope.push(PluginSkills { + name: &plugin.manifest.name, skills, - }) - .collect() + }); + } + (global, project) } #[derive(Default)] @@ -69,7 +76,22 @@ pub(crate) struct Compiled { pub skill_origins: BTreeSet, } -pub(crate) fn compile(root: &Path, plugins: &[GlobalPlugin], debounce: Duration) -> Compiled { +pub(crate) fn compile_project( + project_root: &Path, + plugins: &[PluginSkills], + debounce: Duration, +) -> Compiled { + let dir = project_root.join(PROJECT_DIR); + if !plugins.is_empty() + && let Err(e) = sync::ignore_in_git(&dir) + { + warn(format!("skipping project plugins: {e:#}")); + return Compiled::default(); + } + compile(&project_plugins_root(project_root), plugins, debounce) +} + +pub(crate) fn compile(root: &Path, plugins: &[PluginSkills], debounce: Duration) -> Compiled { let mut compiled = Compiled::default(); for (plugin, name) in plugins.iter().zip(plugin_dir_names(root, plugins)) { let Some(name) = name else { @@ -124,7 +146,7 @@ fn normalize_name(name: &str) -> Option { (!out.is_empty()).then(|| out.to_string()) } -fn plugin_dir_names(root: &Path, plugins: &[GlobalPlugin]) -> Vec> { +fn plugin_dir_names(root: &Path, plugins: &[PluginSkills]) -> Vec> { let names: Vec> = plugins.iter().map(|p| normalize_name(p.name)).collect(); let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); for name in names.iter().flatten() { @@ -156,7 +178,7 @@ fn suffixed(name: &str, hash: &str) -> String { format!("{}-{hash}", base.trim_end_matches('-')) } -fn skill_dirs<'a>(plugin: &GlobalPlugin<'a>) -> Vec<(String, &'a SkillWithGroupContext)> { +fn skill_dirs<'a>(plugin: &PluginSkills<'a>) -> Vec<(String, &'a SkillWithGroupContext)> { let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); for skill in &plugin.skills { *counts.entry(skill.skill.name()).or_default() += 1; diff --git a/src/compile/tests.rs b/src/compile/tests.rs index c8f4f1557..15a5a421c 100644 --- a/src/compile/tests.rs +++ b/src/compile/tests.rs @@ -88,60 +88,87 @@ fn is_valid_plugin_name(name: &str) -> bool { } #[test] -fn only_a_global_use_entry_naming_the_plugin_installs_it_for_the_user() { +fn a_global_use_entry_installs_a_plugin_for_the_user_and_anything_else_for_the_project() { let mut crate_plugin = validated(PluginKind::Package, "cargo", "crate_a", ""); crate_plugin.manifest.name = "a-tools".into(); let mut member = registry("house-style", ""); member.workspace_member = true; + let uses = |used: Vec| PluginsConfig { + used, + ..Default::default() + }; let workspace_use = UseEntry::Workspace { name: "dormant-guide".into(), workspace: "/ws".into(), }; + let consented = PluginsConfig { + auto_enable: vec!["lopdf".into()], + ..Default::default() + }; + let skill_md = Path::new("/nonexistent/SKILL.md"); + let skills = [skill("guide", "11111111", skill_md, 0)]; + let skills: Vec<&SkillWithGroupContext> = skills.iter().collect(); let cases = [ ( "global use on a dormant registry plugin", registry("dormant-guide", ""), - vec![global("dormant-guide")], + uses(vec![global("dormant-guide")]), true, ), ( "global use on a crate, by its crate name", crate_plugin, - vec![global("crate-a")], + uses(vec![global("crate-a")]), true, ), ( "workspace use", registry("dormant-guide", ""), - vec![workspace_use], + uses(vec![workspace_use]), false, ), ( "registry plugin active by depends-on(*), no use", registry("always-guide", "depends-on = [\"*\"]"), - vec![], + uses(vec![]), false, ), ( "dependency-gated registry plugin, no use", registry("serde-guide", "depends-on = [\"serde\"]"), - vec![], + uses(vec![]), false, ), ( "workspace member named by a global use", member, - vec![global("house-style")], + uses(vec![global("house-style")]), + false, + ), + ( + "consented dependency", + validated(PluginKind::Package, "cargo", "lopdf", ""), + consented, false, ), ]; - for (label, plugin, used, expected) in cases { - let config = PluginsConfig { - used, - ..Default::default() + for (label, plugin, config, global) in cases { + let active = [plugin]; + let (user, project) = plugins_by_scope(&config, &active, &skills); + let names = |plugins: Vec| { + plugins + .into_iter() + .map(|p| p.name.to_string()) + .collect::>() + }; + let name = active[0].manifest.name.clone(); + let expected = if global { + (vec![name], vec![]) + } else { + (vec![], vec![name]) }; - assert_eq!(installs_globally(&config, &plugin), expected, "{label}"); + assert_eq!((names(user), names(project)), expected, "{label}"); } } @@ -202,10 +229,10 @@ fn colliding_names_are_all_suffixed_and_unsluggable_ones_skipped() { long_lower.as_str(), long_upper.as_str(), ]; - let plugins: Vec = names + let plugins: Vec = names .iter() .zip(&skills) - .map(|(name, skill)| GlobalPlugin { + .map(|(name, skill)| PluginSkills { name, skills: vec![skill], }) @@ -255,7 +282,7 @@ fn compile_writes_one_self_contained_directory_per_plugin() { skill("dup", "22222222", &dup_two, 0), skill("../escape", "eeeeeeee", &escaping, 0), ]; - let plugins = [GlobalPlugin { + let plugins = [PluginSkills { name: "Guide_Tools", skills: skills.iter().collect(), }]; @@ -316,7 +343,7 @@ fn compile_follows_symlinks_in_skill_content() { compile( &root, - &[GlobalPlugin { + &[PluginSkills { name: "links", skills: skills.iter().collect(), }], @@ -338,11 +365,11 @@ fn recompiling_writes_only_what_changed_and_reaps_what_left() { skill("keep", "11111111", &keep_md, 0), skill("gone", "22222222", &gone_md, 1), ]; - let keep = GlobalPlugin { + let keep = PluginSkills { name: "keep", skills: vec![&skills[0]], }; - let gone = GlobalPlugin { + let gone = PluginSkills { name: "gone", skills: vec![&skills[1]], }; @@ -358,7 +385,7 @@ fn recompiling_writes_only_what_changed_and_reaps_what_left() { .set_modified(an_hour_ago) .unwrap(); - let keep = GlobalPlugin { + let keep = PluginSkills { name: "keep", skills: vec![&skills[0]], }; @@ -384,7 +411,7 @@ fn recompiling_writes_only_what_changed_and_reaps_what_left() { ); fs::write(&keep_md, "---\nname: keep\ndescription: edited\n---\n").unwrap(); - let keep = GlobalPlugin { + let keep = PluginSkills { name: "keep", skills: vec![&skills[0]], }; @@ -415,10 +442,64 @@ fn a_skill_reached_by_two_global_plugins_compiles_into_the_first() { ..Default::default() }; - let grouped = global_plugins(&config, &active, &deduplicated.iter().collect::>()); + let (grouped, project) = + plugins_by_scope(&config, &active, &deduplicated.iter().collect::>()); assert_eq!(grouped.len(), 1, "the plugin dedup emptied is dropped"); assert_eq!(grouped[0].name, "first"); + assert!(project.is_empty(), "a plugin with no skills is in no scope"); +} + +#[test] +fn project_plugins_compile_under_the_projects_gitignored_symposium_directory() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + let skill_md = write_skill(&tmp.path().join("src/guide"), "guide"); + let skills = [skill("guide", "11111111", &skill_md, 0)]; + let plugins = || { + [PluginSkills { + name: "guide", + skills: skills.iter().collect(), + }] + }; + + compile_project(&project, &[], Duration::ZERO); + assert!( + !project.join(".symposium").exists(), + "nothing to compile writes nothing" + ); + + let compiled = compile_project(&project, &plugins(), Duration::ZERO); + assert_eq!( + compiled.plugins[0].dir, + project_plugins_root(&project).join("guide") + ); + assert!( + project + .join(".symposium/plugins/guide/plugin.json") + .is_file() + ); + assert_eq!( + fs::read_to_string(project.join(".symposium/.gitignore")).unwrap(), + "*\n" + ); + + fs::write(project.join(".symposium/.gitignore"), "edited\n").unwrap(); + compile_project(&project, &plugins(), Duration::ZERO); + assert_eq!( + fs::read_to_string(project.join(".symposium/.gitignore")).unwrap(), + "edited\n", + "an existing .gitignore is left alone" + ); + + let blocked = tmp.path().join("blocked"); + fs::create_dir_all(&blocked).unwrap(); + fs::write(blocked.join(".symposium"), "").unwrap(); + let compiled = compile_project(&blocked, &plugins(), Duration::ZERO); + assert!( + compiled.plugins.is_empty(), + "no project plugins when they cannot be kept out of git" + ); } #[test] diff --git a/src/skills.rs b/src/skills.rs index caecbef57..2cf9547dc 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -387,7 +387,7 @@ fn find_skill_files_in( ancestors: &mut crate::dir_walk::Ancestors, out: &mut Vec, ) { - if dir.join(crate::sync::MARKER_FILE).exists() { + if crate::sync::has_symposium_marker(dir) { return; } let entries = match std::fs::read_dir(dir) { diff --git a/src/sync.rs b/src/sync.rs index c7feb882d..9d2b9fb5b 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -76,7 +76,16 @@ fn mark_generated_skill_directory(dir: &Path) -> Result<()> { /// skill directory? Returns `false` for user-authored skills and for any /// directory symposium did not create. pub(crate) fn has_symposium_marker(dir: &Path) -> bool { - dir.join(MARKER_FILE).exists() + dir.join(MARKER_FILE).is_file() +} + +pub(crate) fn ignore_in_git(dir: &Path) -> Result<()> { + let path = dir.join(".gitignore"); + if path.is_file() { + return Ok(()); + } + fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; + fs::write(&path, "*\n").with_context(|| format!("write {}", path.display())) } /// One entry of a source tree as the installer sees it: a path relative to the @@ -573,8 +582,10 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let mut installed_dirs: BTreeSet = BTreeSet::new(); let plugins_root = sym.config_dir().join(INSTALLED_PLUGINS_SUBDIR); - let global = compile::global_plugins(&sym.config.plugins, &active, &to_install); + let project_plugins_root = compile::project_plugins_root(&project_root); + let (global, project) = compile::plugins_by_scope(&sym.config.plugins, &active, &to_install); let compiled = compile::compile(&plugins_root, &global, debounce); + let project_compiled = compile::compile_project(&project_root, &project, debounce); for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; @@ -628,9 +639,28 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve ); false }); + let delivered_to_project = agent + .sync_project_plugins( + sym, + &project_root, + &project_plugins_root, + &project_compiled.plugins, + out, + ) + .unwrap_or_else(|e| { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to install project plugins for {agent_name}: {e:#}"), + }, + ); + false + }); for entry in &to_install { - if delivered && compiled.skill_origins.contains(&entry.origin_hash) { + if delivered && compiled.skill_origins.contains(&entry.origin_hash) + || delivered_to_project + && project_compiled.skill_origins.contains(&entry.origin_hash) + { continue; } let skill_name = entry.skill.name(); @@ -753,6 +783,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve out, ); } + let _ = agent.sync_project_plugins(sym, &project_root, &project_plugins_root, &[], out); } } unregister_user_plugins(sym, out); @@ -965,6 +996,27 @@ mod tests { assert!(!dir_contents_differ(&src, &dst).unwrap()); } + #[test] + fn only_a_marker_file_marks_a_directory() { + let tmp = tempfile::tempdir().unwrap(); + let marked = tmp.path().join("marked"); + let project = tmp.path().join("project"); + let plain = tmp.path().join("plain"); + for dir in [&marked, &project, &plain] { + fs::create_dir_all(dir.join("guide")).unwrap(); + fs::write(dir.join("guide/SKILL.md"), "").unwrap(); + } + fs::write(marked.join(MARKER_FILE), "").unwrap(); + fs::create_dir(project.join(MARKER_FILE)).unwrap(); + + for (dir, is_marked) in [(&marked, true), (&project, false), (&plain, false)] { + assert_eq!(has_symposium_marker(dir), is_marked, "{}", dir.display()); + let mut found = Vec::new(); + skills::find_skill_files_recursive(dir, &mut found); + assert_eq!(found.is_empty(), is_marked, "{}", dir.display()); + } + } + /// Managed metadata is excluded from the comparison, so writing the marker /// and gitignore into the destination does not make it look changed. #[test] From 59f5a8c662ee370048fb8527f04a59b50e7c9c13 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 16 Oct 2026 15:33:07 -0300 Subject: [PATCH 5/6] test(sync): cover project plugin delivery end to end --- tests/agent_plugins/claude.rs | 165 ++++++++++++++++++++++++++++++---- tests/custom_predicates.rs | 24 ++--- tests/enablement.rs | 21 +---- tests/init_sync.rs | 153 ++++++++++++++----------------- 4 files changed, 231 insertions(+), 132 deletions(-) diff --git a/tests/agent_plugins/claude.rs b/tests/agent_plugins/claude.rs index 3bab9e137..2506f43fa 100644 --- a/tests/agent_plugins/claude.rs +++ b/tests/agent_plugins/claude.rs @@ -24,10 +24,25 @@ fn project_skills(ctx: &TestContext, agent_dir: &str) -> PathBuf { .join("skills") } +fn project_symposium_dir(ctx: &TestContext) -> PathBuf { + ctx.workspace_root.as_ref().unwrap().join(".symposium") +} + +fn project_staging(ctx: &TestContext) -> PathBuf { + project_symposium_dir(ctx).join("plugins") +} + fn claude_skill_dirs(ctx: &TestContext) -> Vec { vec![project_skills(ctx, ".claude"), user_skills(ctx)] } +fn project_plugin_skill(ctx: &TestContext, plugin: &str, skill: &str) -> PathBuf { + project_skills(ctx, ".claude") + .join(plugin) + .join("skills") + .join(skill) +} + fn drop_use(ctx: &mut TestContext, name: &str) -> anyhow::Result<()> { ctx.sym .config @@ -119,7 +134,7 @@ async fn a_global_plugin_reaches_claude_once_and_other_agents_keep_per_skill_cop for skill in ["workspace-guide", "always-guide"] { assert_eq!( find_installed_skill(&claude_skill_dirs(&ctx), skill), - project_skills(&ctx, ".claude").join(skill) + project_plugin_skill(&ctx, skill, skill) ); } for agent_dir in [".agents", ".kiro"] { @@ -133,6 +148,89 @@ async fn a_global_plugin_reaches_claude_once_and_other_agents_keep_per_skill_cop .unwrap(); } +#[tokio::test] +async fn project_plugins_reach_claude_as_plugins_in_the_project() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0", "member-agents-skills0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + expect![[r#" + always-guide/ + always-guide/.claude-plugin/ + always-guide/.claude-plugin/plugin.json + always-guide/.gitignore + always-guide/.symposium + always-guide/plugin.json + always-guide/skills/ + always-guide/skills/always-guide/ + always-guide/skills/always-guide/SKILL.md + tool/ + tool/.claude-plugin/ + tool/.claude-plugin/plugin.json + tool/.gitignore + tool/.symposium + tool/plugin.json + tool/skills/ + tool/skills/plain-notes/ + tool/skills/plain-notes/SKILL.md + tool/skills/tool-maintainer/ + tool/skills/tool-maintainer/SKILL.md + workspace-guide/ + workspace-guide/.claude-plugin/ + workspace-guide/.claude-plugin/plugin.json + workspace-guide/.gitignore + workspace-guide/.symposium + workspace-guide/plugin.json + workspace-guide/skills/ + workspace-guide/skills/workspace-guide/ + workspace-guide/skills/workspace-guide/SKILL.md"#]] + .assert_eq(&tree(&project_skills(&ctx, ".claude"))); + expect![[r#" + .gitignore + plugins/ + plugins/.claude-plugin/ + plugins/.claude-plugin/marketplace.json + plugins/always-guide/ + plugins/always-guide/.claude-plugin/ + plugins/always-guide/.claude-plugin/plugin.json + plugins/always-guide/.symposium + plugins/always-guide/plugin.json + plugins/always-guide/skills/ + plugins/always-guide/skills/always-guide/ + plugins/always-guide/skills/always-guide/SKILL.md + plugins/tool/ + plugins/tool/.claude-plugin/ + plugins/tool/.claude-plugin/plugin.json + plugins/tool/.symposium + plugins/tool/plugin.json + plugins/tool/skills/ + plugins/tool/skills/plain-notes/ + plugins/tool/skills/plain-notes/SKILL.md + plugins/tool/skills/tool-maintainer/ + plugins/tool/skills/tool-maintainer/SKILL.md + plugins/workspace-guide/ + plugins/workspace-guide/.claude-plugin/ + plugins/workspace-guide/.claude-plugin/plugin.json + plugins/workspace-guide/.symposium + plugins/workspace-guide/plugin.json + plugins/workspace-guide/skills/ + plugins/workspace-guide/skills/workspace-guide/ + plugins/workspace-guide/skills/workspace-guide/SKILL.md"#]] + .assert_eq(&tree(&project_symposium_dir(&ctx))); + assert_eq!( + std::fs::read_to_string(project_symposium_dir(&ctx).join(".gitignore"))?, + "*\n" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + #[tokio::test] async fn hook_scope_does_not_decide_where_a_global_plugin_installs() { with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { @@ -147,7 +245,7 @@ async fn hook_scope_does_not_decide_where_a_global_plugin_installs() { ); assert_eq!( find_installed_skill(&claude_skill_dirs(&ctx), "workspace-guide"), - project_skills(&ctx, ".claude").join("workspace-guide") + project_plugin_skill(&ctx, "workspace-guide", "workspace-guide") ); let settings: serde_json::Value = serde_json::from_str(&std::fs::read_to_string( ctx.sym.home_dir().join(".claude/settings.json"), @@ -171,6 +269,7 @@ async fn another_workspace_shares_the_global_plugin_and_gets_no_project_copy() { ctx.symposium(&["sync"]).await?; let staged = tree(&staging_root(&ctx)); let delivered = tree(&user_skills(&ctx)); + let first_project = tree(&project_skills(&ctx, ".claude")); let other = ctx.tempdir.join("other-workspace"); write_workspace(&other); @@ -183,6 +282,7 @@ async fn another_workspace_shares_the_global_plugin_and_gets_no_project_copy() { assert_eq!(tree(&staging_root(&ctx)), staged); assert_eq!(tree(&user_skills(&ctx)), delivered); + assert_eq!(tree(&project_skills(&ctx, ".claude")), first_project); let other_skills = other.join(".claude/skills"); assert!( find_installed_skills(std::slice::from_ref(&other_skills), "global-guide").is_empty() @@ -199,31 +299,45 @@ async fn another_workspace_shares_the_global_plugin_and_gets_no_project_copy() { } #[tokio::test] -async fn dropping_the_global_use_removes_what_symposium_wrote_and_reports_it() { +async fn dropping_a_use_removes_what_symposium_wrote_and_reports_it() { with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; let installed = ctx.sync_with_report(tracing::Level::INFO).await?; - assert!( - installed.iter().any(|e| e["kind"] == "plugin_installed" - && e["plugin"] == "global-guide" - && e["agent"] == "claude"), - "{installed:#?}" - ); + for plugin in ["global-guide", "workspace-guide"] { + assert!( + installed.iter().any(|e| e["kind"] == "plugin_installed" + && e["plugin"] == plugin + && e["agent"] == "claude"), + "{installed:#?}" + ); + } drop_use(&mut ctx, "global-guide")?; + drop_use(&mut ctx, "workspace-guide")?; let removed = ctx.sync_with_report(tracing::Level::INFO).await?; for path in [ staging_root(&ctx).join("global-guide"), user_skills(&ctx).join("global-guide"), + project_staging(&ctx).join("workspace-guide"), + project_skills(&ctx, ".claude").join("workspace-guide"), ] { assert!(!path.exists(), "{} is left behind", path.display()); - let shown = ctx.normalize_paths(&symposium::output::display_path(&path)); + let tail = format!( + "/{}", + path.strip_prefix(&ctx.tempdir)? + .to_string_lossy() + .replace('\\', "/") + ); assert!( removed.iter().any(|e| e["kind"] == "skill_removed" - && ctx.normalize_paths(e["path"].as_str().unwrap()) == shown), - "{shown} removal not reported: {removed:#?}" + && e["path"] + .as_str() + .unwrap() + .replace('\\', "/") + .ends_with(&tail)), + "{tail} removal not reported: {removed:#?}" ); } assert!(!staging_root(&ctx).join(".claude-plugin").exists()); @@ -234,10 +348,11 @@ async fn dropping_the_global_use_removes_what_symposium_wrote_and_reports_it() { } #[tokio::test] -async fn a_global_use_turned_workspace_use_returns_to_per_skill_copies() { +async fn moving_a_plugin_between_scopes_leaves_nothing_behind() { with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; + let user_scope = tree(&user_skills(&ctx)); ctx.symposium(&["use", "--remove", "--global", "global-guide"]) .await?; @@ -247,8 +362,19 @@ async fn a_global_use_turned_workspace_use_returns_to_per_skill_copies() { assert_eq!(tree(&user_skills(&ctx)), ""); assert_eq!( find_installed_skill(&claude_skill_dirs(&ctx), "global-guide"), - project_skills(&ctx, ".claude").join("global-guide") + project_plugin_skill(&ctx, "global-guide", "global-guide") ); + + ctx.symposium(&["use", "--remove", "global-guide"]).await?; + ctx.symposium(&["use", "--global", "global-guide"]).await?; + + assert_eq!(tree(&user_skills(&ctx)), user_scope); + for path in [ + project_staging(&ctx).join("global-guide"), + project_skills(&ctx, ".claude").join("global-guide"), + ] { + assert!(!path.exists(), "{} is left behind", path.display()); + } Ok(()) }) .await @@ -256,16 +382,23 @@ async fn a_global_use_turned_workspace_use_returns_to_per_skill_copies() { } #[tokio::test] -async fn removing_claude_removes_its_user_plugins() { +async fn removing_claude_removes_its_plugins() { with_fixture(TestMode::SimulationOnly, SCOPES, async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude", "--add-agent", "codex"]) .await?; ctx.symposium(&["sync"]).await?; assert!(user_skills(&ctx).join("global-guide").exists()); + assert!( + project_skills(&ctx, ".claude") + .join("always-guide") + .exists() + ); ctx.symposium(&["init", "--remove-agent", "claude"]).await?; - assert!(!user_skills(&ctx).join("global-guide").exists()); + + ctx.symposium(&["sync"]).await?; + assert_eq!(tree(&project_skills(&ctx, ".claude")), ""); Ok(()) }) .await diff --git a/tests/custom_predicates.rs b/tests/custom_predicates.rs index 87217a2f2..2488cb06f 100644 --- a/tests/custom_predicates.rs +++ b/tests/custom_predicates.rs @@ -24,14 +24,14 @@ async fn sync_custom_predicate_installs_skill() { let script_path = ctx.tempdir.join("bp-checker.sh"); write_script(&script_path, "exit 0"); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); let skill_dir = skills_dir.join("bp-skill"); assert!( @@ -59,14 +59,14 @@ async fn sync_custom_predicate_fails_skips_skill() { let script_path = ctx.tempdir.join("bp-checker.sh"); write_script(&script_path, "exit 1"); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); let entries: Vec<_> = std::fs::read_dir(&skills_dir) .ok() @@ -104,14 +104,14 @@ async fn sync_custom_predicate_receives_correct_argument() { r#"if [ "$1" = "cli" ]; then exit 0; else exit 1; fi"#, ); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); assert!( skills_dir.join("bp-skill").join("SKILL.md").exists(), @@ -138,14 +138,14 @@ async fn sync_custom_predicate_wrong_argument_fails() { r#"if [ "$1" = "web" ]; then exit 0; else exit 1; fi"#, ); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); let entries: Vec<_> = std::fs::read_dir(&skills_dir) .ok() @@ -178,14 +178,14 @@ async fn sync_custom_predicate_cross_plugin() { let script_path = ctx.tempdir.join("cross-checker.sh"); write_script(&script_path, "exit 0"); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); assert!( skills_dir.join("consumer-skill").join("SKILL.md").exists(), @@ -214,14 +214,14 @@ async fn sync_custom_predicate_cross_plugin_fails() { let script_path = ctx.tempdir.join("cross-checker.sh"); write_script(&script_path, "exit 1"); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let skills_dir = ctx .workspace_root .as_ref() .unwrap() - .join(".claude") + .join(".agents") .join("skills"); let entries: Vec<_> = std::fs::read_dir(&skills_dir) .ok() diff --git a/tests/enablement.rs b/tests/enablement.rs index 0c846255c..7c72dc549 100644 --- a/tests/enablement.rs +++ b/tests/enablement.rs @@ -7,27 +7,8 @@ use symposium::output::Output; use symposium::status_command::StatusState; use symposium_testlib::{HookStep, TestContext, TestMode, with_fixture}; -/// Every installed skill directory under `parent` named `` or -/// `-`. fn find_installed_skills(parent: &Path, skill_name: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(parent) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let matches = name == skill_name - || (name.starts_with(skill_name) - && name.as_bytes().get(skill_name.len()) == Some(&b'-')); - if matches && path.join("SKILL.md").is_file() { - out.push(path); - } - } - out.sort(); - out + symposium_testlib::find_installed_skills(&[parent.to_path_buf()], skill_name) } /// The unique installed skill directory with this name. Panics on 0 or >1. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 297707ff2..fce1dd5a3 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -11,31 +11,8 @@ fn read_user_config(ctx: &symposium_testlib::TestContext) -> String { std::fs::read_to_string(&path).unwrap_or_else(|_| "(not found)".to_string()) } -/// Locate every installed skill directory under `parent` whose name is -/// `` or `-`. Sync embeds an origin-derived -/// hash in the directory name to keep distinct origins from colliding. fn find_installed_skills(parent: &Path, skill_name: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(parent) else { - return Vec::new(); - }; - let mut out = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - let matches = name == skill_name - || (name.starts_with(skill_name) - && name.as_bytes().get(skill_name.len()) == Some(&b'-')); - if matches && path.join("SKILL.md").is_file() { - out.push(path); - } - } - out.sort(); - out + symposium_testlib::find_installed_skills(&[parent.to_path_buf()], skill_name) } /// Locate the unique installed skill directory by name. Panics if 0 or @@ -143,11 +120,11 @@ async fn sync_installs_workspace_plugin_skills() { TestMode::SimulationOnly, &["workspace-plugin0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let skill_dir = find_installed_skill(&skills_dir, "ws-hello"); assert!( skill_dir.join(".symposium").exists(), @@ -222,11 +199,11 @@ async fn sync_installs_skills() { TestMode::SimulationOnly, &["plugins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let skill_dir = find_installed_skill(&skills_dir, "serde-guidance"); @@ -257,7 +234,7 @@ async fn sync_installs_skills() { "unexpected .gitignore content at {}", gi.display() ); - // Parent directories (e.g. `.claude/skills/`) should NOT get a + // Parent directories (e.g. `.agents/skills/`) should NOT get a // gitignore — they are shared namespace directories. assert!( !skills_dir.join(".gitignore").exists(), @@ -597,11 +574,11 @@ async fn sync_installs_skill_from_crate_path() { TestMode::SimulationOnly, &["crate-path0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); // crate-x: default path via source = "crate" let x_dir = find_installed_skill(&skills_dir, "x-guidance"); @@ -633,11 +610,11 @@ async fn auto_enable_admits_a_dependencys_embedded_skills() { TestMode::SimulationOnly, &["auto-enable0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); assert!( find_installed_skills(&skills_dir, "a-guidance").is_empty(), "an unconsented dependency plugin must not install anything" @@ -720,11 +697,11 @@ async fn sync_installs_skill_via_chained_plugin() { TestMode::SimulationOnly, &["chained-crate0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let w_dir = find_installed_skill(&skills_dir, "w-guidance"); let content = std::fs::read_to_string(w_dir.join("SKILL.md"))?; @@ -756,11 +733,11 @@ async fn sync_installs_skill_via_crate_manifest() { TestMode::SimulationOnly, &["crate-manifest0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let m_dir = find_installed_skill(&skills_dir, "m-guidance"); let content = std::fs::read_to_string(m_dir.join("SKILL.md"))?; @@ -954,17 +931,25 @@ async fn sync_installations_are_gitignored() { skill_dir.join("SKILL.md").exists(), "skill should be installed on disk" ); + let plugin_dir = workspace_root.join(".claude/skills/serde-guidance"); assert!( - skill_dir.join(".symposium").exists(), + plugin_dir.join(".symposium").exists(), "marker should be on disk" ); + assert!( + workspace_root + .join(".symposium/plugins/serde-guidance") + .is_dir(), + "the compiled plugin should be on disk" + ); // Use `-uall` so untracked dirs expand to their leaf paths — // gives deterministic output regardless of git's collapsing rules. let status = git(&["status", "--porcelain", "-uall"])?; - // Skill dirs are fully gitignored by the wildcard `.gitignore` - // symposium drops into them, so they don't appear here. + // Skill, plugin and `.symposium/` dirs are fully gitignored by the + // wildcard `.gitignore` symposium drops into them, so they don't + // appear here. // // `.claude/settings.json` does appear: the `plugins0` fixture // sets `hook-scope = "project"`, so init+sync register hooks @@ -997,12 +982,12 @@ async fn sync_dedups_same_crate_origin_across_plugins() { TestMode::SimulationOnly, &["dedup-crate-origin0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); let installed = find_installed_skills( - &workspace_root.join(".claude/skills"), + &workspace_root.join(".agents/skills"), "code-review", ); assert_eq!( @@ -1039,12 +1024,12 @@ async fn sync_dedups_same_source_path_across_plugins() { TestMode::SimulationOnly, &["dedup-source-origin0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); let installed = find_installed_skills( - &workspace_root.join(".claude/skills"), + &workspace_root.join(".agents/skills"), "shared-skill", ); assert_eq!( @@ -1074,12 +1059,12 @@ async fn sync_keeps_distinct_plugin_origins_with_same_skill_name() { TestMode::SimulationOnly, &["distinct-plugin-origins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); let installed = find_installed_skills( - &workspace_root.join(".claude/skills"), + &workspace_root.join(".agents/skills"), "code-review", ); assert_eq!( @@ -1124,7 +1109,7 @@ async fn sync_demotes_to_suffixed_when_conflict_appears() { TestMode::SimulationOnly, &["distinct-plugin-origins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; // Park plugin-b *outside* any plugin source dir so it isn't // discovered. (`tempdir/` sits next to the user config root, @@ -1137,7 +1122,7 @@ async fn sync_demotes_to_suffixed_when_conflict_appears() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); // Baseline: only plugin-a's `code-review` is visible, so it // takes the plain slot. @@ -1198,11 +1183,11 @@ async fn sync_promotes_to_unsuffixed_when_conflict_disappears() { TestMode::SimulationOnly, &["distinct-plugin-origins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); // Baseline: two origins, both suffixed, neither at the // plain slot. @@ -1252,12 +1237,12 @@ async fn sync_falls_back_to_hashed_name_when_user_dir_in_the_way() { TestMode::SimulationOnly, &["plugins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); // Plant a user-managed dir at the slot symposium would // normally pick. No `.symposium` marker → user-owned. - let user_dir = workspace_root.join(".claude/skills/serde-guidance"); + let user_dir = workspace_root.join(".kiro/skills/serde-guidance"); std::fs::create_dir_all(&user_dir)?; std::fs::write(user_dir.join("SKILL.md"), "user content")?; @@ -1278,7 +1263,7 @@ async fn sync_falls_back_to_hashed_name_when_user_dir_in_the_way() { // matching directory shape; the suffix variant is the only // one that should carry the marker. let installed = - find_installed_skills(&workspace_root.join(".claude/skills"), "serde-guidance"); + find_installed_skills(&workspace_root.join(".kiro/skills"), "serde-guidance"); let hashed: Vec<_> = installed .iter() .filter(|p| p.join(".symposium").exists()) @@ -1376,16 +1361,16 @@ async fn sync_keeps_distinct_standalone_origins_at_different_paths() { // --------------------------------------------------------------------------- /// User-authored skills in `.agents/skills/` are propagated to agents that -/// read skills from a different directory (e.g. Claude → `.claude/skills/`). +/// read skills from a different directory (e.g. Kiro -> `.kiro/skills/`). /// Companion files next to `SKILL.md` are copied too, and the destination /// receives a `.symposium` marker so future syncs recognize it as managed. #[tokio::test] -async fn agents_syncing_propagates_user_authored_skill_to_claude() { +async fn agents_syncing_propagates_user_authored_skill() { with_fixture( TestMode::SimulationOnly, &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); @@ -1401,7 +1386,7 @@ async fn agents_syncing_propagates_user_authored_skill_to_claude() { // Propagated copy exists with SKILL.md, companion files, marker, // and wildcard gitignore. - let dest = workspace_root.join(".claude/skills/user-authored-skill"); + let dest = workspace_root.join(".kiro/skills/user-authored-skill"); assert!(dest.join("SKILL.md").exists(), "SKILL.md propagated"); assert!( dest.join("REFERENCE.md").exists(), @@ -1465,14 +1450,14 @@ async fn agents_syncing_disabled_skips_propagation() { TestMode::SimulationOnly, &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; ctx.sym.config.agents_syncing = false; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); assert!( !workspace_root - .join(".claude/skills/user-authored-skill") + .join(".kiro/skills/user-authored-skill") .exists(), "propagation should not occur when agents-syncing is disabled" ); @@ -1492,11 +1477,11 @@ async fn agents_syncing_cleans_up_removed_user_skill() { TestMode::SimulationOnly, &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let propagated = workspace_root.join(".claude/skills/user-authored-skill"); + let propagated = workspace_root.join(".kiro/skills/user-authored-skill"); assert!(propagated.exists(), "first sync should propagate"); assert!(propagated.join(".symposium").exists()); @@ -1524,11 +1509,11 @@ async fn agents_syncing_disabling_removes_previously_propagated_skills() { TestMode::SimulationOnly, &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let propagated = workspace_root.join(".claude/skills/user-authored-skill"); + let propagated = workspace_root.join(".kiro/skills/user-authored-skill"); assert!(propagated.exists(), "first sync should propagate"); ctx.sym.config.agents_syncing = false; @@ -1584,12 +1569,12 @@ async fn agents_syncing_does_not_overwrite_user_managed_target() { TestMode::SimulationOnly, &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); // Pre-existing, user-managed file in the target with the same name. - let target_dir = workspace_root.join(".claude/skills/user-authored-skill"); + let target_dir = workspace_root.join(".kiro/skills/user-authored-skill"); std::fs::create_dir_all(&target_dir)?; let preexisting = target_dir.join("SKILL.md"); std::fs::write(&preexisting, "pre-existing user content")?; @@ -1611,7 +1596,7 @@ async fn agents_syncing_does_not_overwrite_user_managed_target() { } /// Modifying a user-authored skill in `.agents/skills/` is detected on the -/// next sync and propagated to the destination (e.g. `.claude/skills/`). +/// next sync and propagated to the destination (e.g. `.kiro/skills/`). #[tokio::test] async fn agents_syncing_detects_modified_source_skill() { with_fixture( @@ -1619,12 +1604,12 @@ async fn agents_syncing_detects_modified_source_skill() { &["plugins0", "workspace0", "user-skills0"], async |mut ctx| { ctx.sym.config.sync_debounce_secs = 0; - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "kiro"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); let source = workspace_root.join(".agents/skills/user-authored-skill/SKILL.md"); - let dest = workspace_root.join(".claude/skills/user-authored-skill/SKILL.md"); + let dest = workspace_root.join(".kiro/skills/user-authored-skill/SKILL.md"); // Sanity: initial propagation worked. assert!(dest.exists(), "skill should be propagated on first sync"); @@ -2188,11 +2173,11 @@ async fn auto_sync_always_runs_on_session_start() { }]; ctx.prompt_or_hook("test", &pre_tool, symposium::hook_schema::HookAgent::Claude) .await?; - let skill_dir = find_installed_skill(&skills_dir, "serde-guidance"); + find_installed_skill(&skills_dir, "serde-guidance"); // The gate is now fresh (Cargo.lock unchanged): a PreToolUse hook // would skip sync. Remove the skill so we can observe a re-sync. - std::fs::remove_dir_all(&skill_dir).unwrap(); + std::fs::remove_dir_all(&skills_dir).unwrap(); assert!( find_installed_skills(&skills_dir, "serde-guidance").is_empty(), "skill should be gone after removal" @@ -2227,11 +2212,11 @@ async fn sync_installs_skill_from_named_crate_source() { TestMode::SimulationOnly, &["crate-path-named0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let b_dir = find_installed_skill(&skills_dir, "b-guidance"); let content = std::fs::read_to_string(b_dir.join("SKILL.md"))?; @@ -2282,11 +2267,11 @@ async fn sync_crate_metadata_multihop_redirect() { TestMode::SimulationOnly, &["crate-path-multihop0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let c_dir = find_installed_skill(&skills_dir, "c-guidance"); let content = std::fs::read_to_string(c_dir.join("SKILL.md"))?; @@ -2375,11 +2360,11 @@ async fn sync_crate_metadata_hyphen_underscore_cycle() { TestMode::SimulationOnly, &["crate-path-hyphen-cycle0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); // The path entry's skill should be installed. let foo_dir = find_installed_skill(&skills_dir, "foo-guidance"); @@ -2502,11 +2487,11 @@ async fn sync_crate_metadata_missing_path_dir() { TestMode::SimulationOnly, &["crate-path-missing-dir0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills_dir = workspace_root.join(".claude/skills"); + let skills_dir = workspace_root.join(".agents/skills"); let entries: Vec<_> = std::fs::read_dir(&skills_dir) .into_iter() @@ -2534,7 +2519,7 @@ async fn report_json_info_emits_installed_events() { TestMode::SimulationOnly, &["plugins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; let events = ctx.sync_with_report(tracing::Level::INFO).await?; assert!(!events.is_empty(), "expected at least one report event"); @@ -2549,7 +2534,7 @@ async fn report_json_info_emits_installed_events() { "expected at least one skill_installed event, got: {events:?}" ); assert_eq!(installed[0]["skill"], "serde-guidance"); - assert_eq!(installed[0]["agent"], "claude"); + assert_eq!(installed[0]["agent"], "codex"); assert!( installed[0]["dest"] .as_str() @@ -2581,7 +2566,7 @@ async fn report_json_debug_emits_decision_events() { TestMode::SimulationOnly, &["plugins0", "workspace0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; // Should have skill_considered for serde-guidance (matched) @@ -2623,7 +2608,7 @@ async fn report_json_shows_skipped_skills() { TestMode::SimulationOnly, &["plugins0", "workspace-empty0"], async |mut ctx| { - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; // The plugins0 fixture's serde-guidance is a bare skill, now a @@ -2685,7 +2670,7 @@ async fn sync_follows_symlinks_in_skill_content() { symlink("no-such-file.txt", source.join("dangling.txt")).unwrap(); symlink("../ws-hello", source.join("loop")).unwrap(); - ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["init", "--add-agent", "codex"]).await?; let events = ctx.sync_with_report(tracing::Level::INFO).await?; let installed: Vec<&Value> = events .iter() @@ -2704,7 +2689,7 @@ async fn sync_follows_symlinks_in_skill_content() { "the dangling link should be reported, not silently dropped: {events:?}" ); - let dest = find_installed_skill(&workspace_root.join(".claude/skills"), "ws-hello"); + let dest = find_installed_skill(&workspace_root.join(".agents/skills"), "ws-hello"); let lib = dest.join("lib.txt"); assert!( From a61ea127cb5e647303602139fa2c49f7af228ea0 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 16 Oct 2026 15:33:08 -0300 Subject: [PATCH 6/6] docs: document project plugin delivery and the Claude Code probe --- md/design/agent-details/claude-code.md | 40 ++++++++++++++++++++++---- md/design/agents.md | 10 ++++--- md/design/important-flows.md | 12 +++++++- md/design/module-structure.md | 15 +++++----- md/design/sync-agent-flow.md | 17 +++++++++-- md/reference/agents/claude.md | 20 +++++++++---- md/reference/cargo-agents-sync.md | 15 ++++++++-- md/rfds/agent-plugins/README.md | 21 ++++++++++++-- md/workspace-skills.md | 4 +-- 9 files changed, 122 insertions(+), 32 deletions(-) diff --git a/md/design/agent-details/claude-code.md b/md/design/agent-details/claude-code.md index dd2e83548..4205ab6da 100644 --- a/md/design/agent-details/claude-code.md +++ b/md/design/agent-details/claude-code.md @@ -256,9 +256,37 @@ other's content. ### Project scope -A `/.claude/skills//` folder with the same layout loads as a -project-scoped `@skills-dir`, only in a folder the user has trusted (an -untrusted folder lists it as `(suppressed)@skills-dir`; plain project skills -still load). A user-level plugin of the same name shadows the project one, and -`claude plugin list` reports it. Symposium does not write project-scoped -plugins yet. +Every other plugin that applies to the project reaches Claude Code the same +way, one level down: symposium copies the compiled directory from the project +staging root (`/.symposium/plugins//`) into +`/.claude/skills//` and adds a `.gitignore` containing `*` +inside it. Claude Code loads it as a project-scoped `@skills-dir`, in +that project only. Nothing is written to any settings file. + +Verified against Claude Code 2.1.284, logged out and offline (the `system/init` +event of `claude -p`, `claude plugin list --json`, and the TUI's `/` completion, +which shows each skill's description): + +- Only in a folder the user has trusted. Untrusted, `claude -p` loads none of + them and `claude plugin list` shows `(suppressed)@skills-dir`, while plain + project skills still load. In the TUI session that accepts the trust prompt + they load only after `/reload-plugins`; later sessions load them at start. +- Repository B, with the same profile, does not see repository A's plugins. + Two repositories carrying a plugin of the same name with different content, + open at the same time, each load their own. +- The `.gitignore` inside the directory does not affect loading. +- An edit at the same version is picked up by the next session, and removing + the directory leaves nothing listed and no error. +- A plugin written during the `SessionStart` hook loads in the next session, + or after `/reload-plugins` in the current one. +- Started in a subdirectory of the project, Claude Code loads no plugin from + the root's `.claude/skills/` (`claude plugin list` is empty there), while it + still finds the plain skills in that directory, and so found the per-skill + copies symposium wrote before. +- A user-level plugin of the same name shadows the project one, and + `claude plugin list` reports it. + +The Copilot CLI (1.0.89) and Goose (1.54.0) read `/.claude/skills/` +too, and take the skills inside `/skills/` as plain project skills, +as they took the per-skill copies before. Configured in the same project, each +lists every skill once, from its own copy in `.agents/skills/`. diff --git a/md/design/agents.md b/md/design/agents.md index a89609bdb..676357cdc 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -14,14 +14,14 @@ | `opencode` | OpenCode | | `goose` | Goose | -The agent name is stored in `[agent] name` in either the user or project config. +The agent name is stored in an `[[agent]]` entry's `name` in the user config. ## Agent responsibilities For each agent, `cargo agents` needs to know how to: 1. **Register hooks** — write the hook configuration so the agent calls `cargo-agents hook` on the right events. -2. **Install extensions**: place skills where the agent loads them, as per-skill directories in the project or as a compiled plugin directory for the user. +2. **Install extensions**: place skills where the agent loads them, as per-skill directories in the project or as a compiled plugin directory for the user or the project. Hooks and MCP servers follow `hook-scope`. Skills follow the scope of whatever enabled them (see [`sync`](./sync-agent-flow.md)). @@ -29,12 +29,12 @@ Hooks and MCP servers follow `hook-scope`. Skills follow the scope of whatever e Skills reach an agent in one of two units. -**Per-skill directories in the project.** Everything that applies to the workspace (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) installs one directory per skill, preferring the vendor-neutral path: +**Per-skill directories in the project.** Everything that applies to the workspace (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) installs one directory per skill, preferring the vendor-neutral path, on every agent that cannot take it as a project plugin: | Path | Agents | |------|--------| | `.agents/skills//SKILL.md` | Antigravity, Copilot, Codex, OpenCode, Goose | -| `.claude/skills//SKILL.md` | Claude Code (does not read `.agents/skills/`) | +| `.claude/skills//SKILL.md` | Claude Code (does not read `.agents/skills/`), only for a skill no compiled plugin carries | | `.kiro/skills//SKILL.md` | Kiro | Symposium never writes per-skill directories at user scope. @@ -51,6 +51,8 @@ Symposium never writes per-skill directories at user scope. | Kiro | no plugin unit: per-skill copies in the project | | OpenCode | no plugin unit: per-skill copies in the project | +**Compiled plugin directories for the project.** Every other plugin that applies to the workspace is compiled the same way into the project staging root, `/.symposium/plugins//`, under one `.gitignore` containing `*` at `/.symposium/` (written only when absent). An agent that can scope a plugin to a project receives it through `Agent::sync_project_plugins` and then gets no per-skill copies of that plugin's skills. Only Claude Code does: each directory is copied to `/.claude/skills//`, with a `.gitignore` containing `*` added inside, and loads there as `@skills-dir` once the user trusts the folder. Every other agent keeps the per-skill directories above for these plugins. + Symposium writes these files itself and never runs an agent's own plugin command: delivery also runs inside the `SessionStart` hook, where nothing can answer a prompt. See [Claude Code plugin delivery](./agent-details/claude-code.md#plugin-delivery) for what was verified. --- diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 116f7a358..f95f57297 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -35,13 +35,23 @@ The key code paths are in `discovery.rs`, `config.rs` (`PluginsConfig`, `UseEntr A plugin named by a `use --global` entry is installed for the user rather than copied into each project, on every agent that can take a plugin directory. It runs inside `sync`, so `cargo agents sync`, `use`, and the hook-triggered auto-sync all do it. 1. `skills::collect_skills` stamps each applicable skill with the index of its plugin in the active set, and `sync` deduplicates the skills as before, the first plugin to reach a skill keeping it. -2. `compile::global_plugins` groups the deduplicated skills by plugin and keeps the plugins `compile::installs_globally` accepts: named by a `[plugins] use` entry without a workspace (`PluginsConfig::is_used_globally`), and not workspace members. `hook-scope` is not consulted. +2. `compile::plugins_by_scope` groups the deduplicated skills by plugin and keeps for the user the plugins `compile::installs_globally` accepts: named by a `[plugins] use` entry without a workspace (`PluginsConfig::is_used_globally`), and not workspace members. `hook-scope` is not consulted. The rest form the [project set](#project-plugin-delivery). 3. `compile::compile` writes one Agent Plugins directory per plugin under `/installed/`, writing only what changed and swapping it in by rename (`sync::sync_tree`), maintains the root's `.claude-plugin/marketplace.json`, and reaps the marked directories it did not compile (`sync::reap_unlisted`). It returns the `CompiledPlugin`s and the origins of the skills they carry. 4. For each configured agent, `Agent::sync_user_plugins` hands it the compiled set; `true` means the agent installed it, and the per-skill loop skips those skills for that agent. Claude Code copies each directory into its user skills directory (`agents/plugin_registration/claude.rs`); the other agents' modules return `false` until their delivery lands. 5. `sync::unregister_user_plugins` calls every unconfigured agent with an empty set, from `sync` and from `init`. The key code paths are in `compile.rs`, `agents/mod.rs` (`CompiledPlugin`, `Agent::sync_user_plugins`), `agents/plugin_registration/`, `sync.rs` (`sync`, `sync_tree`, `sync_plugin_dir`, `reap_unlisted`, `unregister_user_plugins`), `skills.rs` (`SkillWithGroupContext::plugin_index`), and `config.rs` (`PluginsConfig::is_used_globally`, `INSTALLED_PLUGINS_SUBDIR`, `Symposium::env_dir`). +## Project plugin delivery + +Every other applicable plugin (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) is compiled for the project in the same `sync`, and delivered as a plugin to the agents that can scope one to a project. Claude Code is the only one; every other agent keeps per-skill copies of these plugins' skills. + +1. The project set from `compile::plugins_by_scope` goes to `compile::compile_project`, which makes sure `/.symposium/.gitignore` exists (containing `*`) and then runs the same `compile::compile` into the staging root `/.symposium/plugins/` (`compile::project_plugins_root`). When the `.gitignore` cannot be written, it compiles nothing and every agent falls back to per-skill copies. +2. For each configured agent, `Agent::sync_project_plugins` hands it that set, after `sync_user_plugins`. Claude Code copies each directory into `/.claude/skills//` with `sync::sync_plugin_dir`, writes a `.gitignore` containing `*` inside it (`sync::ignore_in_git`), and reaps the marked plugin directories there that it did not deliver; the other agents return `false`. A `true` makes the per-skill loop skip those skills for that agent, and the stale-skill cleanup then removes the per-skill copies earlier syncs left. +3. `sync` calls every unconfigured agent with an empty set, so their project plugins go on the project's next sync. + +The key code paths are in `compile.rs` (`plugins_by_scope`, `compile_project`, `project_plugins_root`), `agents/mod.rs` (`Agent::sync_project_plugins`), `agents/plugin_registration/claude.rs`, and `sync.rs` (`sync`, `ignore_in_git`, `has_symposium_marker`). + ## Help rendering `cargo agents --help` (and `-h`, the bare `help` keyword, or no subcommand) is rendered by `help_render`, not by clap's default help. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 21cdcc54c..27c0c028d 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -19,7 +19,7 @@ Everything hangs off the `Symposium` struct, which wraps the parsed `Config` wit Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, `[[registry]]` entries (`[[plugin-source]]` is the retired spelling, still accepted), defaults, `auto-update` (off/warn/on, default on), and the `[plugins]` enablement section. User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `registry_instances()` to build the effective registry `PmInstance`s directly (the builtin recommendations entry, the builtin `user-plugins` entry, then the configured ones): a git `[[registry]]` entry becomes a `GitPm`, a path entry a `PathPm`, each a trust root named for its registry (that name is what its plugins are attributed to). There is no `ResolvedRegistry`/`content_dir` intermediate — a git registry's cache directory and its refresh live on the `GitPm` itself. `package_managers(deps)` prepends the fixed cargo transport (a `CargoPm` built over the shared `deps` resolver) to those to make the active `PmRegistry`. `detached_managers()` is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs `cargo metadata`. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching, and returns it as an `Arc` so a `CargoPm` can hold one. -`PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }`: the durable record of a deliberate enablement, scoped to one workspace or to all; a global entry also installs the plugin for the user, see [`compile.rs`](#compilers-compiled-plugins-for-the-user)), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods (`used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in`, `is_used_globally`) all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. +`PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }`: the durable record of a deliberate enablement, scoped to one workspace or to all; a global entry also installs the plugin for the user, see [`compile.rs`](#compilers-compiled-plugins)), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods (`used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in`, `is_used_globally`) all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. ### `pm/cargo/workspace.rs` — cargo workspace resolution @@ -30,7 +30,7 @@ The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's m Centralizes agent-specific knowledge: hook registration file paths, skill installation directories, and hook registration logic for each supported agent (Antigravity CLI, Claude Code, GitHub Copilot, Codex CLI, Kiro, OpenCode, Goose). Handles the differences between agents — e.g., Claude Code uses `.claude/skills/` and Kiro uses `.kiro/skills/`, while Antigravity, Copilot, Codex, OpenCode, and Goose use the vendor-neutral `.agents/skills/`. OpenCode and Goose are skills-only agents (no hook registration). -Plugin delivery is the third axis: `Agent::sync_user_plugins(sym, root, plugins, out)` reconciles one agent's user-level installation with the [compiled plugins](#compilers-compiled-plugins-for-the-user) (`CompiledPlugin { name, version, dir }`, all under the staging `root`), an empty slice removing everything symposium installed for it, and returns whether the agent took delivery. Each agent's logic lives in its own `agents/plugin_registration/.rs`, reached through one explicit match arm; Claude Code copies the directory into its user skills directory, where it loads as `@skills-dir`, and the agents without a delivery yet return `false`, which keeps their per-skill copies. +Plugin delivery is the third axis: `Agent::sync_user_plugins(sym, root, plugins, out)` reconciles one agent's user-level installation with the [compiled plugins](#compilers-compiled-plugins) (`CompiledPlugin { name, version, dir }`, all under the staging `root`), an empty slice removing everything symposium installed for it, and returns whether the agent took delivery. Each agent's logic lives in its own `agents/plugin_registration/.rs`, reached through one explicit match arm; Claude Code copies the directory into its user skills directory, where it loads as `@skills-dir`, and the agents without a delivery yet return `false`, which keeps their per-skill copies. `Agent::sync_project_plugins(sym, project_root, root, plugins, out)` is the same contract for the plugins that apply to the project only, staged under `/.symposium/plugins/`: Claude Code copies them into `/.claude/skills/`, and every other agent returns `false` from its match arm in `agents/mod.rs`. Hook and MCP registration is scope-dispatched: the project and global locations are genuinely different files for some agents (Antigravity writes `.agents/hooks.json` but `~/.gemini/config/hooks.json`; Copilot `.github/hooks/` but `~/.copilot/settings.json`), so `sync` calls the project-scoped functions at project scope rather than rooting the global path at the workspace. @@ -48,14 +48,15 @@ One entry point, `sync(sym, deps, update)`. The caller supplies the `WorkspaceDe `sync` takes an `UpdateLevel` that it threads into skill resolution (`skills::collect_skills`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`. -Before the per-agent loop, `sync` [compiles](#compilers-compiled-plugins-for-the-user) the plugins enabled with `use --global`, and each agent's `sync_user_plugins` runs after its MCP registration; an agent that took delivery skips its per-skill copies of those plugins' skills. `unregister_user_plugins` hands an empty set to every agent no longer configured, and is also called by `init`, whichever hook scope it runs at. The directory helpers the agent deliveries share are `pub(crate)`: `sync_tree` (make a symposium-owned directory hold exactly a given tree plus the marker, writing a sibling and swapping it in by rename only when the content differs, and refusing a directory it does not own), `sync_plugin_dir` (the same from a source directory, debounced on the marker), `reap_unlisted` (remove the marked subdirectories a predicate does not keep), `collect_dir_contents`, `collect_skill_tree`, `recently_synced` and `has_symposium_marker`. The per-skill cleanup leaves compiled plugin directories to their agent's own reaping. +Before the per-agent loop, `sync` [compiles](#compilers-compiled-plugins) the plugins enabled with `use --global` for the user and every other applicable plugin for the project, and each agent's `sync_user_plugins` and then `sync_project_plugins` run after its MCP registration; an agent that took delivery skips its per-skill copies of those plugins' skills. `unregister_user_plugins` hands an empty set to every agent no longer configured, and is also called by `init`, whichever hook scope it runs at; `sync` also hands such an agent an empty project set. The directory helpers the agent deliveries share are `pub(crate)`: `sync_tree` (make a symposium-owned directory hold exactly a given tree plus the marker, writing a sibling and swapping it in by rename only when the content differs, and refusing a directory it does not own), `sync_plugin_dir` (the same from a source directory, debounced on the marker), `reap_unlisted` (remove the marked subdirectories a predicate does not keep), `collect_dir_contents`, `collect_skill_tree`, `recently_synced`, `has_symposium_marker` (true only for a `.symposium` file, so a project's `.symposium/` staging directory is no marker) and `ignore_in_git` (write a `.gitignore` containing `*` unless one exists). The per-skill cleanup leaves compiled plugin directories to their agent's own reaping. -### `compile.rs`: compiled plugins for the user +### `compile.rs`: compiled plugins -Turns the plugins a `use --global` entry names into [Agent Plugins](https://agent-plugins.org/) directories under the staging root `/installed/` (`INSTALLED_PLUGINS_SUBDIR`), which each agent's delivery installs. +Turns the plugins a `use --global` entry names into [Agent Plugins](https://agent-plugins.org/) directories under the staging root `/installed/` (`INSTALLED_PLUGINS_SUBDIR`), and every other applicable plugin into ones under the project staging root `/.symposium/plugins/` (`project_plugins_root`), which each agent's delivery installs. - `installs_globally` is the eligibility rule: a `UseEntry::Global` names the plugin, by manifest name or by package name (a crate's `use` entry names the crate, whatever its manifest calls itself), and the plugin is not a workspace member. `hook-scope` is not an input. Registry plugins active by their own gate, workspace members, dependencies and workspace-scoped `use` entries stay project-scoped. -- `global_plugins` groups the deduplicated applicable skills by owning plugin, using `SkillWithGroupContext::plugin_index` rather than `Plugin::canonical`, which two registry entries declaring the same name share. +- `plugins_by_scope` groups the deduplicated applicable skills by owning plugin, using `SkillWithGroupContext::plugin_index` rather than `Plugin::canonical`, which two registry entries declaring the same name share, and splits the plugins into the user set and the project set. +- `compile_project` makes sure `/.symposium/.gitignore` exists (containing `*`, written only when absent) before compiling the project set, and compiles nothing when it cannot. - `compile` names each directory with `normalize_name` (lowercase `[a-z0-9-]`, 1 to 64 characters, alphanumeric at both ends, no `--`: what every agent's loader accepts), suffixing a hash of the plugin's skill origins when two plugins normalize alike or a directory symposium does not own holds the slot. It writes `plugin.json` and `.claude-plugin/plugin.json` (`$schema` of Agent Plugins 1.0.0, `name`, `version` `0.0.0`), the marker, and `skills//` with each skill's whole directory, skipping a skill whose name is not a plain directory name and suffixing skill names a plugin carries twice. It maintains `.claude-plugin/marketplace.json` at the root and reaps marked directories it did not compile this run. ### `plugins.rs` — plugin registry @@ -148,7 +149,7 @@ The enabled-dependency ids seed the same worklist: `discovery::enabled_dependenc Production `sync` shares one `PredicateContext` across the skill and MCP passes, so it calls `active_plugins` then `collect_skills` directly rather than the `skills_applicable_to` convenience wrapper (which builds its own context and is test-only). -Each applicable skill also carries `plugin_index`, the position of the plugin it was collected for in the active set, which is how [`compile.rs`](#compilers-compiled-plugins-for-the-user) attributes skills to the plugin they compile into. +Each applicable skill also carries `plugin_index`, the position of the plugin it was collected for in the active set, which is how [`compile.rs`](#compilers-compiled-plugins) attributes skills to the plugin they compile into. Each applicable skill carries an **origin hash** (a `String`) describing *where its bytes live*, used at sync time for dedup and install-path disambiguation. `skill_origin_hash` computes it as an 8-hex-char prefix of SHA-256 over the `SKILL.md`'s **canonical** on-disk path — nothing else. Identity is the file's location, not which plugin manifest pointed at it: two references that resolve to the same file (the same crate reached through two chained plugins, or two `source.path` groups landing on the same bundle) produce the same hash and dedupe; skills at different paths stay distinct. Canonicalizing inside the hash is what makes that hold across discovery paths, since group scan dirs are canonicalized inconsistently — so on a platform whose temp prefix is a symlink (macOS `/var` → `/private/var`) the same file would otherwise hash two ways and install twice. diff --git a/md/design/sync-agent-flow.md b/md/design/sync-agent-flow.md index d890cbfee..542ab2324 100644 --- a/md/design/sync-agent-flow.md +++ b/md/design/sync-agent-flow.md @@ -15,7 +15,7 @@ Scans workspace dependencies, installs applicable skills into agent directories, 4. **Match skills to dependencies** — for each plugin, parse `SKILL.md` YAML frontmatter, reject malformed or non-string metadata, warn about skipped invalid skills, then evaluate skill group dependency predicates and individual skill `depends-on` frontmatter against the workspace dependencies. 5. **Install skills per agent** — for each configured agent: - - Copy applicable `SKILL.md` files into the agent's expected skill directory. + - Copy applicable `SKILL.md` files into the agent's expected skill directory, except the skills the agent received inside a compiled plugin (see below). - Drop a `.symposium` marker file into each installed skill directory so future syncs (and other tools) can recognize it as symposium-managed. - For every skill directory symposium creates along the way (the skill directory itself or its `skills/` parent), write a `.gitignore` containing a single `*` so symposium-managed files stay out of version control. @@ -36,13 +36,24 @@ Between matching skills (step 4) and installing them (step 5), the plugins a `us The selection is user-level state, so every workspace compiles the same plugins, but their content is resolved in the workspace being synced: a plugin or skill that does not apply there is removed from user scope by that sync, and comes back when a workspace where it applies syncs. +## Plugins for this project + +Every other applicable plugin (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) is compiled for the project in the same pass, by the same code. + +1. **Split**: `compile::plugins_by_scope` puts each plugin that kept a skill after dedup in exactly one of two sets, the user set above or the project set. +2. **Compile**: `compile::compile_project` writes the project set into the staging root `/.symposium/plugins/`, with the same layout, names, index and reaping as the user set. It first makes sure `/.symposium/.gitignore` exists (containing `*`, written only when absent); when that fails, nothing is compiled for the project and every agent gets per-skill copies. +3. **Deliver**: after the user-level delivery, each configured agent's `Agent::sync_project_plugins` reconciles its project installation with that set. Claude Code copies each directory into `/.claude/skills//`, adds a `.gitignore` containing `*` inside, and removes the marked plugin directories there that are not in the set. It reports that it took delivery, so the per-skill loop skips those skills for Claude Code and the stale-skill cleanup removes the per-skill copies an earlier sync left. Every other agent reports no delivery and keeps its per-skill copies. +4. **Unregister**: every agent that is no longer configured is called with an empty set, so a removed agent loses its project plugins on the project's next sync. + ## Marker file -Each skill directory symposium installs contains an empty `.symposium` file. Cleanup walks every agent's skills parent directory (`.claude/skills/`, `.agents/skills/`, `.kiro/skills/`) and reaps any subdirectory whose marker is present but which wasn't installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker. +Each skill directory symposium installs contains an empty `.symposium` file. Cleanup walks every agent's skills parent directory (`.claude/skills/`, `.agents/skills/`, `.kiro/skills/`) and reaps any subdirectory whose marker is present but which wasn't installed this sync. This lets symposium reclaim stale skills (including those left behind by agents removed from the config) without touching user-managed skills, which are identified by the absence of the marker. Compiled plugin directories there carry the marker too, and are reconciled by their agent's delivery instead of this walk. + +Only a `.symposium` file is a marker. The project staging root makes `/.symposium/` a directory, which must not mark the project itself as symposium's. ## Gitignore -Each skill directory symposium creates (and its `skills/` parent if new) receives a `.gitignore` containing just `*`. Pre-existing directories are left alone. The wildcard also hides the marker file and the gitignore itself, so `git status` stays clean. +Each skill directory symposium creates (and its `skills/` parent if new) receives a `.gitignore` containing just `*`. Pre-existing directories are left alone. The wildcard also hides the marker file and the gitignore itself, so `git status` stays clean. A project plugin directory in `.claude/skills/` gets the same file, and one `.gitignore` at `/.symposium/` covers the whole staging root. ## Auto-sync diff --git a/md/reference/agents/claude.md b/md/reference/agents/claude.md index 764698fd4..f19875ed0 100644 --- a/md/reference/agents/claude.md +++ b/md/reference/agents/claude.md @@ -4,6 +4,8 @@ Config name: `claude` ## Skills +Skills reach Claude Code inside plugin directories (below). A skill that no plugin directory can carry is installed on its own: + | Scope | Path | |-------|------| | Project | `.claude/skills//SKILL.md` | @@ -12,13 +14,21 @@ Claude Code does not support the vendor-neutral `.agents/skills/` path. ## Plugins -A plugin enabled with `cargo agents use --global` is installed for you as a plugin directory: +Every plugin that applies to your project is installed as a plugin directory, at the scope that enabled it: -| Scope | Path | -|-------|------| -| Global | `~/.claude/skills//` (`$CLAUDE_CONFIG_DIR/skills//` when set) | +| Scope | Path | Plugins | +|-------|------|---------| +| Project | `.claude/skills//` | registry plugins active in the project, workspace members, enabled dependencies, `cargo agents use` without `--global` | +| Global | `~/.claude/skills//` (`$CLAUDE_CONFIG_DIR/skills//` when set) | `cargo agents use --global` | + +Claude Code loads each one as `@skills-dir`, and its skills are invoked as `/:`. A global plugin loads in every project; a project plugin loads only in that project. Symposium writes no settings for them; `claude plugin disable @skills-dir` turns one off, and symposium leaves that choice alone. A plugin installed while a session runs appears in the next session, or after `/reload-plugins`. + +Claude Code loads project plugins only: + +- in a folder you trust. Until then they are skipped, while plain project skills still load. After you accept the trust prompt, `/reload-plugins` or a new session loads them. +- when it starts in the directory that holds `.claude/skills/`, the workspace root. Started in a subdirectory, it does not load them. -Claude Code loads it in every project as `@skills-dir`, and its skills are invoked as `/:`. Symposium writes no settings for it; `claude plugin disable @skills-dir` turns it off, and symposium leaves that choice alone. A plugin installed while a session runs appears in the next session, or after `/reload-plugins`. +A global plugin with the same name as a project plugin takes its place. ## Hooks diff --git a/md/reference/cargo-agents-sync.md b/md/reference/cargo-agents-sync.md index 25fb47030..01809adc4 100644 --- a/md/reference/cargo-agents-sync.md +++ b/md/reference/cargo-agents-sync.md @@ -20,9 +20,9 @@ Must be run from within a Rust workspace. Performs the following steps: 3. **Discover applicable skills** — loads plugin sources (from user config) and matches skill predicates against workspace dependencies. -4. **Install skills** — for each configured agent, copies applicable `SKILL.md` files into the agent's expected skill directory (e.g., `.claude/skills/` for Claude Code, `.agents/skills/` for Antigravity/Copilot/Codex). A `.gitignore` containing `*` is written into every new skill directory (and its `skills/` parent if new), and an empty `.symposium` marker file is dropped into each installed skill directory. +4. **Install skills**: for each configured agent, copies applicable `SKILL.md` files into the agent's expected skill directory (e.g., `.agents/skills/` for Antigravity/Copilot/Codex, `.kiro/skills/` for Kiro), except the skills the agent receives inside a plugin directory (see below). A `.gitignore` containing `*` is written into every new skill directory (and its `skills/` parent if new), and an empty `.symposium` marker file is dropped into each installed skill directory. -5. **Mirror workspace skills** — if `agents-syncing` is enabled (default), user-authored skills in `/.agents/skills/` are propagated into the skill directories of any configured agent that doesn't natively use `.agents/skills/` (e.g., `.claude/skills/`, `.kiro/skills/`). See [Workspace skills](../workspace-skills.md). +5. **Mirror workspace skills**: if `agents-syncing` is enabled (default), user-authored skills in `/.agents/skills/` are propagated into the skill directories of any configured agent that doesn't natively use `.agents/skills/` (e.g., `.kiro/skills/`; Claude Code receives them in the workspace plugin, see below). See [Workspace skills](../workspace-skills.md). 6. **Clean up stale skills** — scans every agent's skills parent directory and removes any subdirectory containing the `.symposium` marker that wasn't installed (or propagated) this sync. Directories without the marker (user-managed) are left untouched. @@ -46,6 +46,17 @@ An agent that received the plugin gets no per-project copy of its skills, and co The plugin's skills are still selected in the workspace being synced, so a skill that does not apply there is left out until a workspace where it applies syncs. Dropping the `--global` entry, or removing the agent with `cargo agents init --remove-agent`, removes what symposium installed. +## Plugins for this project + +Everything else that applies to the project, whether a registry plugin active by its own `depends-on`, a workspace member, an enabled dependency, or a plugin you enabled with `cargo agents use` for this workspace, is compiled into `.symposium/plugins//` at the workspace root. A `.symposium/.gitignore` containing `*` keeps that directory out of git. + +| Agent | Where the plugin goes | +|-------|-----------------------| +| Claude Code | `.claude/skills//` in the project, loaded as the plugin `` in that project only | +| Every other agent | its skills, in the project, as above | + +Claude Code then gets no per-skill copies of those skills, and the copies an earlier sync left are cleaned up. It loads project plugins only in a folder you trust, and only when started at the workspace root; see [Claude Code](./agents/claude.md#plugins). Dropping the `use` entry, or the dependency or gate that made the plugin apply, removes it from the project on the next sync, and so does removing the agent. + ## Consent prompt Before syncing, an interactive `cargo agents sync` asks about each dependency diff --git a/md/rfds/agent-plugins/README.md b/md/rfds/agent-plugins/README.md index 643731218..614f9d4a8 100644 --- a/md/rfds/agent-plugins/README.md +++ b/md/rfds/agent-plugins/README.md @@ -300,7 +300,7 @@ Gemini CLI needs no configuration write at all: a package present at `~/.gemini/ ### Which plugins install for the user -A plugin installs for the user when a `[plugins] use` entry without a workspace names it, by manifest name or by package name, and it is not a workspace member, whatever `hook-scope` says: `hook-scope` decides only where Symposium registers its own hook and MCP servers. No predicate analysis is done. Everything else (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) keeps the per-skill path. +A plugin installs for the user when a `[plugins] use` entry without a workspace names it, by manifest name or by package name, and it is not a workspace member, whatever `hook-scope` says: `hook-scope` decides only where Symposium registers its own hook and MCP servers. No predicate analysis is done. Everything else (registry plugins active by their own gate, workspace members, dependencies, workspace-scoped `use` entries) installs for the project: Claude Code receives it as a project plugin ([How Claude Code receives a project plugin](#how-claude-code-receives-a-project-plugin)), and every other agent keeps the per-skill path. Predicates are still evaluated in the workspace being synced, so what a global plugin carries reflects the workspace that synced last, and a plugin that does not apply in that workspace leaves user scope until a workspace where it applies syncs. The plugin is compiled from the skills that survive the usual deduplication, the first plugin to reach a skill keeping it. @@ -320,11 +320,28 @@ Claude Code gets a copy of the compiled directory in its user skills directory, Neither copies into `~/.claude/plugins/cache/`, and with either a plugin written during the `SessionStart` hook loads in the next session or after `/reload-plugins`. At user scope neither form is picked up by the Copilot CLI (1.0.89), and Goose (1.53.0) does not load the skills inside the copied directory. +### How Claude Code receives a project plugin + +A plugin that applies to the project only is compiled into the project staging root, `/.symposium/plugins//`, under one `.symposium/.gitignore` containing `*`. Claude Code gets a copy in `/.claude/skills//`, with a `.gitignore` containing `*` inside since that directory is not Symposium's own, and loads it as a project-scoped `@skills-dir`. No settings are written. Tried against Claude Code 2.1.284 in the same way as the global form: + +| | Result | +|---|---| +| Untrusted folder | not loaded (`claude plugin list` shows `(suppressed)@skills-dir`), while plain project skills load; in the TUI session that accepts the trust prompt, loaded after `/reload-plugins` | +| Another repository, same profile | not visible there | +| Two repositories, same plugin name, different content, open together | each loads its own | +| Same-version edit | picked up by the next session | +| Removal | nothing listed, no error | +| Written during the `SessionStart` hook | next session, or `/reload-plugins` | +| Session started in a subdirectory of the project | not loaded: Claude Code reads skills-directory plugins only from its working directory, whereas it finds plain skills, and so the per-skill copies, in parent directories | + +A user-level plugin of the same name shadows the project one. The Copilot CLI (1.0.89) and Goose (1.54.0) read the skills inside `/.claude/skills//skills/` as plain project skills, as they read the per-skill copies before; configured in the same project, each lists every skill once, from its own `.agents/skills/` copy. + ## Implementation status In progress. -- Step 2: plugins enabled with `use --global` are compiled into `~/.symposium/installed//`, carrying `plugin.json`, `.claude-plugin/plugin.json`, the ownership marker and name disambiguation; no `gemini-extension.json` is emitted, since Gemini CLI support was replaced by Antigravity CLI. Project-scoped compilation is not implemented. +- Step 2: plugins enabled with `use --global` are compiled into `~/.symposium/installed//`, carrying `plugin.json`, `.claude-plugin/plugin.json`, the ownership marker and name disambiguation; no `gemini-extension.json` is emitted, since Gemini CLI support was replaced by Antigravity CLI. The plugins that apply to the project only are compiled the same way into `/.symposium/plugins//`. - Step 3: done for Claude Code ([How Claude Code receives a global plugin](#how-claude-code-receives-a-global-plugin)) under the eligibility rule in [Which plugins install for the user](#which-plugins-install-for-the-user). The other agents keep per-skill delivery until their delivery lands. +- Step 4: done for Claude Code ([How Claude Code receives a project plugin](#how-claude-code-receives-a-project-plugin)), whose per-skill copies are removed by the next sync. The other agents keep per-skill delivery for project plugins. See [Proposed: Agent Plugins packages](./proposed-reference.md) for the intended authoring reference and [Proposed: How extensions are installed](./proposed-install.md) for the resulting install locations. diff --git a/md/workspace-skills.md b/md/workspace-skills.md index f42dd4758..5f8d47824 100644 --- a/md/workspace-skills.md +++ b/md/workspace-skills.md @@ -2,7 +2,7 @@ In addition to adding skills based on your dependencies, Symposium will also install skills your workspace defines for itself: skills found in `skills/` or `.agents/skills` in the workspace root or any member crate install into the directory appropriate for your configured agent(s). -This allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users will have the skills synced to `.claude/skills`). +This allows your project to add skills in one central location that will work for all developers, regardless of which agent they use (for example, Claude Code users get each workspace plugin below as a plugin in `.claude/skills//`, and invoke its skills as `/:`). The default skill location therefore varies depending on the intended audience: @@ -43,7 +43,7 @@ Workspace skills are your own notes, so the [skill frontmatter](./reference/skil ## Recommended git setup -We recommend you commit your `.agents/skills` or `skills/` into the repository. Symposium installs a `.gitignore` file into every skill that it creates, so automatically copied and installed skills should not dirty your git status. +We recommend you commit your `.agents/skills` or `skills/` into the repository. Symposium installs a `.gitignore` file into every skill and plugin directory that it creates, and into the `.symposium/` directory where it compiles the project's plugins, so automatically copied and installed skills should not dirty your git status. ## Pre-existing files