From 3c22fe525b7ed1cc4f0efde8e1a4fabfa3048042 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 12:37:35 -0300 Subject: [PATCH 01/14] feat(plugins): carry plugin version, description, and skill attribution --- md/design/module-structure.md | 7 ++- src/help_render.rs | 8 +-- src/hook.rs | 8 +-- src/plugins.rs | 106 +++++++++++++++++++--------------- src/report.rs | 10 +++- src/skills.rs | 63 +++++--------------- src/subcommand_dispatch.rs | 8 +-- src/sync.rs | 53 +++++++++++------ tests/init_sync.rs | 1 + 9 files changed, 124 insertions(+), 140 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index d2590a0b..313c11a1 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,9 +24,9 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_skill_dir(source_dir, dest_dir, project_root)`. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. -Two entry points: `sync(sym, cwd)` for standalone CLI use (creates its own `WorkspaceDeps`) and `sync_with_deps(sym, deps)` for the hook pipeline (shares the cached workspace resolution with other hook stages). +One entry point, `sync(sym, deps, update)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. `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`. @@ -37,6 +37,7 @@ Loads plugin manifests from the configured registries and parses them into `Plug Validation here turns the raw TOML into: - `Installation` entries (optional `source`, optional `executable`/`script`, optional `args`, plus `requirements` and `install_commands`) collected on `Plugin.installations`. Inline installation references on hooks or other installations are *promoted* into synthetic `Installation` entries with derived names (`` for an inline `command`, `__req_` for an inline requirement), so all references in the validated form are plain names. - `Hook` entries with `command: String` (the name of an `Installation`) plus optional hook-level `executable` / `script` / `args`. Validation guarantees at most one of `executable`/`script` is set across hook + installation, and at most one layer sets `args`. +- `Plugin.version` and `Plugin.description` from the manifest's optional `version` / `description` keys. Absent on a plugin whose manifest omits them, and on a bare-`SKILL.md` plugin. - `SkillGroup` and `PluginMcpServer` entries whose `depends-on` sugar and `predicates` list are merged into one runtime `PredicateSet`. Skill group `source` syntax is deserialized as raw string/table forms, then validated into `PluginSource`. - `ChainedPlugin` entries from `[[plugins]]`: a per-edge `PredicateSet` plus a `source.cargo` reference (dependency-atom string `"widget>=1"` or `{ name, version }` table) naming the crate that carries the referenced plugin. This is the "package ≡ plugin" edge — how one plugin (e.g. a recommendations manifest) names another plugin by its package. Validation rejects git/path sources and the retired dependency-table form with hints. Expansion is wired in `skills.rs`: when the owning plugin is active and the edge predicates hold, the referenced crate is loaded (see [important flows](./important-flows.md#crate-sourced-skill-resolution)) — as a first-class plugin from its own `SYMPOSIUM.toml` if it ships one, otherwise from the crate's metadata / default-`skills/` path. The recorded version requirement is not yet enforced at resolution — the crate resolves against the workspace. @@ -100,7 +101,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 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. +Each applicable skill carries the name of the plugin that contributed it, plus 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/src/help_render.rs b/src/help_render.rs index 823b26ed..deafe4db 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -223,15 +223,9 @@ mod tests { ParsedPlugin { plugin: Plugin { name: name.into(), - hooks: vec![], predicates: crate_set(depends_on), - skills: vec![], - mcp_servers: vec![], subcommands, - installations: vec![], - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }, workspace_member: false, canonical: PackageId::new("test", name, ANY_VERSION), diff --git a/src/hook.rs b/src/hook.rs index 29cba839..35c9d4d8 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -942,7 +942,6 @@ fn dispatched_hooks_for_payload( #[cfg(test)] mod tests { - use std::collections::BTreeMap; use crate::pm::{ANY_VERSION, PackageId}; @@ -1157,12 +1156,7 @@ mod tests { }, installations: vec![install], hooks: vec![hook], - skills: vec![], - mcp_servers: vec![], - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; crate::plugins::ParsedPlugin { plugin, diff --git a/src/plugins.rs b/src/plugins.rs index efea5ea2..5d1335b4 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -448,9 +448,13 @@ impl ParsedPlugin { /// This is a table of contents — it describes what skills and hooks are /// available, but does not load skill content. The skills layer handles /// discovery and loading. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct Plugin { pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, /// Activation predicates for this plugin — the plugin's `depends-on` /// (lowered to `any(depends-on(...))`) merged with its `predicates`. Holds /// when every entry holds. Evaluated at sync time (for skills/MCP), at @@ -1009,6 +1013,8 @@ struct RawPluginManifest { /// Required for registry plugins; defaults to the directory name for /// workspace plugins. name: Option, + version: Option, + description: Option, /// Default-content opt-outs. Only meaningful for workspace plugins. #[serde(default)] defaults: Option, @@ -1060,6 +1066,12 @@ impl RawPluginManifest { if over.name.is_some() { self.name = over.name; } + if over.version.is_some() { + self.version = over.version; + } + if over.description.is_some() { + self.description = over.description; + } if over.defaults.is_some() { self.defaults = over.defaults; } @@ -1327,6 +1339,8 @@ fn load_standalone_skill_plugin( let mut plugin = Plugin { name: name.clone(), + version: None, + description: None, predicates, installations: Vec::new(), hooks: Vec::new(), @@ -2078,6 +2092,8 @@ fn validate_manifest( Ok(Plugin { name, + version: manifest.version.take(), + description: manifest.description.take(), predicates, installations, hooks, @@ -2230,7 +2246,6 @@ fn build_custom_predicate_registry( mod tests { use super::*; use indoc::indoc; - use std::collections::BTreeMap; use crate::predicate::PredicateSet; @@ -2543,6 +2558,41 @@ mod tests { assert!(plugin.skills.is_empty()); } + #[test] + fn parse_manifest_version_and_description() { + let toml = indoc! {r#" + name = "pdf-tools" + version = "1.2.0" + description = "Table extraction guidance" + depends-on = ["lopdf"] + "#}; + let plugin = from_str(toml).expect("parse"); + assert_eq!(plugin.version.as_deref(), Some("1.2.0")); + assert_eq!( + plugin.description.as_deref(), + Some("Table extraction guidance") + ); + + let bare = from_str("name = \"bare\"\ndepends-on = [\"serde\"]\n").expect("parse"); + assert_eq!(bare.version, None); + assert_eq!(bare.description, None); + } + + #[test] + fn crate_manifest_merge_prefers_the_file_layer_for_version_and_description() { + let metadata: toml::Table = toml::from_str(indoc! {r#" + version = "0.1.0" + description = "from Cargo.toml" + "#}) + .expect("metadata"); + let file = indoc! {r#" + version = "0.2.0" + "#}; + let plugin = load_crate_manifest(Some(metadata), Some(file), "widget").expect("merge"); + assert_eq!(plugin.version.as_deref(), Some("0.2.0")); + assert_eq!(plugin.description.as_deref(), Some("from Cargo.toml")); + } + #[test] fn parse_manifest_with_source_git_under_skills() { let toml = indoc! {r#" @@ -3174,14 +3224,7 @@ mod tests { let plugin_wildcard = Plugin { name: "wildcard".to_string(), predicates: pred_set("*"), - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; assert!(plugin_wildcard.applies(&mut ctx(&workspace_crates))); @@ -3189,14 +3232,7 @@ mod tests { let plugin_serde = Plugin { name: "serde-plugin".to_string(), predicates: pred_set("serde"), - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; assert!(plugin_serde.applies(&mut ctx(&workspace_crates))); @@ -3204,14 +3240,7 @@ mod tests { let plugin_other = Plugin { name: "other-plugin".to_string(), predicates: pred_set("other-crate"), - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; assert!(!plugin_other.applies(&mut ctx(&workspace_crates))); @@ -3219,14 +3248,7 @@ mod tests { let plugin_version = Plugin { name: "version-plugin".to_string(), predicates: pred_set("tokio>=2.0"), - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; assert!(!plugin_version.applies(&mut ctx(&workspace_crates))); } @@ -3416,14 +3438,7 @@ mod tests { predicates: PredicateSet { predicates: vec![crate::predicate::Predicate::WorkspaceMember], }, - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let mut parsed = ParsedPlugin { plugin, @@ -4864,17 +4879,12 @@ mod tests { requirements: vec![], install_commands: vec![], }], - hooks: vec![], - skills: vec![], - mcp_servers: vec![], - subcommands: BTreeMap::new(), custom_predicates: vec![CustomPredicate { name: predicate_name.to_string(), command: "checker".to_string(), args: vec![], }], - chained: vec![], - requires_use: false, + ..Default::default() }, workspace_member: false, canonical: PackageId::new("test", plugin_name, ANY_VERSION), diff --git a/src/report.rs b/src/report.rs index ceead0c0..97cb2415 100644 --- a/src/report.rs +++ b/src/report.rs @@ -69,6 +69,7 @@ pub enum ReportEvent { /// A skill was installed to an agent's directory. SkillInstalled { skill: String, + plugin: String, agent: String, dest: String, }, @@ -228,8 +229,13 @@ impl ReportEvent { format!(" skill {skill} ({plugin}): skipped ({r})") } } - Self::SkillInstalled { skill, agent, dest } => { - format!("✅ installed skill {skill} for {agent} → {dest}") + Self::SkillInstalled { + skill, + plugin, + agent, + dest, + } => { + format!("✅ installed skill {skill} from {plugin} for {agent} → {dest}") } Self::SkillRemoved { path } => { format!("➖ removed {path}") diff --git a/src/skills.rs b/src/skills.rs index f07500a4..755ab1d6 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -99,6 +99,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: String, } /// Resolve all applicable skills from the registry. @@ -159,13 +160,7 @@ pub(crate) async fn collect_skills( for group in &parsed.plugin.skills { let skills = load_skills_for_group(sym, parsed, group, ctx, update).await; for (skill, origin_hash) in skills { - collect_skill_applicable_to( - skill, - origin_hash, - &parsed.plugin.name, - ctx, - &mut results, - ); + collect_skill_applicable_to(skill, origin_hash, parsed, ctx, &mut results); } } } @@ -552,10 +547,11 @@ fn load_skill( fn collect_skill_applicable_to( skill: Skill, origin_hash: String, - plugin_name: &str, + parsed: &ParsedPlugin, ctx: &mut PredicateContext, results: &mut Vec, ) { + let plugin_name = parsed.plugin.name.as_str(); if !skill.predicates.evaluate(ctx) { tracing::debug!( report = %crate::report::ReportEvent::SkillConsidered { @@ -576,7 +572,11 @@ fn collect_skill_applicable_to( reason: None, }, ); - results.push(SkillWithGroupContext { skill, origin_hash }); + results.push(SkillWithGroupContext { + skill, + origin_hash, + plugin: plugin_name.to_string(), + }); } /// Raw frontmatter fields extracted from a SKILL.md file. @@ -1076,19 +1076,13 @@ mod tests { let plugin = Plugin { name: "other-crate-plugin".to_string(), predicates: pred_set("other-crate"), - hooks: vec![], skills: vec![SkillGroup { predicates: pred_set("serde"), // Group targets serde source: PluginSource::Path(PathBuf::from("skills")), source_label: None, workspace_member: false, }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1135,19 +1129,13 @@ mod tests { let plugin = Plugin { name: "wildcard-plugin".to_string(), predicates: pred_set("*"), // Plugin applies to all - hooks: vec![], skills: vec![SkillGroup { predicates: pred_set("other-crate"), // But group targets other-crate source: PluginSource::Path(PathBuf::from("skills")), source_label: None, workspace_member: false, }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1212,19 +1200,13 @@ mod tests { let plugin = Plugin { name: "serde-plugin".to_string(), predicates: pred_set("serde"), // Plugin targets serde - hooks: vec![], skills: vec![SkillGroup { predicates: pred_set("serde"), // Group also targets serde source: PluginSource::Path(skill_dir.to_path_buf()), source_label: None, workspace_member: false, }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1294,19 +1276,14 @@ mod tests { Predicate::Shell("false".into()), ], }, - hooks: vec![], skills: vec![SkillGroup { predicates: pred_set("serde"), source: PluginSource::Path(skill_dir.to_path_buf()), source_label: None, workspace_member: false, }], - mcp_servers: vec![], - installations: Vec::new(), subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1372,7 +1349,6 @@ mod tests { Predicate::Shell("true".into()), ], }, - hooks: vec![], skills: vec![SkillGroup { predicates: PredicateSet { predicates: vec![ @@ -1384,12 +1360,8 @@ mod tests { source_label: None, workspace_member: false, }], - mcp_servers: vec![], - installations: Vec::new(), subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1504,8 +1476,6 @@ mod tests { let plugin = Plugin { name: "my-skill".to_string(), predicates: pred_set("serde"), - installations: vec![], - hooks: vec![], skills: vec![SkillGroup { predicates: PredicateSet::default(), // A PM returns absolute skill dirs; the bare-skill group's "." @@ -1514,11 +1484,8 @@ mod tests { source_label: None, workspace_member: false, }], - mcp_servers: vec![], subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 046dc9f1..9ce37b3d 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -205,14 +205,8 @@ mod tests { plugin: Plugin { name: name.into(), predicates: crate_set(depends_on), - installations: vec![], - hooks: vec![], - skills: vec![], - mcp_servers: vec![], subcommands, - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }, workspace_member: false, } diff --git a/src/sync.rs b/src/sync.rs index 71d534c8..c9536d67 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -55,14 +55,12 @@ fn skills_parent_dir(agent: Agent, project_root: &Path) -> PathBuf { .to_path_buf() } -/// Mark a directory as symposium-generated: drop the `.symposium` marker +/// Mark a directory as symposium-managed: drop the `.symposium` marker /// and a `.gitignore` containing `*` so the directory is recognized on /// future syncs and kept out of version control. /// -/// Idempotent — overwrites any pre-existing marker or `.gitignore` in -/// `dir`. Callers use this both for freshly-installed plugin skills and -/// for skills propagated by the agents-syncing feature. -fn mark_generated_skill_directory(dir: &Path) -> Result<()> { +/// Idempotent: overwrites any pre-existing marker or `.gitignore` in `dir`. +fn mark_managed_dir(dir: &Path) -> Result<()> { fs::write(dir.join(MARKER_FILE), "") .with_context(|| format!("write marker in {}", dir.display()))?; fs::write(dir.join(".gitignore"), "*\n") @@ -144,10 +142,9 @@ fn dir_contents_differ(source_dir: &Path, dest_dir: &Path) -> Result { Ok(src != dst) } -/// Synchronize a skill directory from `source_dir` into `dest_dir`. +/// Synchronize a symposium-managed directory from `source_dir` into `dest_dir`. /// -/// This is the single function used by both the plugin-skill and -/// user-authored-skill code paths. It: +/// Used by every install path that copies a directory symposium owns. It: /// 1. Checks whether `dest_dir` is debounce-fresh (marker mtime < `debounce`) /// — if so, skips entirely. /// 2. Compares source and dest content — if identical, touches the marker @@ -157,7 +154,7 @@ fn dir_contents_differ(source_dir: &Path, dest_dir: &Path) -> Result { /// /// Returns `Ok(true)` if the destination was created or updated (callers /// record it as installed). Returns `Ok(false)` if skipped (no-op). -fn sync_skill_dir( +fn sync_managed_dir( source_dir: &Path, dest_dir: &Path, project_root: &Path, @@ -171,7 +168,7 @@ fn sync_skill_dir( if !dest_dir.exists() { create_managed_dir_all(dest_dir, project_root)?; copy_dir_recursive(source_dir, dest_dir)?; - mark_generated_skill_directory(dest_dir)?; + mark_managed_dir(dest_dir)?; return Ok(true); } @@ -198,7 +195,7 @@ fn sync_skill_dir( fs::remove_dir_all(dest_dir).with_context(|| format!("remove {}", dest_dir.display()))?; create_managed_dir_all(dest_dir, project_root)?; copy_dir_recursive(source_dir, dest_dir)?; - mark_generated_skill_directory(dest_dir)?; + mark_managed_dir(dest_dir)?; Ok(true) } @@ -270,6 +267,14 @@ async fn resolve_custom_predicate_entries( entries } +/// One skill selected for installation, with the plugin it came from. +struct PendingSkill<'a> { + name: String, + origin_hash: String, + plugin: String, + source: &'a Path, +} + /// Run the full sync: discover applicable skills, install into agent dirs, /// clean up stale installations. pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLevel) -> Result<()> { @@ -334,7 +339,7 @@ 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> = Vec::new(); let mut name_counts: std::collections::BTreeMap = std::collections::BTreeMap::new(); @@ -342,7 +347,12 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve 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)); + to_install.push(PendingSkill { + name, + origin_hash: entry.origin_hash.clone(), + plugin: entry.plugin.clone(), + source: &entry.skill.path, + }); } } @@ -410,10 +420,16 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve .register_global_mcp_servers(&hook_root, &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() { + for pending in &to_install { + let PendingSkill { + name: skill_name, + origin_hash, + plugin, + source, + } = pending; + // `source` is the path to the SKILL.md file; the skill directory + // is its parent. + let source_dir = match source.parent() { Some(p) => p, None => { out.warn(format!( @@ -465,12 +481,13 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve continue; } - match sync_skill_dir(source_dir, &dest_dir, &project_root, debounce) { + match sync_managed_dir(source_dir, &dest_dir, &project_root, debounce) { Ok(true) => { installed_dirs.insert(dest_dir.clone()); tracing::info!( report = %crate::report::ReportEvent::SkillInstalled { skill: dir_name.clone(), + plugin: plugin.clone(), agent: agent_name.clone(), dest: display_path(&dest_dir), }, diff --git a/tests/init_sync.rs b/tests/init_sync.rs index c418ec56..1ff760e6 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2485,6 +2485,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]["plugin"], "serde-guidance"); assert_eq!(installed[0]["agent"], "claude"); assert!( installed[0]["dest"] From 620c5b88f7c916f08f8f363f56b8234f20403024 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 13:22:10 -0300 Subject: [PATCH 02/14] feat(agent-plugin): compile applicable plugins into agent plugin directories --- md/design/important-flows.md | 12 ++ md/design/module-structure.md | 25 ++- src/agent_plugin/manifest.rs | 160 ++++++++++++++++ src/agent_plugin/mod.rs | 290 ++++++++++++++++++++++++++++ src/agent_plugin/tests.rs | 347 ++++++++++++++++++++++++++++++++++ src/config.rs | 8 + src/lib.rs | 1 + src/predicate.rs | 29 +++ src/report.rs | 16 ++ src/skills.rs | 7 +- src/sync.rs | 115 +++++++++-- tests/init_sync.rs | 169 +++++++++++++++++ 12 files changed, 1162 insertions(+), 17 deletions(-) create mode 100644 src/agent_plugin/manifest.rs create mode 100644 src/agent_plugin/mod.rs create mode 100644 src/agent_plugin/tests.rs diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 11eff832..a5807969 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -29,6 +29,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` (`Plugin::requires_use`), `predicate.rs` (`PredicateContext::is_used`), and `skills.rs` (`active_plugins`, `record_active`). +## Compilation into agent plugin directories + +Every `cargo agents sync` compiles the plugins that apply into the directory unit agents consume. The step runs after skills are resolved, so it never re-evaluates a gate. + +1. `agent_plugin::compile` groups the applicable skills by their contributing plugin's `canonical` id and builds one `CompiledPlugin` each: a manifest name (slugged into the format's grammar), an optional version (the manifest's, else a crate plugin's resolved version — a registry placeholder `*` is not a version), the plugin's description, and one skill entry per distinct origin. +2. Directory names are disambiguated across plugins, and skill directory names within each plugin, using the same origin-hash suffix rule that already governs skill installs. +3. `Scope::of` sends each compiled plugin to `/.symposium/plugins/` or `/installed/`. Global requires the plugin, its groups, and its skills to all be workspace-independent, and a dormant plugin to be woken by a *global* `use` entry — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for why that is a correctness requirement and not a preference. +4. `agent_plugin::write` stages the content in a temporary directory and syncs it in through `sync::sync_managed_dir`, so an unchanged plugin is not recopied. The directory gets the `.symposium` marker; the project tree's single `.gitignore` is written at `.symposium/` rather than into each plugin. +5. `agent_plugin::reap` removes marked directories under each root that this sync did not write. Reaping the global root from a project sync is sound only because step 3 keeps the global set a function of user config alone. + +Per-agent delivery of these directories is not wired up yet; the per-skill install path is still what reaches agents. The key code paths are in `agent_plugin/mod.rs` (`compile`, `Scope::of`, `write`, `reap`), `agent_plugin/manifest.rs` (`slug`, `is_valid_name`), `predicate.rs` (`is_workspace_independent`), and `sync.rs`. + ## 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 313c11a1..7bb32ad5 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,7 +24,7 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync also compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects, and reaps the compiled directories it did not write. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. One entry point, `sync(sym, deps, update)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. @@ -49,6 +49,27 @@ A registry manifest that references no dependency anywhere — plugin, `[[skills Workspace-scoped callers use `load_registry_with_workspace`, which additionally loads *workspace plugins* (`workspace_plugins`): the workspace root and every member directory each define a plugin when they carry a `SYMPOSIUM.toml` (validated with `ManifestOrigin::WorkspaceMember` — `name` defaults to the directory name, membership is the gate so dormancy never applies, and the default groups are appended unless `[defaults] skills = false`: `[[skills]] source.path = "skills"` plus, when the `agents-syncing` config is on, a `workspace-member()`-gated `[[skills]] source.path = ".agents/skills"` — the maintainer-skills convention, unified into the ordinary pipeline) or a bare `skills/` or `.agents/skills/` directory (an all-defaults manifest-less plugin). Workspace plugins are stamped `workspace_member = true` — the producer of the `workspace-member()` predicate — and attributed to the `"(workspace)"` source with skill paths relative to the workspace root. +### `agent_plugin/` — compiling an agent plugin directory + +Turns an already-gated plugin into the unit agents themselves consume: a manifest beside a `skills/` directory, per the [Agent Plugins](https://agent-plugins.org/) format. Because every predicate has been evaluated by the time compilation runs, the emitted directory holds only what applies — an agent never receives a gate and never resolves one. + +`manifest.rs` models the manifest fields symposium emits and owns the format's **name grammar** (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars). That grammar is narrower than a symposium plugin name, which may be a crate name with underscores or a free-form manifest string, so `slug` normalizes one into the other. Two distinct names can slug alike (`foo_bar` and `foo-bar`), which is why directory disambiguation keys on the *slug*, not the original name. + +`compile(active, skills, plugins)` groups the applicable skills by their contributing plugin's `canonical` id — the name is only a display label, since two registries can supply the same one. A plugin with no applicable skills compiles to nothing (version one carries only the skills component, so the directory would be empty). Skills sharing a name *within* one plugin take an origin-hash suffix; across plugins they do not collide, because agents namespace a plugin's skills under the plugin (`pdf-tools:extract-tables`). When more than one plugin claims a directory name, every claimant takes the suffixed form, so a name stays stable as unrelated plugins come and go. + +`Scope::of` decides which of two staging roots a plugin belongs in: + +| Scope | Root | | +|---|---|---| +| `Project` | `/.symposium/plugins/` | symposium owns the whole `.symposium/` tree, so one `.gitignore` with `*` sits at its root rather than one per directory | +| `Global` | `/installed/` | deliberately **not** `plugins/`, which is the builtin `user-plugins` registry — compiling there would make symposium ingest its own output as registry plugins on the next load | + +Global is the narrow case, and the rule is a safety property rather than a preference. A user-level directory is visible from every workspace while cleanup reaps whatever it did not install this run, so a global set that varied by workspace would have two projects undoing each other on every session start. A plugin therefore goes global only when nothing about it can vary by workspace: it is not a workspace member, not crate-sourced, its own gate is workspace-independent, a *global* `use` entry wakes it if it is dormant, and every declared skill group and contributed skill is workspace-independent too. That last clause matters as much as the first — a plugin gated `depends-on(*)` whose group is gated `depends-on(serde)` would compile to different content in different projects, which is the same churn by another route. + +`write` assembles the directory in a temporary directory and hands it to `sync::sync_managed_dir`, so the install is change-aware and debounced exactly like a skill directory: recompiling identical content leaves the destination untouched. `reap` removes marked directories the current sync did not write, keyed on the `.symposium` marker so a directory the user placed there is left alone. + +Nothing consumes these directories yet — per-agent delivery lands with the emitters. Compilation owns and reaps them from here on. + ### `installation.rs` — sources and acquisition Defines `Source` (the `source = "..."`-tagged enum: `cargo`, `github`) and `acquire_source`, which downloads / installs / clones the source and returns an `AcquiredSource` whose `resolve_executable` / `resolve_script` methods turn a relative `executable`/`script` name into a concrete path. The `Runnable` enum (`Exec(PathBuf)` or `Script(PathBuf)`) is the final form a hook command resolves to. The `git` submodule handles GitHub tarball acquisition and caching. @@ -87,6 +108,8 @@ Defines one `Predicate` enum covering both dependency-graph matching and runtime - The **`depends-on`** field uses dependency-atom syntax (`serde`, `serde>=1.0`, `*`) and lowers, via `DependsOnList`, to `depends-on(...)` / `depends-on(*)` predicates OR-combined into a single `any(...)` that is appended to the same list. So `depends-on` is sugar — there is no separate dependency-predicate type. - The **`predicates`** field uses function-call syntax: `depends-on()`, `shell()` (verbatim arg, `sh -c`, exit 0 holds), `path_exists()` (disk, then `$PATH` for bare names), `env([=])`, `workspace-member()` (the plugin is defined by a member of the active workspace — provenance stamped per plugin into `PredicateContext` via `ParsedPlugin::applies`; registry loading stamps false, workspace-plugin loading stamps true), and the combinators `not(

)`, `any(

, …)`, `all(

, …)`. The retired `crate(...)` spelling is rejected with a migration hint, as are the old `crates` fields. +`is_workspace_independent` answers whether a gate's value can vary by workspace — only `depends-on(*)` can't, since it holds unconditionally. It is deliberately conservative: `workspace-member()` is workspace-dependent by definition, `shell(...)` and a relative `path_exists(...)` resolve against the workspace as their working directory, a custom predicate is opaque, and `not(...)` is dependent regardless of its operand. [Compilation](#agent_plugin--compiling-an-agent-plugin-directory) uses it to decide global versus project scope. + Each gated struct (plugin, skill group, skill, hook, MCP server, subcommand) stores a single merged `predicates: PredicateSet`. Evaluation is `PredicateSet::evaluate(ctx) -> bool` — a predicate is purely a boolean gate. A `depends-on` atom matches a dependency by exact name; a version requirement is checked when the dependency id's version component parses as semver. `collect_dep_names` (crates.io validation) walks all positions regardless. Plugin/group/skill/MCP predicates are evaluated at sync time; hook dispatch evaluates the plugin-level set (so a plugin's `depends-on` now gates its hooks) plus the hook-level set. Hook dispatch threads in the workspace crate list, but resolves it (running cargo) only when some plugin- or hook-level predicate references a *concrete* `depends-on(...)`, or there is crate-plugin expansion to perform — a chained `[[plugins]]` edge or a `[plugins]` enablement entry (`hook_dispatch_needs_deps`) — since expansion evaluates predicates against the crate graph too. A workspace whose plugins have none of these dispatches without a cargo query. See the [predicates reference](../reference/predicates.md). ### `skills.rs` — skill resolution and matching diff --git a/src/agent_plugin/manifest.rs b/src/agent_plugin/manifest.rs new file mode 100644 index 00000000..6cc8fb09 --- /dev/null +++ b/src/agent_plugin/manifest.rs @@ -0,0 +1,160 @@ +//! The [Agent Plugins 1.0.0](https://agent-plugins.org/) manifest. +//! +//! Only the fields symposium emits are modelled. The name grammar is the +//! format's, not ours: agent plugin names are narrower than symposium plugin +//! names (which are crate names or free-form manifest strings), so a name has +//! to be slugged before it can be written. + +use serde::Serialize; + +pub const SCHEMA_URL: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; + +const MAX_NAME_LEN: usize = 64; + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Manifest { + #[serde(rename = "$schema")] + pub schema: &'static str, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl Manifest { + pub fn new(name: String, version: Option, description: Option) -> Self { + Self { + schema: SCHEMA_URL, + name, + version, + description, + } + } + + pub fn to_json(&self) -> String { + let mut json = serde_json::to_string_pretty(self).expect("manifest always serializes"); + json.push('\n'); + json + } +} + +/// `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1 to 64 characters. +pub fn is_valid_name(name: &str) -> bool { + if name.is_empty() || name.len() > MAX_NAME_LEN { + return false; + } + let alnum = |c: char| c.is_ascii_lowercase() || c.is_ascii_digit(); + let mut chars = name.chars(); + if !chars.next().is_some_and(alnum) { + return false; + } + if !name.chars().next_back().is_some_and(alnum) { + return false; + } + name.chars().all(|c| alnum(c) || c == '.' || c == '-') +} + +/// Convert a symposium plugin name into a valid manifest name, or `None` when +/// nothing legal survives. +/// +/// Two distinct names can slug to the same result (`foo_bar` and `foo-bar`), +/// which is why callers disambiguate the *slug* rather than the original name. +pub fn slug(name: &str) -> Option { + let lowered: String = name + .chars() + .map(|c| { + let c = c.to_ascii_lowercase(); + if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '.' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + + let trimmed = trim_to_alnum(&lowered); + let capped = if trimmed.len() > MAX_NAME_LEN { + trim_to_alnum(&trimmed[..MAX_NAME_LEN]) + } else { + trimmed + }; + + (!capped.is_empty()).then_some(capped) +} + +fn trim_to_alnum(s: &str) -> String { + s.trim_matches(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit())) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slug_normalizes_symposium_names() { + assert_eq!(slug("pdf-tools").as_deref(), Some("pdf-tools")); + assert_eq!(slug("my_crate").as_deref(), Some("my-crate")); + assert_eq!(slug("Serde Guidance").as_deref(), Some("serde-guidance")); + assert_eq!( + slug("dev.symposium.tools").as_deref(), + Some("dev.symposium.tools") + ); + assert_eq!( + slug("-leading-and-trailing-").as_deref(), + Some("leading-and-trailing") + ); + assert_eq!(slug("_"), None); + assert_eq!(slug(""), None); + } + + #[test] + fn slug_output_is_always_a_valid_name() { + for name in [ + "pdf-tools", + "my_crate", + "Serde Guidance", + "-leading-", + "UPPER", + "a", + &"x".repeat(200), + &format!("{}_", "y".repeat(70)), + ] { + let slugged = slug(name).expect("slug"); + assert!( + is_valid_name(&slugged), + "slug({name:?}) produced invalid name {slugged:?}" + ); + } + } + + #[test] + fn name_grammar_rejects_what_the_format_rejects() { + assert!(is_valid_name("a")); + assert!(is_valid_name("pdf-tools")); + assert!(is_valid_name("a.b-c9")); + assert!(!is_valid_name("")); + assert!(!is_valid_name("-lead")); + assert!(!is_valid_name("trail-")); + assert!(!is_valid_name("Upper")); + assert!(!is_valid_name("has space")); + assert!(!is_valid_name("under_score")); + assert!(!is_valid_name(&"x".repeat(MAX_NAME_LEN + 1))); + } + + #[test] + fn manifest_omits_absent_optional_fields() { + let bare = Manifest::new("pdf-tools".into(), None, None).to_json(); + assert!(bare.contains(SCHEMA_URL)); + assert!(!bare.contains("version")); + assert!(!bare.contains("description")); + + let full = Manifest::new("pdf-tools".into(), Some("1.2.0".into()), Some("d".into())); + let json: serde_json::Value = serde_json::from_str(&full.to_json()).expect("json"); + assert_eq!(json["$schema"], SCHEMA_URL); + assert_eq!(json["name"], "pdf-tools"); + assert_eq!(json["version"], "1.2.0"); + assert_eq!(json["description"], "d"); + } +} diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs new file mode 100644 index 00000000..070e39d0 --- /dev/null +++ b/src/agent_plugin/mod.rs @@ -0,0 +1,290 @@ +//! Compiling a gated symposium plugin into an agent plugin directory. +//! +//! The directory is the unit agents themselves use: a manifest beside a +//! `skills/` folder. Compilation happens after every predicate has been +//! evaluated, so what lands on disk is only what applies — an agent never +//! receives a gate and never resolves one. + +pub mod manifest; + +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; + +use crate::config::PluginsConfig; +use crate::plugins::ParsedPlugin; +use crate::pm::{ANY_VERSION, CARGO_PM}; +use crate::skills::SkillWithGroupContext; +use manifest::Manifest; + +/// The directory under a project root that symposium owns outright, so one +/// `.gitignore` at its root covers everything below it. +pub const PROJECT_OWNED_DIR: &str = ".symposium"; + +/// Staging directory for compiled plugins within [`PROJECT_OWNED_DIR`]. +pub const PROJECT_STAGING_SUBDIR: &str = "plugins"; + +/// Staging directory under the user configuration directory. +/// +/// Deliberately not `plugins/`, which is the builtin `user-plugins` *registry* — +/// a directory symposium reads entries from. Compiling into it would make +/// symposium ingest its own output as registry plugins on the next load. +pub const GLOBAL_STAGING_DIR: &str = "installed"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + Project, + Global, +} + +impl Scope { + /// Where a plugin's compiled directory belongs. + /// + /// Global installation requires the decision to be reproducible from user + /// config alone: a user-level directory is visible from every workspace, and + /// cleanup reaps whatever it did not install this run, so a global set that + /// varied by workspace would have two projects undoing each other. Anything + /// whose activation *or content* depends on this workspace is therefore + /// project-scoped, even when a global `use` entry named it. + /// + /// Content matters as much as activation: a plugin gated `depends-on(*)` + /// whose skill group is gated `depends-on(serde)` would compile to different + /// directories in different projects, which is the same churn by another + /// route. So every gate in the chain has to hold workspace-independently — + /// the plugin's, each declared group's, and each contributed skill's. + pub fn of( + parsed: &ParsedPlugin, + contributed: &[&SkillWithGroupContext], + plugins: &PluginsConfig, + ) -> Scope { + let workspace_bound = parsed.workspace_member + || parsed.canonical.pm == CARGO_PM + || !parsed.plugin.predicates.is_workspace_independent() + || (parsed.plugin.requires_use && !plugins.is_used_globally(&parsed.plugin.name)) + || parsed + .plugin + .skills + .iter() + .any(|group| !group.predicates.is_workspace_independent()) + || contributed + .iter() + .any(|entry| !entry.skill.predicates.is_workspace_independent()); + if workspace_bound { + Scope::Project + } else { + Scope::Global + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Scope::Project => "project", + Scope::Global => "global", + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledSkill { + pub dir_name: String, + /// Directory holding the skill's `SKILL.md`, copied verbatim. + pub source_dir: PathBuf, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledPlugin { + pub dir_name: String, + pub manifest: Manifest, + pub scope: Scope, + pub skills: Vec, +} + +/// Group already-gated skills into one compiled plugin per contributing plugin. +/// +/// A plugin with no applicable skills compiles to nothing: version one carries +/// only the format's skills component, so such a directory would be empty. +pub fn compile( + active: &[ParsedPlugin], + skills: &[SkillWithGroupContext], + plugins: &PluginsConfig, +) -> Vec { + let mut compiled: Vec<(String, CompiledPlugin)> = Vec::new(); + + for parsed in active { + let mine: Vec<&SkillWithGroupContext> = skills + .iter() + .filter(|s| s.plugin_id == parsed.canonical) + .collect(); + if mine.is_empty() { + continue; + } + + let Some(name) = manifest::slug(&parsed.plugin.name) else { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!( + "cannot compile plugin `{}`: no valid agent plugin name", + parsed.plugin.name + ), + }, + ); + continue; + }; + + compiled.push(( + crate::skills::hash_origin_key(&parsed.canonical.to_string()), + CompiledPlugin { + dir_name: name.clone(), + manifest: Manifest::new( + name, + version_of(parsed), + parsed.plugin.description.clone(), + ), + scope: Scope::of(parsed, &mine, plugins), + skills: compile_skills(&mine), + }, + )); + } + + disambiguate(compiled) +} + +/// Two plugin names can slug to the same directory name, so whenever more than +/// one plugin claims a slug, every claimant takes the suffixed form. Suffixing +/// all of them rather than all-but-one keeps a name stable when an unrelated +/// plugin appears or disappears. +fn disambiguate(compiled: Vec<(String, CompiledPlugin)>) -> Vec { + let mut claims: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); + for (_, plugin) in &compiled { + *claims.entry(plugin.dir_name.as_str()).or_default() += 1; + } + let contested: std::collections::BTreeSet = claims + .into_iter() + .filter(|(_, n)| *n > 1) + .map(|(name, _)| name.to_string()) + .collect(); + + compiled + .into_iter() + .map(|(hash, mut plugin)| { + if contested.contains(&plugin.dir_name) { + plugin.dir_name = format!("{}-{hash}", plugin.dir_name); + } + plugin + }) + .collect() +} + +/// One skill directory per distinct origin. Skills sharing a name within one +/// plugin take the origin-hash suffix; across plugins they do not collide, +/// because the agent namespaces a plugin's skills under the plugin. +fn compile_skills(skills: &[&SkillWithGroupContext]) -> Vec { + let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); + let mut name_counts: std::collections::BTreeMap<&str, usize> = + std::collections::BTreeMap::new(); + let mut distinct: Vec<&&SkillWithGroupContext> = Vec::new(); + + for skill in skills { + if seen.insert(&skill.origin_hash) { + *name_counts.entry(skill.skill.name()).or_default() += 1; + distinct.push(skill); + } + } + + distinct + .into_iter() + .filter_map(|entry| { + let name = entry.skill.name(); + let source_dir = entry.skill.path.parent()?.to_path_buf(); + let dir_name = if name_counts.get(name).copied().unwrap_or(0) == 1 { + name.to_string() + } else { + format!("{name}-{}", entry.origin_hash) + }; + Some(CompiledSkill { + dir_name, + source_dir, + }) + }) + .collect() +} + +/// The manifest's version wins; otherwise a crate plugin's resolved version +/// stands in. A registry or workspace plugin has no real package identity, so +/// its placeholder `*` is not a version and is dropped. +fn version_of(parsed: &ParsedPlugin) -> Option { + parsed.plugin.version.clone().or_else(|| { + (parsed.canonical.version != ANY_VERSION).then(|| parsed.canonical.version.clone()) + }) +} + +/// Write a compiled plugin into `root`, returning its directory. +/// +/// The content is assembled in a temporary directory and then handed to the +/// ordinary managed-directory sync, so the install is change-aware and +/// debounced exactly like a skill directory: recompiling identical content +/// leaves the destination untouched. +pub fn write( + compiled: &CompiledPlugin, + root: &Path, + boundary: &Path, + debounce: Duration, +) -> Result { + let staged = tempfile::tempdir().context("create staging dir")?; + fs::write( + staged.path().join("plugin.json"), + compiled.manifest.to_json(), + ) + .context("write plugin.json")?; + + for skill in &compiled.skills { + let dest = staged.path().join("skills").join(&skill.dir_name); + fs::create_dir_all(&dest).with_context(|| format!("create {}", dest.display()))?; + crate::sync::copy_dir_recursive(&skill.source_dir, &dest) + .with_context(|| format!("copy skill {}", skill.dir_name))?; + } + + let dest = root.join(&compiled.dir_name); + crate::sync::sync_managed_dir( + staged.path(), + &dest, + boundary, + debounce, + crate::sync::Marking::MarkerOnly, + )?; + Ok(dest) +} + +/// Reap compiled directories under `root` that this sync did not write. Keyed on +/// the ownership marker, so a directory the user put there is left alone. +pub fn reap(root: &Path, written: &std::collections::BTreeSet) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() || written.contains(&path) || !crate::sync::has_symposium_marker(&path) { + continue; + } + match fs::remove_dir_all(&path) { + Ok(()) => tracing::info!( + report = %crate::report::ReportEvent::SkillRemoved { + path: crate::output::display_path(&path), + }, + ), + Err(e) => tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!( + "failed to remove stale {}: {e}", + crate::output::display_path(&path) + ), + }, + ), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs new file mode 100644 index 00000000..b1a6f74c --- /dev/null +++ b/src/agent_plugin/tests.rs @@ -0,0 +1,347 @@ +use std::collections::BTreeMap; + +use super::*; +use crate::config::UseEntry; +use crate::plugins::{Plugin, PluginSource, SkillGroup}; +use crate::pm::{ANY_VERSION, PackageId}; +use crate::predicate::{Predicate, PredicateSet}; +use crate::skills::Skill; + +fn wildcard() -> PredicateSet { + PredicateSet::from_depends_on("*").expect("wildcard") +} + +fn on_serde() -> PredicateSet { + PredicateSet::from_depends_on("serde").expect("serde") +} + +fn registry_plugin(name: &str, predicates: PredicateSet) -> ParsedPlugin { + ParsedPlugin { + plugin: Plugin { + name: name.to_string(), + predicates, + ..Default::default() + }, + workspace_member: false, + canonical: PackageId::new("user-plugins", name, ANY_VERSION), + } +} + +fn skill_of(plugin: &ParsedPlugin, name: &str, path: &str) -> SkillWithGroupContext { + SkillWithGroupContext { + skill: Skill { + frontmatter: BTreeMap::from([("name".to_string(), name.to_string())]), + predicates: PredicateSet::default(), + path: PathBuf::from(path), + }, + origin_hash: crate::skills::hash_origin_key(&path), + plugin: plugin.plugin.name.clone(), + plugin_id: plugin.canonical.clone(), + } +} + +fn no_config() -> PluginsConfig { + PluginsConfig::default() +} + +// ── scope ──────────────────────────────────────────────────────────── + +#[test] +fn wildcard_registry_plugin_is_global() { + let plugin = registry_plugin("pdf-tools", wildcard()); + assert_eq!(Scope::of(&plugin, &[], &no_config()), Scope::Global); +} + +#[test] +fn a_concrete_dependency_gate_keeps_a_plugin_project_scoped() { + let plugin = registry_plugin("pdf-tools", on_serde()); + assert_eq!(Scope::of(&plugin, &[], &no_config()), Scope::Project); +} + +#[test] +fn workspace_members_and_crate_plugins_are_project_scoped() { + let mut member = registry_plugin("house-style", wildcard()); + member.workspace_member = true; + assert_eq!(Scope::of(&member, &[], &no_config()), Scope::Project); + + let mut from_crate = registry_plugin("widget", wildcard()); + from_crate.canonical = PackageId::new("cargo", "widget", "1.0.0"); + assert_eq!(Scope::of(&from_crate, &[], &no_config()), Scope::Project); +} + +#[test] +fn a_dormant_plugin_goes_global_only_when_used_globally() { + let mut dormant = registry_plugin("pdf-tools", PredicateSet::default()); + dormant.plugin.requires_use = true; + + assert_eq!(Scope::of(&dormant, &[], &no_config()), Scope::Project); + + let workspace_scoped = PluginsConfig { + used: vec![UseEntry::Workspace { + name: "pdf-tools".into(), + workspace: PathBuf::from("/work/reporter"), + }], + ..Default::default() + }; + assert_eq!( + Scope::of(&dormant, &[], &workspace_scoped), + Scope::Project, + "a workspace `use` entry is workspace-dependent by definition" + ); + + let globally = PluginsConfig { + used: vec![UseEntry::Global("pdf_tools".into())], + ..Default::default() + }; + assert_eq!( + Scope::of(&dormant, &[], &globally), + Scope::Global, + "global `use` names match hyphen/underscore-insensitively" + ); +} + +#[test] +fn a_dependency_gated_group_or_skill_keeps_the_plugin_project_scoped() { + let mut grouped = registry_plugin("pdf-tools", wildcard()); + grouped.plugin.skills = vec![SkillGroup { + predicates: on_serde(), + source: PluginSource::Path(PathBuf::from("skills")), + source_label: None, + workspace_member: false, + }]; + assert_eq!(Scope::of(&grouped, &[], &no_config()), Scope::Project); + + let plugin = registry_plugin("pdf-tools", wildcard()); + let mut gated = skill_of(&plugin, "extract-tables", "/reg/pdf/skills/x/SKILL.md"); + gated.skill.predicates = on_serde(); + assert_eq!( + Scope::of(&plugin, &[&gated], &no_config()), + Scope::Project, + "a dep-gated skill makes the compiled content vary by workspace" + ); +} + +#[test] +fn shell_and_path_predicates_are_treated_as_workspace_dependent() { + let set = PredicateSet { + predicates: vec![Predicate::Shell("true".into())], + }; + assert!(!set.is_workspace_independent()); + + assert!(wildcard().is_workspace_independent()); + assert!(PredicateSet::default().is_workspace_independent()); + assert!(!on_serde().is_workspace_independent()); +} + +// ── compile ────────────────────────────────────────────────────────── + +#[test] +fn skills_are_grouped_under_the_plugin_that_contributed_them() { + let one = registry_plugin("pdf-tools", wildcard()); + let two = registry_plugin("csv-tools", wildcard()); + let skills = vec![ + skill_of(&one, "extract-tables", "/reg/pdf/skills/extract/SKILL.md"), + skill_of(&one, "read-forms", "/reg/pdf/skills/forms/SKILL.md"), + skill_of(&two, "split-rows", "/reg/csv/skills/split/SKILL.md"), + ]; + + let compiled = compile(&[one, two], &skills, &no_config()); + let names: Vec<(&str, usize)> = compiled + .iter() + .map(|p| (p.dir_name.as_str(), p.skills.len())) + .collect(); + assert_eq!(names, vec![("pdf-tools", 2), ("csv-tools", 1)]); +} + +#[test] +fn a_plugin_with_no_applicable_skills_compiles_to_nothing() { + let plugin = registry_plugin("pdf-tools", wildcard()); + assert!(compile(&[plugin], &[], &no_config()).is_empty()); +} + +#[test] +fn names_that_slug_alike_are_both_suffixed() { + let underscored = registry_plugin("pdf_tools", wildcard()); + let hyphenated = registry_plugin("pdf-tools", wildcard()); + let skills = vec![ + skill_of(&underscored, "a", "/reg/one/skills/a/SKILL.md"), + skill_of(&hyphenated, "b", "/reg/two/skills/b/SKILL.md"), + ]; + + let compiled = compile(&[underscored, hyphenated], &skills, &no_config()); + assert_eq!(compiled.len(), 2); + for plugin in &compiled { + assert!( + plugin.dir_name.starts_with("pdf-tools-"), + "expected a suffixed name, got {}", + plugin.dir_name + ); + assert!(manifest::is_valid_name(&plugin.dir_name)); + } + assert_ne!(compiled[0].dir_name, compiled[1].dir_name); + assert_eq!( + compiled[0].manifest.name, "pdf-tools", + "the manifest keeps the undisambiguated name; only the directory moves" + ); +} + +#[test] +fn one_skill_reached_twice_through_a_plugin_is_compiled_once() { + let plugin = registry_plugin("pdf-tools", wildcard()); + let once = skill_of(&plugin, "extract", "/reg/pdf/skills/extract/SKILL.md"); + let twice = skill_of(&plugin, "extract", "/reg/pdf/skills/extract/SKILL.md"); + let compiled = compile(&[plugin], &[once, twice], &no_config()); + assert_eq!(compiled[0].skills.len(), 1); +} + +#[test] +fn same_named_skills_from_different_paths_both_survive_with_suffixes() { + let plugin = registry_plugin("pdf-tools", wildcard()); + let skills = vec![ + skill_of(&plugin, "extract", "/reg/pdf/a/SKILL.md"), + skill_of(&plugin, "extract", "/reg/pdf/b/SKILL.md"), + ]; + let compiled = compile(&[plugin], &skills, &no_config()); + let dirs: Vec<&str> = compiled[0] + .skills + .iter() + .map(|s| s.dir_name.as_str()) + .collect(); + assert_eq!(dirs.len(), 2); + assert!(dirs.iter().all(|d| d.starts_with("extract-")), "{dirs:?}"); + assert_ne!(dirs[0], dirs[1]); +} + +#[test] +fn the_version_comes_from_the_manifest_then_the_resolved_crate() { + let mut declared = registry_plugin("pdf-tools", wildcard()); + declared.plugin.version = Some("1.2.0".into()); + assert_eq!(version_of(&declared).as_deref(), Some("1.2.0")); + + let mut from_crate = registry_plugin("widget", wildcard()); + from_crate.canonical = PackageId::new("cargo", "widget", "0.3.1"); + assert_eq!(version_of(&from_crate).as_deref(), Some("0.3.1")); + + let placeholder = registry_plugin("pdf-tools", wildcard()); + assert_eq!( + version_of(&placeholder), + None, + "the `*` placeholder is not a version" + ); +} + +// ── write and reap ─────────────────────────────────────────────────── + +fn skill_on_disk(dir: &Path, name: &str, body: &str) -> PathBuf { + let skill_dir = dir.join(name); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: d\n---\n{body}\n"), + ) + .expect("write SKILL.md"); + skill_dir.join("SKILL.md") +} + +#[test] +fn write_produces_a_manifest_beside_the_skills() { + let tmp = tempfile::tempdir().expect("tmp"); + let source = tmp.path().join("source"); + let skill_md = skill_on_disk(&source, "extract", "body"); + + let compiled = CompiledPlugin { + dir_name: "pdf-tools".into(), + manifest: Manifest::new("pdf-tools".into(), Some("1.2.0".into()), None), + scope: Scope::Global, + skills: vec![CompiledSkill { + dir_name: "extract".into(), + source_dir: skill_md.parent().unwrap().to_path_buf(), + }], + }; + + let root = tmp.path().join("staging"); + let dest = write(&compiled, &root, tmp.path(), Duration::ZERO).expect("write"); + + let manifest: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dest.join("plugin.json")).expect("read manifest")) + .expect("parse manifest"); + assert_eq!(manifest["name"], "pdf-tools"); + assert_eq!(manifest["version"], "1.2.0"); + assert_eq!(manifest["$schema"], manifest::SCHEMA_URL); + + assert!(dest.join("skills/extract/SKILL.md").is_file()); + assert!( + dest.join(crate::sync::MARKER_FILE).is_file(), + "compiled dirs carry the ownership marker so cleanup can find them" + ); + assert!( + !dest.join(".gitignore").exists(), + "the staging root carries the only .gitignore" + ); +} + +#[test] +fn rewriting_identical_content_leaves_the_directory_untouched() { + let tmp = tempfile::tempdir().expect("tmp"); + let source = tmp.path().join("source"); + let skill_md = skill_on_disk(&source, "extract", "body"); + let compiled = CompiledPlugin { + dir_name: "pdf-tools".into(), + manifest: Manifest::new("pdf-tools".into(), None, None), + scope: Scope::Global, + skills: vec![CompiledSkill { + dir_name: "extract".into(), + source_dir: skill_md.parent().unwrap().to_path_buf(), + }], + }; + let root = tmp.path().join("staging"); + + let dest = write(&compiled, &root, tmp.path(), Duration::ZERO).expect("first write"); + let installed = dest.join("skills/extract/SKILL.md"); + let before = fs::metadata(&installed) + .and_then(|m| m.modified()) + .expect("mtime"); + + write(&compiled, &root, tmp.path(), Duration::ZERO).expect("second write"); + let after = fs::metadata(&installed) + .and_then(|m| m.modified()) + .expect("mtime"); + assert_eq!(before, after, "unchanged content must not be recopied"); + + fs::write( + skill_md, + "---\nname: extract\ndescription: d\n---\nchanged\n", + ) + .expect("edit"); + write(&compiled, &root, tmp.path(), Duration::ZERO).expect("third write"); + assert!( + fs::read_to_string(&installed) + .expect("read") + .contains("changed"), + "changed content must be recopied" + ); +} + +#[test] +fn reap_removes_marked_directories_and_leaves_user_ones_alone() { + let tmp = tempfile::tempdir().expect("tmp"); + let root = tmp.path().join("staging"); + let kept = root.join("kept"); + let stale = root.join("stale"); + let user = root.join("user-authored"); + for dir in [&kept, &stale, &user] { + fs::create_dir_all(dir).expect("create"); + } + for dir in [&kept, &stale] { + fs::write(dir.join(crate::sync::MARKER_FILE), "").expect("marker"); + } + + reap(&root, &std::collections::BTreeSet::from([kept.clone()])); + + assert!(kept.is_dir(), "a directory written this run stays"); + assert!( + !stale.exists(), + "a marked directory we did not write is reaped" + ); + assert!(user.is_dir(), "an unmarked directory is never touched"); +} diff --git a/src/config.rs b/src/config.rs index 6e743b63..3927712b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,14 @@ impl PluginsConfig { .collect() } + /// Is `name` enabled by a `use` entry that applies in every workspace? + pub fn is_used_globally(&self, name: &str) -> bool { + self.used.iter().any(|entry| match entry { + UseEntry::Global(entry) => name_matches(entry, name), + UseEntry::Workspace { .. } => false, + }) + } + /// Does `name` appear in `auto-enable` (directly or via `"*"`)? pub fn is_auto_enabled(&self, name: &str) -> bool { self.auto_enable diff --git a/src/lib.rs b/src/lib.rs index 7ca53ed2..9ddebab6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agent_plugin; pub mod agents; pub mod cli; pub mod config; diff --git a/src/predicate.rs b/src/predicate.rs index 2074c9c7..9a7b650d 100644 --- a/src/predicate.rs +++ b/src/predicate.rs @@ -289,6 +289,25 @@ impl Predicate { } } + /// True when this predicate's value cannot vary by workspace. + /// + /// Only `depends-on(*)` qualifies, since it holds unconditionally. Every + /// other kind is treated as workspace-dependent: `depends-on()` and + /// `workspace-member()` by definition, `shell(...)` and a relative + /// `path_exists(...)` because they resolve against the workspace as their + /// working directory, and a custom predicate because it is opaque. `not(...)` + /// is dependent regardless of its operand — negating an unconditional truth + /// is never what a caller wants to install globally. + pub fn is_workspace_independent(&self) -> bool { + match self { + Predicate::DependsOnWildcard => true, + Predicate::Any(v) | Predicate::All(v) => { + v.iter().all(Predicate::is_workspace_independent) + } + _ => false, + } + } + /// True if this predicate names a *concrete* dependency /// (`depends-on(serde)`), as opposed to only `depends-on(*)`. /// Non-allocating — used on the hook hot path. @@ -381,6 +400,16 @@ impl PredicateSet { self.predicates.iter().any(Predicate::has_concrete_dep) } + /// True when this whole gate's value cannot vary by workspace, which is what + /// makes a global installation sound: the set of globally-installed plugins + /// has to be a function of user config alone, or syncing one project would + /// reap what another project installed. + pub fn is_workspace_independent(&self) -> bool { + self.predicates + .iter() + .all(Predicate::is_workspace_independent) + } + /// True if any dependency predicate (including `depends-on(*)`) appears /// anywhere. pub fn mentions_dep(&self) -> bool { diff --git a/src/report.rs b/src/report.rs index 97cb2415..8e1ddd1d 100644 --- a/src/report.rs +++ b/src/report.rs @@ -66,6 +66,14 @@ pub enum ReportEvent { reason: Option, }, + /// A plugin was compiled into an agent plugin directory. + PluginCompiled { + plugin: String, + scope: String, + skills: usize, + dest: String, + }, + /// A skill was installed to an agent's directory. SkillInstalled { skill: String, @@ -229,6 +237,14 @@ impl ReportEvent { format!(" skill {skill} ({plugin}): skipped ({r})") } } + Self::PluginCompiled { + plugin, + scope, + skills, + dest, + } => { + format!("📦 compiled {plugin} ({scope}, {skills} skills) → {dest}") + } Self::SkillInstalled { skill, plugin, diff --git a/src/skills.rs b/src/skills.rs index 755ab1d6..1379bf8e 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -69,7 +69,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,10 @@ pub struct SkillWithGroupContext { /// and dedup at sync time. pub origin_hash: String, pub plugin: String, + /// Canonical id of the plugin that contributed the skill. Groups skills into + /// compiled plugin directories, where the plugin *name* is only a display + /// label and two registries can supply the same one. + pub plugin_id: crate::pm::PackageId, } /// Resolve all applicable skills from the registry. @@ -576,6 +580,7 @@ fn collect_skill_applicable_to( skill, origin_hash, plugin: plugin_name.to_string(), + plugin_id: parsed.canonical.clone(), }); } diff --git a/src/sync.rs b/src/sync.rs index c9536d67..34ca15ad 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -12,6 +12,7 @@ use std::time::{Duration, SystemTime}; use anyhow::{Context, Result}; use symposium_install::UpdateLevel; +use crate::agent_plugin::Scope; use crate::agents::Agent; use crate::config::Symposium; use crate::output::{Output, display_path}; @@ -55,30 +56,51 @@ fn skills_parent_dir(agent: Agent, project_root: &Path) -> PathBuf { .to_path_buf() } -/// Mark a directory as symposium-managed: drop the `.symposium` marker -/// and a `.gitignore` containing `*` so the directory is recognized on -/// future syncs and kept out of version control. +/// Whether a managed directory also needs its own `.gitignore`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Marking { + /// Marker plus a `.gitignore` containing `*`. For a directory installed into + /// agent-owned territory such as `.claude/skills/`, where the parent holds + /// user content and so cannot be ignored wholesale. + MarkerAndGitignore, + /// Marker only. For a directory under `.symposium/`, which symposium owns + /// entirely and covers with one `.gitignore` at its root. + MarkerOnly, +} + +/// Mark a directory as symposium-managed: drop the `.symposium` marker so the +/// directory is recognized on future syncs, and, per `marking`, a `.gitignore` +/// containing `*` to keep it out of version control. /// /// Idempotent: overwrites any pre-existing marker or `.gitignore` in `dir`. -fn mark_managed_dir(dir: &Path) -> Result<()> { +fn mark_managed_dir(dir: &Path, marking: Marking) -> Result<()> { fs::write(dir.join(MARKER_FILE), "") .with_context(|| format!("write marker in {}", dir.display()))?; - fs::write(dir.join(".gitignore"), "*\n") - .with_context(|| format!("write .gitignore in {}", dir.display()))?; + if marking == Marking::MarkerAndGitignore { + fs::write(dir.join(".gitignore"), "*\n") + .with_context(|| format!("write .gitignore in {}", dir.display()))?; + } Ok(()) } +/// Write the single `.gitignore` covering the project directory symposium owns. +fn ignore_owned_dir(owned: &Path, project_root: &Path) -> Result<()> { + create_managed_dir_all(owned, project_root)?; + fs::write(owned.join(".gitignore"), "*\n") + .with_context(|| format!("write .gitignore in {}", owned.display())) +} + /// 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() } /// Recursively copy the contents of `src` into `dst`. Creates `dst` if /// missing. Regular files are copied with `fs::copy`; subdirectories are /// walked. Symlinks and other special files are ignored. -fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { +pub(crate) fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst).with_context(|| format!("create {}", dst.display()))?; for entry in fs::read_dir(src).with_context(|| format!("read {}", src.display()))? { let entry = entry?; @@ -154,11 +176,12 @@ fn dir_contents_differ(source_dir: &Path, dest_dir: &Path) -> Result { /// /// Returns `Ok(true)` if the destination was created or updated (callers /// record it as installed). Returns `Ok(false)` if skipped (no-op). -fn sync_managed_dir( +pub(crate) fn sync_managed_dir( source_dir: &Path, dest_dir: &Path, - project_root: &Path, + boundary: &Path, debounce: Duration, + marking: Marking, ) -> Result { if dest_dir == source_dir { return Ok(false); @@ -166,9 +189,9 @@ fn sync_managed_dir( // If the destination doesn't exist yet, do a fresh install. if !dest_dir.exists() { - create_managed_dir_all(dest_dir, project_root)?; + create_managed_dir_all(dest_dir, boundary)?; copy_dir_recursive(source_dir, dest_dir)?; - mark_managed_dir(dest_dir)?; + mark_managed_dir(dest_dir, marking)?; return Ok(true); } @@ -193,9 +216,9 @@ fn sync_managed_dir( // Content changed: replace entirely. fs::remove_dir_all(dest_dir).with_context(|| format!("remove {}", dest_dir.display()))?; - create_managed_dir_all(dest_dir, project_root)?; + create_managed_dir_all(dest_dir, boundary)?; copy_dir_recursive(source_dir, dest_dir)?; - mark_managed_dir(dest_dir)?; + mark_managed_dir(dest_dir, marking)?; Ok(true) } @@ -356,6 +379,62 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } + // Compile each applicable plugin into an agent plugin directory. Nothing + // reads these yet — the per-agent delivery lands with the emitters — but the + // directories are owned and reaped from here on. + let compiled = crate::agent_plugin::compile(&active, &applicable, &sym.config.plugins); + let owned_dir = project_root.join(crate::agent_plugin::PROJECT_OWNED_DIR); + let project_staging = owned_dir.join(crate::agent_plugin::PROJECT_STAGING_SUBDIR); + let global_staging = sym + .config_dir() + .join(crate::agent_plugin::GLOBAL_STAGING_DIR); + let mut staged_project: BTreeSet = BTreeSet::new(); + let mut staged_global: BTreeSet = BTreeSet::new(); + + if compiled.iter().any(|p| p.scope == Scope::Project) + && let Err(e) = ignore_owned_dir(&owned_dir, &project_root) + { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to prepare {}: {e}", display_path(&owned_dir)), + }, + ); + } + + for plugin in &compiled { + let (root, boundary, staged) = match plugin.scope { + Scope::Project => ( + &project_staging, + project_root.as_path(), + &mut staged_project, + ), + Scope::Global => (&global_staging, sym.config_dir(), &mut staged_global), + }; + match crate::agent_plugin::write(plugin, root, boundary, debounce) { + Ok(dest) => { + tracing::info!( + report = %crate::report::ReportEvent::PluginCompiled { + plugin: plugin.dir_name.clone(), + scope: plugin.scope.as_str().to_string(), + skills: plugin.skills.len(), + dest: display_path(&dest), + }, + ); + staged.insert(dest); + } + Err(e) => tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to compile plugin {}: {e}", plugin.dir_name), + }, + ), + } + } + + // Reaping the global root from a project sync is only sound because + // `Scope::of` keeps the global set a function of user config alone. + crate::agent_plugin::reap(&project_staging, &staged_project); + crate::agent_plugin::reap(&global_staging, &staged_global); + // Collect MCP servers from the same active plugin set. let mut mcp_servers: Vec = Vec::new(); for p in &active { @@ -481,7 +560,13 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve continue; } - match sync_managed_dir(source_dir, &dest_dir, &project_root, debounce) { + match sync_managed_dir( + source_dir, + &dest_dir, + &project_root, + debounce, + Marking::MarkerAndGitignore, + ) { Ok(true) => { installed_dirs.insert(dest_dir.clone()); tracing::info!( diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 1ff760e6..94789d91 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -1,5 +1,6 @@ //! Integration tests for init and sync flows. +use std::ops::Not; use std::path::{Path, PathBuf}; use serde_json::Value; @@ -2461,6 +2462,174 @@ async fn sync_crate_metadata_missing_path_dir() { .unwrap(); } +// ── Compiled agent plugin directories ──────────────────────────────── + +/// Sync compiles each applicable plugin into an agent plugin directory, choosing +/// the staging root by scope: a dependency-gated plugin is project-scoped, a +/// `depends-on = ["*"]` one is workspace-independent and so goes global. +#[tokio::test] +async fn sync_compiles_plugins_into_scoped_staging_roots() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-skill-group0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let events = ctx.sync_with_report(tracing::Level::INFO).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let project = root.join(".symposium/plugins/my-plugin"); + let global = ctx.sym.config_dir().join("installed/wildcard-plugin"); + + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string(project.join("plugin.json")).expect("read manifest"), + ) + .expect("parse manifest"); + assert_eq!(manifest["name"], "my-plugin"); + assert_eq!( + manifest["$schema"], + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + ); + assert!( + project.join("skills/serde-guidance/SKILL.md").is_file(), + "the plugin's skills are resolved into its own skills/ directory" + ); + assert!( + project.join(".symposium").is_file(), + "compiled directories carry the ownership marker" + ); + assert!( + !project.join(".gitignore").exists(), + "one .gitignore covers the whole .symposium tree" + ); + assert_eq!( + std::fs::read_to_string(root.join(".symposium/.gitignore")).expect("gitignore"), + "*\n" + ); + + assert!( + global.join("skills/wildcard-guidance/SKILL.md").is_file(), + "a workspace-independent plugin compiles to the global root, not the project" + ); + assert!( + !root.join(".symposium/plugins/wildcard-plugin").exists(), + "and not to both" + ); + + let compiled: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "plugin_compiled") + .collect(); + let mut reported: Vec<(&str, &str)> = compiled + .iter() + .map(|e| { + ( + e["plugin"].as_str().expect("plugin"), + e["scope"].as_str().expect("scope"), + ) + }) + .collect(); + reported.sort(); + assert_eq!( + reported, + vec![("my-plugin", "project"), ("wildcard-plugin", "global")] + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A plugin that stops applying has its compiled directory reaped on the next +/// sync, and the per-skill installs are unaffected by compilation. +#[tokio::test] +async fn compiled_directories_are_reaped_when_a_plugin_stops_applying() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-skill-group0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let compiled = root.join(".symposium/plugins/my-plugin"); + assert!(compiled.is_dir()); + assert!( + find_installed_skills(&root.join(".claude/skills"), "serde-guidance").len() == 1, + "the per-skill install is untouched by compilation" + ); + + let manifest = ctx + .sym + .config_dir() + .join("plugins/my-plugin/SYMPOSIUM.toml"); + std::fs::write( + &manifest, + "name = \"my-plugin\"\ndepends-on = [\"nowhere-crate\"]\n\n[[skills]]\nsource.path = \".\"\n", + )?; + ctx.symposium(&["sync"]).await?; + + assert!( + !compiled.exists(), + "a plugin that no longer applies loses its compiled directory" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Global installs are shared by every project, so one project's sync must not +/// reap what another's put there. That holds only because a globally-compiled +/// plugin's gate is workspace-independent by construction. +#[tokio::test] +async fn syncing_another_workspace_leaves_global_plugins_alone() { + with_fixture( + TestMode::SimulationOnly, + &["plugin-skill-group0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let global = ctx.sym.config_dir().join("installed/wildcard-plugin"); + assert!(global.is_dir(), "first sync installs the global plugin"); + assert!( + ctx.sym.config_dir().join("installed/my-plugin").exists().not(), + "a dependency-gated plugin must never reach the global root, or the \ + next project's sync would reap it" + ); + + let other = ctx.tempdir.join("other-workspace"); + std::fs::create_dir_all(other.join("src"))?; + std::fs::write( + other.join("Cargo.toml"), + "[package]\nname = \"other\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n", + )?; + std::fs::write(other.join("src/lib.rs"), "")?; + ctx.workspace_root = Some(other.clone()); + ctx.symposium(&["sync"]).await?; + + assert!( + global.is_dir(), + "syncing a project with no serde must not disturb the global set" + ); + assert!( + other.join(".symposium/plugins/wildcard-plugin").exists().not(), + "a global plugin is not also compiled into each project" + ); + assert!( + other.join(".symposium/plugins/my-plugin").exists().not(), + "the serde-gated plugin does not apply in a workspace without serde" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + // ── Report / verbose output tests ──────────────────────────────────── /// `sync_with_report` at INFO level emits SkillInstalled events. From 999e0566b49126ccbc59aceb4063b739b4d94f5f Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 13:34:49 -0300 Subject: [PATCH 03/14] feat(agent-plugin): emit per-agent manifests and the marketplace index --- src/agent_plugin/manifest.rs | 106 ++++++++++++++++++++++++++++++++ src/agent_plugin/mod.rs | 77 +++++++++++++++++++++-- src/agent_plugin/tests.rs | 115 +++++++++++++++++++++++++++++++++++ src/sync.rs | 19 ++++++ tests/init_sync.rs | 39 ++++++++++++ 5 files changed, 350 insertions(+), 6 deletions(-) diff --git a/src/agent_plugin/manifest.rs b/src/agent_plugin/manifest.rs index 6cc8fb09..0aa1ed5e 100644 --- a/src/agent_plugin/manifest.rs +++ b/src/agent_plugin/manifest.rs @@ -39,6 +39,77 @@ impl Manifest { } } +/// Gemini CLI reads its own manifest name, carrying just the identity. The +/// directory is otherwise the same, so this is a second file rather than a +/// second layout. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct GeminiExtension { + pub name: String, + /// Gemini requires a version, unlike the Agent Plugins manifest. + pub version: String, +} + +impl GeminiExtension { + /// Gemini rejects an extension with no version, so a plugin that declares + /// none is given one rather than being skipped. + pub const FALLBACK_VERSION: &'static str = "0.0.0"; + + pub fn new(name: String, version: Option) -> Self { + Self { + name, + version: version.unwrap_or_else(|| Self::FALLBACK_VERSION.to_string()), + } + } + + pub fn to_json(&self) -> String { + let mut json = serde_json::to_string_pretty(self).expect("manifest always serializes"); + json.push('\n'); + json + } +} + +/// The marketplace manifest at a staging root: the index Claude Code, Codex, and +/// Copilot all read to discover the plugins under it. Written at +/// `.claude-plugin/marketplace.json`, which is the one path all three accept. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Marketplace { + pub name: String, + pub owner: MarketplaceOwner, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct MarketplaceOwner { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct MarketplaceEntry { + pub name: String, + /// Relative to the marketplace root. + pub source: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} + +impl Marketplace { + pub fn new(name: String, plugins: Vec) -> Self { + Self { + name, + owner: MarketplaceOwner { + name: "symposium".to_string(), + }, + plugins, + } + } + + pub fn to_json(&self) -> String { + let mut json = serde_json::to_string_pretty(self).expect("marketplace always serializes"); + json.push('\n'); + json + } +} + /// `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1 to 64 characters. pub fn is_valid_name(name: &str) -> bool { if name.is_empty() || name.len() > MAX_NAME_LEN { @@ -143,6 +214,41 @@ mod tests { assert!(!is_valid_name(&"x".repeat(MAX_NAME_LEN + 1))); } + #[test] + fn gemini_manifest_always_carries_a_version() { + let declared = GeminiExtension::new("pdf-tools".into(), Some("1.2.0".into())); + assert_eq!(declared.version, "1.2.0"); + + let undeclared = GeminiExtension::new("pdf-tools".into(), None); + assert_eq!(undeclared.version, GeminiExtension::FALLBACK_VERSION); + + let json: serde_json::Value = serde_json::from_str(&undeclared.to_json()).expect("json"); + assert_eq!(json["name"], "pdf-tools"); + assert_eq!(json["version"], GeminiExtension::FALLBACK_VERSION); + assert!(json.get("$schema").is_none(), "gemini has its own manifest"); + } + + #[test] + fn marketplace_indexes_each_plugin_by_relative_path() { + let market = Marketplace::new( + "symposium".into(), + vec![MarketplaceEntry { + name: "pdf-tools".into(), + source: "./pdf-tools".into(), + description: Some("Table extraction guidance".into()), + }], + ); + let json: serde_json::Value = serde_json::from_str(&market.to_json()).expect("json"); + assert_eq!(json["name"], "symposium"); + assert_eq!(json["owner"]["name"], "symposium"); + assert_eq!(json["plugins"][0]["name"], "pdf-tools"); + assert_eq!(json["plugins"][0]["source"], "./pdf-tools"); + assert_eq!( + json["plugins"][0]["description"], + "Table extraction guidance" + ); + } + #[test] fn manifest_omits_absent_optional_fields() { let bare = Manifest::new("pdf-tools".into(), None, None).to_json(); diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index 070e39d0..f0a74942 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -17,7 +17,7 @@ use crate::config::PluginsConfig; use crate::plugins::ParsedPlugin; use crate::pm::{ANY_VERSION, CARGO_PM}; use crate::skills::SkillWithGroupContext; -use manifest::Manifest; +use manifest::{GeminiExtension, Manifest, Marketplace, MarketplaceEntry}; /// The directory under a project root that symposium owns outright, so one /// `.gitignore` at its root covers everything below it. @@ -26,6 +26,27 @@ pub const PROJECT_OWNED_DIR: &str = ".symposium"; /// Staging directory for compiled plugins within [`PROJECT_OWNED_DIR`]. pub const PROJECT_STAGING_SUBDIR: &str = "plugins"; +/// Marketplace name for the global staging root. +const MARKETPLACE_NAME: &str = "symposium"; + +/// Marketplace name for a staging root. +/// +/// A project root needs a name of its own because marketplace *registration* is +/// user-level even for a project-scoped plugin (verified against Claude Code), so +/// two projects both registering `symposium` would overwrite each other's path. +pub fn marketplace_name(scope: Scope, project_root: &Path) -> String { + match scope { + Scope::Global => MARKETPLACE_NAME.to_string(), + Scope::Project => { + let scoped = format!( + "{MARKETPLACE_NAME}-{}", + crate::pm::workspace_dir_name(project_root) + ); + manifest::slug(&scoped).unwrap_or_else(|| MARKETPLACE_NAME.to_string()) + } + } +} + /// Staging directory under the user configuration directory. /// /// Deliberately not `plugins/`, which is the builtin `user-plugins` *registry* — @@ -233,11 +254,7 @@ pub fn write( debounce: Duration, ) -> Result { let staged = tempfile::tempdir().context("create staging dir")?; - fs::write( - staged.path().join("plugin.json"), - compiled.manifest.to_json(), - ) - .context("write plugin.json")?; + write_manifests(staged.path(), &compiled.manifest)?; for skill in &compiled.skills { let dest = staged.path().join("skills").join(&skill.dir_name); @@ -257,6 +274,54 @@ pub fn write( Ok(dest) } +/// Every dialect of the same identity, side by side. Claude Code ignores a root +/// `plugin.json` and Agent Plugins agents ignore `.claude-plugin/`, so carrying +/// both costs nothing and saves a second directory; Gemini reads only its own +/// file. Verified by loading one directory in Claude Code, Codex, and Copilot. +fn write_manifests(dir: &Path, manifest: &Manifest) -> Result<()> { + fs::write(dir.join("plugin.json"), manifest.to_json()).context("write plugin.json")?; + + let claude_dir = dir.join(".claude-plugin"); + fs::create_dir_all(&claude_dir).context("create .claude-plugin")?; + fs::write(claude_dir.join("plugin.json"), manifest.to_json()) + .context("write .claude-plugin/plugin.json")?; + + let gemini = GeminiExtension::new(manifest.name.clone(), manifest.version.clone()); + fs::write(dir.join("gemini-extension.json"), gemini.to_json()) + .context("write gemini-extension.json") +} + +/// Write the marketplace index for a staging root, or remove it when the root no +/// longer holds any compiled plugin. Claude Code, Codex, and Copilot all +/// discover plugins through this one file. +pub fn write_marketplace(root: &Path, name: &str, plugins: &[&CompiledPlugin]) -> Result<()> { + let dir = root.join(".claude-plugin"); + let file = dir.join("marketplace.json"); + + if plugins.is_empty() { + if file.exists() { + fs::remove_file(&file).with_context(|| format!("remove {}", file.display()))?; + } + return Ok(()); + } + + let entries = plugins + .iter() + .map(|plugin| MarketplaceEntry { + name: plugin.manifest.name.clone(), + source: format!("./{}", plugin.dir_name), + description: plugin.manifest.description.clone(), + }) + .collect(); + + fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?; + let contents = Marketplace::new(name.to_string(), entries).to_json(); + if fs::read_to_string(&file).is_ok_and(|existing| existing == contents) { + return Ok(()); + } + fs::write(&file, contents).with_context(|| format!("write {}", file.display())) +} + /// Reap compiled directories under `root` that this sync did not write. Keyed on /// the ownership marker, so a directory the user put there is left alone. pub fn reap(root: &Path, written: &std::collections::BTreeSet) { diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index b1a6f74c..42c0025b 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -269,6 +269,22 @@ fn write_produces_a_manifest_beside_the_skills() { assert_eq!(manifest["version"], "1.2.0"); assert_eq!(manifest["$schema"], manifest::SCHEMA_URL); + let claude: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dest.join(".claude-plugin/plugin.json")).expect("read claude manifest"), + ) + .expect("parse claude manifest"); + assert_eq!( + claude, manifest, + "Claude Code reads its own path but the same content" + ); + + let gemini: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dest.join("gemini-extension.json")).expect("read gemini manifest"), + ) + .expect("parse gemini manifest"); + assert_eq!(gemini["name"], "pdf-tools"); + assert_eq!(gemini["version"], "1.2.0"); + assert!(dest.join("skills/extract/SKILL.md").is_file()); assert!( dest.join(crate::sync::MARKER_FILE).is_file(), @@ -345,3 +361,102 @@ fn reap_removes_marked_directories_and_leaves_user_ones_alone() { ); assert!(user.is_dir(), "an unmarked directory is never touched"); } + +#[test] +fn a_plugin_with_no_version_still_gets_a_gemini_manifest() { + let tmp = tempfile::tempdir().expect("tmp"); + let skill_md = skill_on_disk(&tmp.path().join("source"), "extract", "body"); + let compiled = CompiledPlugin { + dir_name: "pdf-tools".into(), + manifest: Manifest::new("pdf-tools".into(), None, None), + scope: Scope::Global, + skills: vec![CompiledSkill { + dir_name: "extract".into(), + source_dir: skill_md.parent().unwrap().to_path_buf(), + }], + }; + let dest = write( + &compiled, + &tmp.path().join("staging"), + tmp.path(), + Duration::ZERO, + ) + .expect("write"); + + let root: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dest.join("plugin.json")).expect("read")) + .expect("json"); + assert!( + root.get("version").is_none(), + "the Agent Plugins manifest leaves an unknown version out" + ); + + let gemini: serde_json::Value = serde_json::from_str( + &fs::read_to_string(dest.join("gemini-extension.json")).expect("read"), + ) + .expect("json"); + assert_eq!( + gemini["version"], + manifest::GeminiExtension::FALLBACK_VERSION, + "gemini requires one, so it is filled in rather than dropping the plugin" + ); +} + +#[test] +fn the_marketplace_index_lists_each_plugin_and_is_removed_when_empty() { + let tmp = tempfile::tempdir().expect("tmp"); + let root = tmp.path().join("staging"); + fs::create_dir_all(&root).expect("create root"); + + let one = CompiledPlugin { + dir_name: "pdf-tools-ab12cd34".into(), + manifest: Manifest::new("pdf-tools".into(), None, Some("Tables".into())), + scope: Scope::Global, + skills: Vec::new(), + }; + let two = CompiledPlugin { + dir_name: "csv-tools".into(), + manifest: Manifest::new("csv-tools".into(), None, None), + scope: Scope::Global, + skills: Vec::new(), + }; + + write_marketplace(&root, "symposium", &[&one, &two]).expect("write index"); + let file = root.join(".claude-plugin/marketplace.json"); + let index: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&file).expect("read")).expect("json"); + assert_eq!(index["name"], "symposium"); + assert_eq!( + index["plugins"][0]["source"], "./pdf-tools-ab12cd34", + "the entry points at the directory, which may be disambiguated" + ); + assert_eq!( + index["plugins"][0]["name"], "pdf-tools", + "while the plugin keeps its declared name" + ); + assert_eq!(index["plugins"][1]["name"], "csv-tools"); + assert!(index["plugins"][1].get("description").is_none()); + + write_marketplace(&root, "symposium", &[]).expect("remove index"); + assert!( + !file.exists(), + "a root with no compiled plugins must not advertise a marketplace" + ); +} + +#[test] +fn a_project_marketplace_is_named_per_workspace() { + let global = marketplace_name(Scope::Global, Path::new("/work/reporter")); + assert_eq!(global, "symposium"); + + let one = marketplace_name(Scope::Project, Path::new("/work/reporter")); + let two = marketplace_name(Scope::Project, Path::new("/elsewhere/reporter")); + assert!(one.starts_with("symposium-reporter-"), "{one}"); + assert_ne!( + one, two, + "registration is user-level, so two projects must not claim one name" + ); + for name in [&global, &one, &two] { + assert!(manifest::is_valid_name(name), "{name}"); + } +} diff --git a/src/sync.rs b/src/sync.rs index 34ca15ad..457f8182 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -435,6 +435,25 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve crate::agent_plugin::reap(&project_staging, &staged_project); crate::agent_plugin::reap(&global_staging, &staged_global); + for (scope, root) in [ + (Scope::Project, &project_staging), + (Scope::Global, &global_staging), + ] { + let in_root: Vec<&crate::agent_plugin::CompiledPlugin> = + compiled.iter().filter(|p| p.scope == scope).collect(); + if in_root.is_empty() && !root.exists() { + continue; + } + let name = crate::agent_plugin::marketplace_name(scope, &project_root); + if let Err(e) = crate::agent_plugin::write_marketplace(root, &name, &in_root) { + tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to index {}: {e}", display_path(root)), + }, + ); + } + } + // Collect MCP servers from the same active plugin set. let mut mcp_servers: Vec = Vec::new(); for p in &active { diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 94789d91..7fee66ed 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2510,6 +2510,45 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { global.join("skills/wildcard-guidance/SKILL.md").is_file(), "a workspace-independent plugin compiles to the global root, not the project" ); + + for dir in [&project, &global] { + assert!( + dir.join(".claude-plugin/plugin.json").is_file(), + "Claude Code reads its own manifest path" + ); + assert!( + dir.join("gemini-extension.json").is_file(), + "Gemini reads its own manifest" + ); + } + + let index: Value = serde_json::from_str( + &std::fs::read_to_string( + ctx.sym + .config_dir() + .join("installed/.claude-plugin/marketplace.json"), + ) + .expect("read global marketplace index"), + ) + .expect("parse index"); + assert_eq!(index["name"], "symposium"); + assert_eq!(index["plugins"][0]["name"], "wildcard-plugin"); + assert_eq!(index["plugins"][0]["source"], "./wildcard-plugin"); + + let project_index: Value = serde_json::from_str( + &std::fs::read_to_string( + root.join(".symposium/plugins/.claude-plugin/marketplace.json"), + ) + .expect("read project marketplace index"), + ) + .expect("parse index"); + assert!( + project_index["name"] + .as_str() + .expect("name") + .starts_with("symposium-"), + "a project marketplace is named per workspace, since registration is user-level" + ); assert!( !root.join(".symposium/plugins/wildcard-plugin").exists(), "and not to both" From 4ea316d1d2614708452596b749bce08a2bc3d617 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 15:08:16 -0300 Subject: [PATCH 04/14] feat(agent-plugin): deliver compiled plugin directories to each agent --- md/design/agents.md | 20 +- md/design/important-flows.md | 12 +- md/design/module-structure.md | 32 +- src/agent_plugin/manifest.rs | 42 +- src/agent_plugin/mod.rs | 105 +++-- src/agent_plugin/tests.rs | 128 ++++-- src/agents/mod.rs | 5 +- src/agents/plugin_install.rs | 329 +++++++++++++++ src/agents/plugin_install/tests.rs | 374 ++++++++++++++++++ src/report.rs | 16 + src/sync.rs | 108 ++++- tests/custom_predicates.rs | 143 ++++--- tests/enablement.rs | 80 ++-- .../dot-symposium/config.toml | 8 + .../plugins/global-tools/SYMPOSIUM.toml | 5 + .../global-tools/global-guidance/SKILL.md | 5 + .../plugins/project-tools/SYMPOSIUM.toml | 5 + .../project-tools/project-guidance/SKILL.md | 5 + tests/init_sync.rs | 286 ++++++++------ 19 files changed, 1386 insertions(+), 322 deletions(-) create mode 100644 src/agents/plugin_install.rs create mode 100644 src/agents/plugin_install/tests.rs create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/SYMPOSIUM.toml create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/global-guidance/SKILL.md create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/SYMPOSIUM.toml create mode 100644 tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/project-guidance/SKILL.md diff --git a/md/design/agents.md b/md/design/agents.md index 95a92486..817c3707 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -21,13 +21,29 @@ 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** — hand each agent the plugins that apply, as a [compiled plugin directory](./module-structure.md#agentsplugin_installrs--handing-a-directory-to-an-agent) where the agent has such a unit, and as individual skill files where it does not. 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)). ## Extension locations -When installing skills, `cargo agents` prefers vendor-neutral paths where possible: +### Plugin directories + +An agent with a plugin unit receives a compiled directory instead of loose skill files. Only Claude Code can scope one to a project; for the others a project-scoped plugin falls back to the per-skill paths below, and OpenCode, Goose, and Kiro use those paths for everything. + +| Agent | How it is given the directory | Project scope | +|-------|-------------------------------|---------------| +| Claude Code | marketplace registration in user settings plus `known_marketplaces.json`; enabled via `enabledPlugins` | yes | +| Codex CLI | `[marketplaces.*]` in `config.toml`, plus a copy in `plugins/cache/` | no | +| GitHub Copilot | `extraKnownMarketplaces` in `~/.copilot/settings.json`, plus a copy in `installed-plugins/` | no | +| Gemini CLI | a copy in `~/.gemini/extensions/` — no configuration at all | no | +| Kiro, OpenCode, Goose | *(no plugin unit)* | n/a | + +A skill delivered inside a plugin is namespaced by the agent as `:`. + +### Skill paths + +When installing individual skills, `cargo agents` prefers vendor-neutral paths where possible: | Scope | Path | Supported by | |-------|------|-------------| diff --git a/md/design/important-flows.md b/md/design/important-flows.md index a5807969..47de87a5 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -29,17 +29,19 @@ 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` (`Plugin::requires_use`), `predicate.rs` (`PredicateContext::is_used`), and `skills.rs` (`active_plugins`, `record_active`). -## Compilation into agent plugin directories +## Compilation and delivery of agent plugin directories -Every `cargo agents sync` compiles the plugins that apply into the directory unit agents consume. The step runs after skills are resolved, so it never re-evaluates a gate. +Every `cargo agents sync` compiles the plugins that apply into the directory unit agents consume, then hands each directory to the agents that can take it. The step runs after skills are resolved, so it never re-evaluates a gate. 1. `agent_plugin::compile` groups the applicable skills by their contributing plugin's `canonical` id and builds one `CompiledPlugin` each: a manifest name (slugged into the format's grammar), an optional version (the manifest's, else a crate plugin's resolved version — a registry placeholder `*` is not a version), the plugin's description, and one skill entry per distinct origin. 2. Directory names are disambiguated across plugins, and skill directory names within each plugin, using the same origin-hash suffix rule that already governs skill installs. -3. `Scope::of` sends each compiled plugin to `/.symposium/plugins/` or `/installed/`. Global requires the plugin, its groups, and its skills to all be workspace-independent, and a dormant plugin to be woken by a *global* `use` entry — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for why that is a correctness requirement and not a preference. +3. `Scope::of` sends each compiled plugin to `/.symposium/plugins/` or `/installed/`. Global requires both a `use --global` entry naming the plugin *and* every gate in its chain (plugin, groups, contributed skills) to hold workspace-independently — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for why the second half is a correctness requirement and not a preference. A scope no configured agent can take is not compiled at all. 4. `agent_plugin::write` stages the content in a temporary directory and syncs it in through `sync::sync_managed_dir`, so an unchanged plugin is not recopied. The directory gets the `.symposium` marker; the project tree's single `.gitignore` is written at `.symposium/` rather than into each plugin. -5. `agent_plugin::reap` removes marked directories under each root that this sync did not write. Reaping the global root from a project sync is sound only because step 3 keeps the global set a function of user config alone. +5. `write_marketplace` writes `.claude-plugin/marketplace.json` at each staging root, the one index path Claude Code, Codex, and Copilot all read, and removes it when a root holds no plugins. +6. For each configured agent and each scope the agent accepts, `Agent::install_plugins` writes that agent's configuration and, where the agent loads only from its own tree, copies the directory there. The plugins an agent received are recorded, and their skills are then skipped in the per-skill loop — so a skill is installed individually only for an agent that could not take its plugin, and nothing arrives twice. +7. `agent_plugin::reap_to_depth` removes marked directories this sync did not write, under both staging roots and under every known agent's plugin tree (so an agent dropped from the config is cleaned up too). Reaping the global root from a project sync is sound only because step 3 keeps the global set a function of user config alone. -Per-agent delivery of these directories is not wired up yet; the per-skill install path is still what reaches agents. The key code paths are in `agent_plugin/mod.rs` (`compile`, `Scope::of`, `write`, `reap`), `agent_plugin/manifest.rs` (`slug`, `is_valid_name`), `predicate.rs` (`is_workspace_independent`), and `sync.rs`. +The key code paths are in `agent_plugin/mod.rs` (`compile`, `Scope::of`, `write`, `write_marketplace`, `reap_to_depth`), `agent_plugin/manifest.rs` (`slug`, `is_valid_name`, the three manifest shapes), `agents/plugin_install.rs` (`accepts_plugin_scope`, `install_plugins`, `plugin_reap_roots`), `predicate.rs` (`is_workspace_independent`), and `sync.rs`. ## Help rendering diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 7bb32ad5..164500cd 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,7 +24,7 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync also compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects, and reaps the compiled directories it did not write. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects — but only for a scope some configured agent can actually take, since otherwise the directory would sit unread — hands each to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps both the compiled directories and the agent-side copies it did not write. The debounce that keeps the per-event hook path cheap applies only when the caller asked for no update; an explicit `sync` or the `SessionStart` catch-up pass compares content so a directory changed since the last sync is restored rather than skipped. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. One entry point, `sync(sym, deps, update)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. @@ -53,7 +53,7 @@ Workspace-scoped callers use `load_registry_with_workspace`, which additionally Turns an already-gated plugin into the unit agents themselves consume: a manifest beside a `skills/` directory, per the [Agent Plugins](https://agent-plugins.org/) format. Because every predicate has been evaluated by the time compilation runs, the emitted directory holds only what applies — an agent never receives a gate and never resolves one. -`manifest.rs` models the manifest fields symposium emits and owns the format's **name grammar** (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars). That grammar is narrower than a symposium plugin name, which may be a crate name with underscores or a free-form manifest string, so `slug` normalizes one into the other. Two distinct names can slug alike (`foo_bar` and `foo-bar`), which is why directory disambiguation keys on the *slug*, not the original name. +`manifest.rs` models the three manifests one compiled directory carries and owns the format's **name grammar** (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars). That grammar is narrower than a symposium plugin name, which may be a crate name with underscores or a free-form manifest string, so `slug` normalizes one into the other. Two distinct names can slug alike (`foo_bar` and `foo-bar`), which is why directory disambiguation keys on the *slug*, not the original name. `compile(active, skills, plugins)` groups the applicable skills by their contributing plugin's `canonical` id — the name is only a display label, since two registries can supply the same one. A plugin with no applicable skills compiles to nothing (version one carries only the skills component, so the directory would be empty). Skills sharing a name *within* one plugin take an origin-hash suffix; across plugins they do not collide, because agents namespace a plugin's skills under the plugin (`pdf-tools:extract-tables`). When more than one plugin claims a directory name, every claimant takes the suffixed form, so a name stays stable as unrelated plugins come and go. @@ -64,11 +64,33 @@ Turns an already-gated plugin into the unit agents themselves consume: a manifes | `Project` | `/.symposium/plugins/` | symposium owns the whole `.symposium/` tree, so one `.gitignore` with `*` sits at its root rather than one per directory | | `Global` | `/installed/` | deliberately **not** `plugins/`, which is the builtin `user-plugins` registry — compiling there would make symposium ingest its own output as registry plugins on the next load | -Global is the narrow case, and the rule is a safety property rather than a preference. A user-level directory is visible from every workspace while cleanup reaps whatever it did not install this run, so a global set that varied by workspace would have two projects undoing each other on every session start. A plugin therefore goes global only when nothing about it can vary by workspace: it is not a workspace member, not crate-sourced, its own gate is workspace-independent, a *global* `use` entry wakes it if it is dormant, and every declared skill group and contributed skill is workspace-independent too. That last clause matters as much as the first — a plugin gated `depends-on(*)` whose group is gated `depends-on(serde)` would compile to different content in different projects, which is the same churn by another route. +Global is the narrow case, and needs two independent things to hold. First the user must have asked for it, with a `use --global` entry naming the plugin: scope follows the enablement, so a project's sync never writes a plugin into the user's home that was not enabled there, not even one gated `depends-on(*)`. Second, nothing about the plugin may vary by workspace — it is not a workspace member, not crate-sourced, and its own gate, every declared skill group's gate, and every contributed skill's gate all hold workspace-independently. That second half is a safety property rather than a preference: a user-level directory is visible from every workspace while cleanup reaps whatever it did not install this run, so a global set that varied by workspace would have two projects undoing each other on every session start. The group and skill clauses matter as much as the plugin's own — a plugin gated `depends-on(*)` whose group is gated `depends-on(serde)` would compile to different content in different projects, which is the same churn by another route. -`write` assembles the directory in a temporary directory and hands it to `sync::sync_managed_dir`, so the install is change-aware and debounced exactly like a skill directory: recompiling identical content leaves the destination untouched. `reap` removes marked directories the current sync did not write, keyed on the `.symposium` marker so a directory the user placed there is left alone. +One skill bundle referenced by several active plugins is emitted **once**, by the first plugin to claim it. A plugin directory is its own namespace, so emitting per plugin would not collide, but it would load identical guidance once per referencing plugin. -Nothing consumes these directories yet — per-agent delivery lands with the emitters. Compilation owns and reaps them from here on. +One directory serves every agent, because their formats differ only in which manifest they read. Claude Code ignores a root `plugin.json` (falling back to the directory name for identity) and Agent Plugins agents ignore `.claude-plugin/`, so carrying both costs nothing; Gemini reads only its own file. All three are written side by side, and `write_marketplace` adds `.claude-plugin/marketplace.json` at the staging root — the one index path Claude Code, Codex, and Copilot all accept. Each was verified by loading a directory in the running agent, not by reading documentation. + +The manifest always carries a `version`, even though the format allows omitting one, because Codex keys its plugin cache directory on the version and picks `1.0.0` itself for a version-less plugin. Emitting one means the cache path is the value symposium wrote. `UNVERSIONED` (`0.0.0`) stands in when a plugin declares none. + +`write` assembles the directory in a temporary directory and hands it to `sync::sync_managed_dir`, so the install is change-aware and debounced exactly like a skill directory: recompiling identical content leaves the destination untouched. `reap_to_depth` removes marked directories the current sync did not write, keyed on the `.symposium` marker so a directory the user placed there is left alone; the depth lets one function serve both a staging root and an agent's own tree, where Codex nests copies as `//`. + +### `agents/plugin_install.rs` — handing a directory to an agent + +Two mechanisms, and which one applies is a property of the agent. Each row below was established by installing a directory and asking the running agent what it could see, then deleting parts of the installation to find what was actually required: + +| Agent | Configuration symposium writes | Content | +|---|---|---| +| Claude Code | `extraKnownMarketplaces` in user settings, an entry in `~/.claude/plugins/known_marketplaces.json`, and `enabledPlugins` in the project's `.claude/settings.json` (project scope) or user settings (global) | **not copied** — resolved from the registered `installLocation` | +| Codex CLI | `[marketplaces.]` and `[plugins."@"] enabled` in `config.toml` | copied to `plugins/cache////` | +| Copilot CLI | `extraKnownMarketplaces` and `enabledPlugins` in `~/.copilot/settings.json` | copied to `installed-plugins///` | +| Gemini CLI | none at all | copied to `~/.gemini/extensions//` | +| Kiro, OpenCode, Goose | none | no plugin unit; skills keep arriving individually | + +Claude Code needs both of its records: with `known_marketplaces.json` missing the plugin does not load, and Claude regenerates it from settings only in time for the *next* session. Its `installed_plugins.json` record and version-keyed cache copy are **not** required — deleting them leaves the plugin working. + +`accepts_plugin_scope` is where the project-scope asymmetry lives: only Claude Code can bound a plugin to one project. The other three store plugins per user with no way to scope them, so a project-scoped plugin reaches them through the per-skill path instead. A skill is installed individually only for agents that did *not* receive its plugin, so nothing arrives twice. + +Registration is user-level even for a project-scoped plugin, which is why `marketplace_name` gives each project root a name of its own (`symposium--`); two projects registering `symposium` would otherwise overwrite each other's path. Entries are reconciled rather than appended: an entry for a plugin that no longer applies is dropped, while an entry whose marketplace symposium does not own is never touched. Copies carry the ownership marker, so `plugin_reap_roots` plus `reap_to_depth` clean up an agent's own tree the same way a staging root is cleaned. ### `installation.rs` — sources and acquisition diff --git a/src/agent_plugin/manifest.rs b/src/agent_plugin/manifest.rs index 0aa1ed5e..6d91d1cb 100644 --- a/src/agent_plugin/manifest.rs +++ b/src/agent_plugin/manifest.rs @@ -16,14 +16,17 @@ pub struct Manifest { #[serde(rename = "$schema")] pub schema: &'static str, pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub version: Option, + /// Always written, even though the format allows omitting it: Codex keys its + /// plugin cache directory on the version, and defaults a version-less plugin + /// to `1.0.0` of its own accord. Emitting one ourselves means the cache path + /// is the value we wrote rather than another tool's default. + pub version: String, #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, } impl Manifest { - pub fn new(name: String, version: Option, description: Option) -> Self { + pub fn new(name: String, version: String, description: Option) -> Self { Self { schema: SCHEMA_URL, name, @@ -45,20 +48,13 @@ impl Manifest { #[derive(Debug, Clone, PartialEq, Serialize)] pub struct GeminiExtension { pub name: String, - /// Gemini requires a version, unlike the Agent Plugins manifest. + /// Required here, unlike in the Agent Plugins manifest. pub version: String, } impl GeminiExtension { - /// Gemini rejects an extension with no version, so a plugin that declares - /// none is given one rather than being skipped. - pub const FALLBACK_VERSION: &'static str = "0.0.0"; - - pub fn new(name: String, version: Option) -> Self { - Self { - name, - version: version.unwrap_or_else(|| Self::FALLBACK_VERSION.to_string()), - } + pub fn new(name: String, version: String) -> Self { + Self { name, version } } pub fn to_json(&self) -> String { @@ -215,16 +211,13 @@ mod tests { } #[test] - fn gemini_manifest_always_carries_a_version() { - let declared = GeminiExtension::new("pdf-tools".into(), Some("1.2.0".into())); - assert_eq!(declared.version, "1.2.0"); - - let undeclared = GeminiExtension::new("pdf-tools".into(), None); - assert_eq!(undeclared.version, GeminiExtension::FALLBACK_VERSION); - - let json: serde_json::Value = serde_json::from_str(&undeclared.to_json()).expect("json"); + fn the_gemini_manifest_carries_only_its_own_fields() { + let json: serde_json::Value = serde_json::from_str( + &GeminiExtension::new("pdf-tools".into(), "1.2.0".into()).to_json(), + ) + .expect("json"); assert_eq!(json["name"], "pdf-tools"); - assert_eq!(json["version"], GeminiExtension::FALLBACK_VERSION); + assert_eq!(json["version"], "1.2.0"); assert!(json.get("$schema").is_none(), "gemini has its own manifest"); } @@ -251,12 +244,11 @@ mod tests { #[test] fn manifest_omits_absent_optional_fields() { - let bare = Manifest::new("pdf-tools".into(), None, None).to_json(); + let bare = Manifest::new("pdf-tools".into(), "0.0.0".into(), None).to_json(); assert!(bare.contains(SCHEMA_URL)); - assert!(!bare.contains("version")); assert!(!bare.contains("description")); - let full = Manifest::new("pdf-tools".into(), Some("1.2.0".into()), Some("d".into())); + let full = Manifest::new("pdf-tools".into(), "1.2.0".into(), Some("d".into())); let json: serde_json::Value = serde_json::from_str(&full.to_json()).expect("json"); assert_eq!(json["$schema"], SCHEMA_URL); assert_eq!(json["name"], "pdf-tools"); diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index f0a74942..74ffe2a1 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -63,18 +63,23 @@ pub enum Scope { impl Scope { /// Where a plugin's compiled directory belongs. /// - /// Global installation requires the decision to be reproducible from user - /// config alone: a user-level directory is visible from every workspace, and - /// cleanup reaps whatever it did not install this run, so a global set that - /// varied by workspace would have two projects undoing each other. Anything - /// whose activation *or content* depends on this workspace is therefore - /// project-scoped, even when a global `use` entry named it. + /// Global needs two independent things to hold, and defaults to project when + /// either fails. /// - /// Content matters as much as activation: a plugin gated `depends-on(*)` - /// whose skill group is gated `depends-on(serde)` would compile to different - /// directories in different projects, which is the same churn by another - /// route. So every gate in the chain has to hold workspace-independently — - /// the plugin's, each declared group's, and each contributed skill's. + /// First, the user has to have asked for it: a `use --global` entry naming the + /// plugin. Scope follows the enablement that selected a plugin, so a project's + /// sync never writes a plugin into the user's home directory that was not + /// enabled there — not even one gated `depends-on(*)`. + /// + /// Second, nothing about the plugin may vary by workspace. A user-level + /// directory is visible from every workspace while cleanup reaps whatever it + /// did not install this run, so a global set that varied by workspace would + /// have two projects undoing each other on every session start. Content + /// counts as much as activation here: a plugin gated `depends-on(*)` whose + /// skill group is gated `depends-on(serde)` would compile to different + /// content per project, which is the same churn by another route. So every + /// gate in the chain must hold workspace-independently — the plugin's, each + /// declared group's, and each contributed skill's. pub fn of( parsed: &ParsedPlugin, contributed: &[&SkillWithGroupContext], @@ -83,7 +88,6 @@ impl Scope { let workspace_bound = parsed.workspace_member || parsed.canonical.pm == CARGO_PM || !parsed.plugin.predicates.is_workspace_independent() - || (parsed.plugin.requires_use && !plugins.is_used_globally(&parsed.plugin.name)) || parsed .plugin .skills @@ -92,7 +96,7 @@ impl Scope { || contributed .iter() .any(|entry| !entry.skill.predicates.is_workspace_independent()); - if workspace_bound { + if workspace_bound || !plugins.is_used_globally(&parsed.plugin.name) { Scope::Project } else { Scope::Global @@ -116,6 +120,9 @@ pub struct CompiledSkill { #[derive(Debug, Clone, PartialEq)] pub struct CompiledPlugin { + /// The plugin this was compiled from. Lets a caller tell whether a given + /// skill is already covered by a delivered plugin directory. + pub source_id: crate::pm::PackageId, pub dir_name: String, pub manifest: Manifest, pub scope: Scope, @@ -132,6 +139,10 @@ pub fn compile( plugins: &PluginsConfig, ) -> Vec { let mut compiled: Vec<(String, CompiledPlugin)> = Vec::new(); + // One skill bundle referenced by several plugins is emitted once, by the + // first plugin to claim it. Emitting it per plugin would load identical + // guidance N times, since a plugin directory is its own namespace. + let mut claimed: std::collections::BTreeSet = std::collections::BTreeSet::new(); for parsed in active { let mine: Vec<&SkillWithGroupContext> = skills @@ -157,6 +168,7 @@ pub fn compile( compiled.push(( crate::skills::hash_origin_key(&parsed.canonical.to_string()), CompiledPlugin { + source_id: parsed.canonical.clone(), dir_name: name.clone(), manifest: Manifest::new( name, @@ -164,7 +176,7 @@ pub fn compile( parsed.plugin.description.clone(), ), scope: Scope::of(parsed, &mine, plugins), - skills: compile_skills(&mine), + skills: compile_skills(&mine, &mut claimed), }, )); } @@ -198,17 +210,20 @@ fn disambiguate(compiled: Vec<(String, CompiledPlugin)>) -> Vec .collect() } -/// One skill directory per distinct origin. Skills sharing a name within one -/// plugin take the origin-hash suffix; across plugins they do not collide, -/// because the agent namespaces a plugin's skills under the plugin. -fn compile_skills(skills: &[&SkillWithGroupContext]) -> Vec { - let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new(); +/// One skill directory per distinct origin, skipping origins an earlier plugin +/// already claimed. Skills sharing a name within one plugin take the origin-hash +/// suffix; across plugins names cannot collide, because the agent namespaces a +/// plugin's skills under the plugin. +fn compile_skills( + skills: &[&SkillWithGroupContext], + claimed: &mut std::collections::BTreeSet, +) -> Vec { let mut name_counts: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); let mut distinct: Vec<&&SkillWithGroupContext> = Vec::new(); for skill in skills { - if seen.insert(&skill.origin_hash) { + if claimed.insert(skill.origin_hash.clone()) { *name_counts.entry(skill.skill.name()).or_default() += 1; distinct.push(skill); } @@ -232,13 +247,21 @@ fn compile_skills(skills: &[&SkillWithGroupContext]) -> Vec { .collect() } +/// Stands in for a plugin that declares no version anywhere. +pub const UNVERSIONED: &str = "0.0.0"; + /// The manifest's version wins; otherwise a crate plugin's resolved version /// stands in. A registry or workspace plugin has no real package identity, so -/// its placeholder `*` is not a version and is dropped. -fn version_of(parsed: &ParsedPlugin) -> Option { - parsed.plugin.version.clone().or_else(|| { - (parsed.canonical.version != ANY_VERSION).then(|| parsed.canonical.version.clone()) - }) +/// its placeholder `*` is not a version. +fn version_of(parsed: &ParsedPlugin) -> String { + parsed + .plugin + .version + .clone() + .or_else(|| { + (parsed.canonical.version != ANY_VERSION).then(|| parsed.canonical.version.clone()) + }) + .unwrap_or_else(|| UNVERSIONED.to_string()) } /// Write a compiled plugin into `root`, returning its directory. @@ -322,15 +345,30 @@ pub fn write_marketplace(root: &Path, name: &str, plugins: &[&CompiledPlugin]) - fs::write(&file, contents).with_context(|| format!("write {}", file.display())) } -/// Reap compiled directories under `root` that this sync did not write. Keyed on -/// the ownership marker, so a directory the user put there is left alone. -pub fn reap(root: &Path, written: &std::collections::BTreeSet) { +/// Reap marked directories under `root` that this sync did not write, descending +/// at most `depth` levels. Keyed on the ownership marker, so a directory the user +/// put there is left alone, and a marked directory is never descended into. +/// +/// The depth is what lets one function serve both a staging root (plugins sit +/// directly under it) and an agent's own tree, where Codex nests its copies as +/// `//`. +pub fn reap_to_depth(root: &Path, depth: usize, written: &std::collections::BTreeSet) { + if depth == 0 { + return; + } let Ok(entries) = fs::read_dir(root) else { return; }; for entry in entries.flatten() { let path = entry.path(); - if !path.is_dir() || written.contains(&path) || !crate::sync::has_symposium_marker(&path) { + if !path.is_dir() { + continue; + } + if !crate::sync::has_symposium_marker(&path) { + reap_to_depth(&path, depth - 1, written); + continue; + } + if written.contains(&path) { continue; } match fs::remove_dir_all(&path) { @@ -351,5 +389,14 @@ pub fn reap(root: &Path, written: &std::collections::BTreeSet) { } } +/// Reap the plugins directly under a staging root. +pub fn reap(root: &Path, written: &std::collections::BTreeSet) { + reap_to_depth(root, 1, written) +} + +/// How deep an agent nests its own plugin copies: Codex uses +/// `//`, the others one or two levels. +pub const AGENT_COPY_DEPTH: usize = 3; + #[cfg(test)] mod tests; diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index 42c0025b..ea631a7a 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -46,10 +46,32 @@ fn no_config() -> PluginsConfig { // ── scope ──────────────────────────────────────────────────────────── +fn used_globally(name: &str) -> PluginsConfig { + PluginsConfig { + used: vec![UseEntry::Global(name.to_string())], + ..Default::default() + } +} + #[test] -fn wildcard_registry_plugin_is_global() { +fn global_needs_both_a_global_use_entry_and_a_workspace_independent_gate() { let plugin = registry_plugin("pdf-tools", wildcard()); - assert_eq!(Scope::of(&plugin, &[], &no_config()), Scope::Global); + assert_eq!( + Scope::of(&plugin, &[], &no_config()), + Scope::Project, + "a workspace-independent gate is not on its own a request to install for the user" + ); + assert_eq!( + Scope::of(&plugin, &[], &used_globally("pdf-tools")), + Scope::Global + ); + + let dep_gated = registry_plugin("pdf-tools", on_serde()); + assert_eq!( + Scope::of(&dep_gated, &[], &used_globally("pdf-tools")), + Scope::Project, + "a global entry on a workspace-dependent plugin installs per project instead" + ); } #[test] @@ -62,11 +84,19 @@ fn a_concrete_dependency_gate_keeps_a_plugin_project_scoped() { fn workspace_members_and_crate_plugins_are_project_scoped() { let mut member = registry_plugin("house-style", wildcard()); member.workspace_member = true; - assert_eq!(Scope::of(&member, &[], &no_config()), Scope::Project); + assert_eq!( + Scope::of(&member, &[], &used_globally("house-style")), + Scope::Project, + "membership is what activates a workspace plugin, so it cannot be global" + ); let mut from_crate = registry_plugin("widget", wildcard()); from_crate.canonical = PackageId::new("cargo", "widget", "1.0.0"); - assert_eq!(Scope::of(&from_crate, &[], &no_config()), Scope::Project); + assert_eq!( + Scope::of(&from_crate, &[], &used_globally("widget")), + Scope::Project, + "a crate plugin is reached through this workspace's dependency graph" + ); } #[test] @@ -102,6 +132,7 @@ fn a_dormant_plugin_goes_global_only_when_used_globally() { #[test] fn a_dependency_gated_group_or_skill_keeps_the_plugin_project_scoped() { + let globally = used_globally("pdf-tools"); let mut grouped = registry_plugin("pdf-tools", wildcard()); grouped.plugin.skills = vec![SkillGroup { predicates: on_serde(), @@ -109,13 +140,13 @@ fn a_dependency_gated_group_or_skill_keeps_the_plugin_project_scoped() { source_label: None, workspace_member: false, }]; - assert_eq!(Scope::of(&grouped, &[], &no_config()), Scope::Project); + assert_eq!(Scope::of(&grouped, &[], &globally), Scope::Project); let plugin = registry_plugin("pdf-tools", wildcard()); let mut gated = skill_of(&plugin, "extract-tables", "/reg/pdf/skills/x/SKILL.md"); gated.skill.predicates = on_serde(); assert_eq!( - Scope::of(&plugin, &[&gated], &no_config()), + Scope::of(&plugin, &[&gated], &globally), Scope::Project, "a dep-gated skill makes the compiled content vary by workspace" ); @@ -135,6 +166,35 @@ fn shell_and_path_predicates_are_treated_as_workspace_dependent() { // ── compile ────────────────────────────────────────────────────────── +#[test] +fn one_bundle_referenced_by_two_plugins_is_emitted_once() { + let first = registry_plugin("pdf-tools", wildcard()); + let second = registry_plugin("csv-tools", wildcard()); + let shared = "/reg/shared/skills/extract/SKILL.md"; + let skills = vec![ + skill_of(&first, "extract", shared), + skill_of(&second, "extract", shared), + skill_of(&second, "split-rows", "/reg/csv/skills/split/SKILL.md"), + ]; + + let compiled = compile(&[first, second], &skills, &no_config()); + assert_eq!( + compiled[0].skills.len(), + 1, + "the first plugin to claim the bundle carries it" + ); + let second_dirs: Vec<&str> = compiled[1] + .skills + .iter() + .map(|s| s.dir_name.as_str()) + .collect(); + assert_eq!( + second_dirs, + vec!["split-rows"], + "the second plugin keeps its own skills but not a second copy of the shared one" + ); +} + #[test] fn skills_are_grouped_under_the_plugin_that_contributed_them() { let one = registry_plugin("pdf-tools", wildcard()); @@ -216,17 +276,17 @@ fn same_named_skills_from_different_paths_both_survive_with_suffixes() { fn the_version_comes_from_the_manifest_then_the_resolved_crate() { let mut declared = registry_plugin("pdf-tools", wildcard()); declared.plugin.version = Some("1.2.0".into()); - assert_eq!(version_of(&declared).as_deref(), Some("1.2.0")); + assert_eq!(version_of(&declared), "1.2.0"); let mut from_crate = registry_plugin("widget", wildcard()); from_crate.canonical = PackageId::new("cargo", "widget", "0.3.1"); - assert_eq!(version_of(&from_crate).as_deref(), Some("0.3.1")); + assert_eq!(version_of(&from_crate), "0.3.1"); let placeholder = registry_plugin("pdf-tools", wildcard()); assert_eq!( version_of(&placeholder), - None, - "the `*` placeholder is not a version" + UNVERSIONED, + "the `*` placeholder is not a version, and Codex keys its cache on one" ); } @@ -250,8 +310,9 @@ fn write_produces_a_manifest_beside_the_skills() { let skill_md = skill_on_disk(&source, "extract", "body"); let compiled = CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), dir_name: "pdf-tools".into(), - manifest: Manifest::new("pdf-tools".into(), Some("1.2.0".into()), None), + manifest: Manifest::new("pdf-tools".into(), "1.2.0".into(), None), scope: Scope::Global, skills: vec![CompiledSkill { dir_name: "extract".into(), @@ -302,8 +363,9 @@ fn rewriting_identical_content_leaves_the_directory_untouched() { let source = tmp.path().join("source"); let skill_md = skill_on_disk(&source, "extract", "body"); let compiled = CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), dir_name: "pdf-tools".into(), - manifest: Manifest::new("pdf-tools".into(), None, None), + manifest: Manifest::new("pdf-tools".into(), UNVERSIONED.into(), None), scope: Scope::Global, skills: vec![CompiledSkill { dir_name: "extract".into(), @@ -363,12 +425,13 @@ fn reap_removes_marked_directories_and_leaves_user_ones_alone() { } #[test] -fn a_plugin_with_no_version_still_gets_a_gemini_manifest() { +fn a_plugin_with_no_version_is_emitted_as_unversioned() { let tmp = tempfile::tempdir().expect("tmp"); let skill_md = skill_on_disk(&tmp.path().join("source"), "extract", "body"); let compiled = CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), dir_name: "pdf-tools".into(), - manifest: Manifest::new("pdf-tools".into(), None, None), + manifest: Manifest::new("pdf-tools".into(), UNVERSIONED.into(), None), scope: Scope::Global, skills: vec![CompiledSkill { dir_name: "extract".into(), @@ -383,23 +446,16 @@ fn a_plugin_with_no_version_still_gets_a_gemini_manifest() { ) .expect("write"); - let root: serde_json::Value = - serde_json::from_str(&fs::read_to_string(dest.join("plugin.json")).expect("read")) - .expect("json"); - assert!( - root.get("version").is_none(), - "the Agent Plugins manifest leaves an unknown version out" - ); - - let gemini: serde_json::Value = serde_json::from_str( - &fs::read_to_string(dest.join("gemini-extension.json")).expect("read"), - ) - .expect("json"); - assert_eq!( - gemini["version"], - manifest::GeminiExtension::FALLBACK_VERSION, - "gemini requires one, so it is filled in rather than dropping the plugin" - ); + for file in ["plugin.json", "gemini-extension.json"] { + let json: serde_json::Value = + serde_json::from_str(&fs::read_to_string(dest.join(file)).expect("read")) + .expect("json"); + assert_eq!( + json["version"], UNVERSIONED, + "{file} needs a version even when the plugin declares none, since Codex keys its \ + cache directory on one" + ); + } } #[test] @@ -409,14 +465,20 @@ fn the_marketplace_index_lists_each_plugin_and_is_removed_when_empty() { fs::create_dir_all(&root).expect("create root"); let one = CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), dir_name: "pdf-tools-ab12cd34".into(), - manifest: Manifest::new("pdf-tools".into(), None, Some("Tables".into())), + manifest: Manifest::new( + "pdf-tools".into(), + UNVERSIONED.into(), + Some("Tables".into()), + ), scope: Scope::Global, skills: Vec::new(), }; let two = CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), dir_name: "csv-tools".into(), - manifest: Manifest::new("csv-tools".into(), None, None), + manifest: Manifest::new("csv-tools".into(), UNVERSIONED.into(), None), scope: Scope::Global, skills: Vec::new(), }; diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 722645ee..e3b41658 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -5,6 +5,9 @@ //! that knowledge. mod mcp_server_registration; +mod plugin_install; + +pub use plugin_install::Registration; use std::fs; use std::path::{Path, PathBuf}; @@ -16,7 +19,7 @@ use crate::config::Symposium; use crate::output::{Output, display_path}; /// Supported AI agents. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum Agent { Claude, Codex, diff --git a/src/agents/plugin_install.rs b/src/agents/plugin_install.rs new file mode 100644 index 00000000..6f99ced7 --- /dev/null +++ b/src/agents/plugin_install.rs @@ -0,0 +1,329 @@ +//! Handing a compiled plugin directory to an agent. +//! +//! Two mechanisms exist, and which applies is a property of the agent, verified +//! by installing a directory and asking the running agent what it can see: +//! +//! - **Registered** — the agent is pointed at the staging root and reads it in +//! place. Only Claude Code does this, and it is also the only agent that can +//! express a project-scoped plugin. +//! - **Copied** — the agent loads only from its own directory, so the content is +//! copied there. Codex CLI, Copilot CLI, and Gemini CLI all require this; +//! deleting the copy makes the skill disappear. +//! +//! Symposium writes each agent's configuration itself rather than driving the +//! agent's own install command, which is what it already does for hooks and MCP +//! entries and the only option on the auto-sync path, where there is no terminal. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use serde_json::{Value, json}; + +use super::{Agent, load_json_or_empty, save_json}; +use crate::agent_plugin::{CompiledPlugin, Scope}; +use crate::sync::{Marking, sync_managed_dir}; + +/// Marketplace names symposium owns. Used to prune entries for plugins that no +/// longer apply without disturbing a marketplace the user added themselves. +const OWNED_MARKETPLACE_PREFIX: &str = "symposium"; + +/// One staging root, as an agent needs to be told about it. +pub struct Registration<'a> { + pub marketplace: &'a str, + pub root: &'a Path, + pub plugins: &'a [&'a CompiledPlugin], + pub scope: Scope, +} + +impl Registration<'_> { + /// `@`, the key every agent uses for enablement. + fn qualified(&self, plugin: &CompiledPlugin) -> String { + format!("{}@{}", plugin.manifest.name, self.marketplace) + } + + fn qualified_names(&self) -> BTreeSet { + self.plugins.iter().map(|p| self.qualified(p)).collect() + } +} + +impl Agent { + /// Can this agent be given a compiled plugin directory at `scope`? + /// + /// Only Claude Code can express a project-scoped plugin; the other three + /// store plugins per user with no way to bound them to one project, so a + /// project-scoped plugin reaches them through the per-skill path instead. + /// OpenCode extends through TypeScript modules and Goose through MCP + /// servers, so neither has a directory-shaped unit at all; Kiro's is not + /// verified yet. + pub fn accepts_plugin_scope(&self, scope: Scope) -> bool { + match self { + Agent::Claude => true, + Agent::Codex | Agent::Copilot | Agent::Gemini => scope == Scope::Global, + Agent::Goose | Agent::Kiro | Agent::OpenCode => false, + } + } + + /// Install the plugins in one staging root, returning the directories + /// written inside the agent's own tree (empty when the agent reads the + /// staging root in place). + pub fn install_plugins( + &self, + reg: &Registration, + home: &Path, + project_root: &Path, + debounce: Duration, + ) -> Result> { + match self { + Agent::Claude => install_claude(reg, home, project_root).map(|()| Vec::new()), + Agent::Codex => install_codex(reg, home, debounce), + Agent::Copilot => install_copilot(reg, home, debounce), + Agent::Gemini => install_gemini(reg, home, debounce), + Agent::Goose | Agent::Kiro | Agent::OpenCode => Ok(Vec::new()), + } + } + + /// Directories to scan for copies symposium no longer owns. Empty for an + /// agent that reads the staging root in place. + pub fn plugin_reap_roots(&self, home: &Path) -> Vec { + match self { + Agent::Codex => vec![home.join(".codex").join("plugins").join("cache")], + Agent::Copilot => vec![home.join(".copilot").join("installed-plugins")], + Agent::Gemini => vec![home.join(".gemini").join("extensions")], + Agent::Claude | Agent::Goose | Agent::Kiro | Agent::OpenCode => Vec::new(), + } + } +} + +fn directory_source(root: &Path) -> Value { + json!({ "source": "directory", "path": root.display().to_string() }) +} + +/// Is this an entry symposium wrote, i.e. does its marketplace belong to us? +fn ours(qualified: &str) -> bool { + qualified + .split_once('@') + .is_some_and(|(_, market)| market.starts_with(OWNED_MARKETPLACE_PREFIX)) +} + +/// Set the entries in `keep` and drop any other entry of ours, leaving entries +/// from marketplaces we do not own untouched. +fn reconcile_enabled(settings: &mut Value, keep: &BTreeSet) { + let map = settings + .as_object_mut() + .expect("settings is an object") + .entry("enabledPlugins") + .or_insert_with(|| json!({})); + let Some(map) = map.as_object_mut() else { + return; + }; + map.retain(|key, _| !ours(key) || keep.contains(key)); + for key in keep { + map.insert(key.clone(), Value::Bool(true)); + } +} + +/// Register `root` as a marketplace, or drop the registration when it holds no +/// plugins. Shared by Claude Code and Copilot, which use the same key. +fn reconcile_marketplace(settings: &mut Value, reg: &Registration) { + let map = settings + .as_object_mut() + .expect("settings is an object") + .entry("extraKnownMarketplaces") + .or_insert_with(|| json!({})); + let Some(map) = map.as_object_mut() else { + return; + }; + if reg.plugins.is_empty() { + map.remove(reg.marketplace); + } else { + map.insert( + reg.marketplace.to_string(), + json!({ "source": directory_source(reg.root) }), + ); + } +} + +/// Claude Code resolves a directory marketplace from its registered location, so +/// nothing is copied. Both the settings entry and `known_marketplaces.json` are +/// required: with the latter missing the plugin does not load, and Claude only +/// regenerates it from settings in time for the *next* session. +fn install_claude(reg: &Registration, home: &Path, project_root: &Path) -> Result<()> { + let user_settings = home.join(".claude").join("settings.json"); + let mut settings = load_json_or_empty(&user_settings)?; + reconcile_marketplace(&mut settings, reg); + + let known_path = home + .join(".claude") + .join("plugins") + .join("known_marketplaces.json"); + let mut known = load_json_or_empty(&known_path)?; + if let Some(map) = known.as_object_mut() { + if reg.plugins.is_empty() { + map.remove(reg.marketplace); + } else { + let last_updated = map + .get(reg.marketplace) + .and_then(|entry| entry.get("lastUpdated").cloned()) + .unwrap_or_else(|| json!(now_rfc3339())); + map.insert( + reg.marketplace.to_string(), + json!({ + "source": directory_source(reg.root), + "installLocation": reg.root.display().to_string(), + "lastUpdated": last_updated, + }), + ); + } + } + save_json(&known_path, &known)?; + + let keep = reg.qualified_names(); + match reg.scope { + Scope::Global => { + reconcile_enabled(&mut settings, &keep); + save_json(&user_settings, &settings) + } + Scope::Project => { + save_json(&user_settings, &settings)?; + let project_settings = project_root.join(".claude").join("settings.json"); + let mut project = load_json_or_empty(&project_settings)?; + reconcile_enabled(&mut project, &keep); + save_json(&project_settings, &project) + } + } +} + +/// Codex keys its plugin cache on the version, which is why the compiled +/// manifest always carries one: the copy lands where we said rather than at a +/// default Codex picks for a version-less plugin. +fn install_codex(reg: &Registration, home: &Path, debounce: Duration) -> Result> { + let config_path = home.join(".codex").join("config.toml"); + let mut doc = load_toml_or_empty(&config_path)?; + + reconcile_codex_marketplace(&mut doc, reg); + reconcile_codex_plugins(&mut doc, reg); + save_toml(&config_path, &doc)?; + + let cache = home + .join(".codex") + .join("plugins") + .join("cache") + .join(reg.marketplace); + copy_each(reg, home, debounce, |plugin| { + cache + .join(&plugin.manifest.name) + .join(&plugin.manifest.version) + }) +} + +fn reconcile_codex_marketplace(doc: &mut toml_edit::DocumentMut, reg: &Registration) { + let marketplaces = doc["marketplaces"].or_insert(toml_edit::table()); + let Some(table) = marketplaces.as_table_like_mut() else { + return; + }; + if reg.plugins.is_empty() { + table.remove(reg.marketplace); + return; + } + let last_updated = table + .get(reg.marketplace) + .and_then(|entry| entry.as_table_like()) + .and_then(|entry| entry.get("last_updated")) + .and_then(|v| v.as_str().map(str::to_string)) + .unwrap_or_else(now_rfc3339); + + let mut entry = toml_edit::Table::new(); + entry.insert("source_type", toml_edit::value("local")); + entry.insert("source", toml_edit::value(reg.root.display().to_string())); + entry.insert("last_updated", toml_edit::value(last_updated)); + table.insert(reg.marketplace, toml_edit::Item::Table(entry)); +} + +fn reconcile_codex_plugins(doc: &mut toml_edit::DocumentMut, reg: &Registration) { + let plugins = doc["plugins"].or_insert(toml_edit::table()); + let Some(table) = plugins.as_table_like_mut() else { + return; + }; + let keep = reg.qualified_names(); + let stale: Vec = table + .iter() + .map(|(key, _)| key.to_string()) + .filter(|key| ours(key) && !keep.contains(key)) + .collect(); + for key in stale { + table.remove(&key); + } + for key in &keep { + let mut entry = toml_edit::Table::new(); + entry.insert("enabled", toml_edit::value(true)); + table.insert(key, toml_edit::Item::Table(entry)); + } +} + +fn install_copilot(reg: &Registration, home: &Path, debounce: Duration) -> Result> { + let settings_path = home.join(".copilot").join("settings.json"); + let mut settings = load_json_or_empty(&settings_path)?; + reconcile_marketplace(&mut settings, reg); + reconcile_enabled(&mut settings, ®.qualified_names()); + save_json(&settings_path, &settings)?; + + let installed = home + .join(".copilot") + .join("installed-plugins") + .join(reg.marketplace); + copy_each(reg, home, debounce, |plugin| { + installed.join(&plugin.manifest.name) + }) +} + +/// Gemini discovers extensions by their presence in its directory, so the copy +/// is the whole installation. No configuration is written. +fn install_gemini(reg: &Registration, home: &Path, debounce: Duration) -> Result> { + let extensions = home.join(".gemini").join("extensions"); + copy_each(reg, home, debounce, |plugin| { + extensions.join(&plugin.dir_name) + }) +} + +fn copy_each( + reg: &Registration, + home: &Path, + debounce: Duration, + dest_of: impl Fn(&CompiledPlugin) -> PathBuf, +) -> Result> { + let mut written = Vec::new(); + for plugin in reg.plugins { + let source = reg.root.join(&plugin.dir_name); + let dest = dest_of(plugin); + sync_managed_dir(&source, &dest, home, debounce, Marking::MarkerOnly) + .with_context(|| format!("install {} into {}", plugin.dir_name, dest.display()))?; + written.push(dest); + } + Ok(written) +} + +fn now_rfc3339() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} + +fn load_toml_or_empty(path: &Path) -> Result { + match std::fs::read_to_string(path) { + Ok(text) => text + .parse() + .with_context(|| format!("parse {}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(toml_edit::DocumentMut::new()), + Err(e) => Err(e).with_context(|| format!("read {}", path.display())), + } +} + +fn save_toml(path: &Path, doc: &toml_edit::DocumentMut) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + } + std::fs::write(path, doc.to_string()).with_context(|| format!("write {}", path.display())) +} + +#[cfg(test)] +mod tests; diff --git a/src/agents/plugin_install/tests.rs b/src/agents/plugin_install/tests.rs new file mode 100644 index 00000000..43db636a --- /dev/null +++ b/src/agents/plugin_install/tests.rs @@ -0,0 +1,374 @@ +use super::*; +use crate::agent_plugin::manifest::Manifest; +use crate::pm::{ANY_VERSION, PackageId}; + +fn plugin(name: &str, dir: &str, version: &str) -> CompiledPlugin { + CompiledPlugin { + source_id: PackageId::new("test", "src", ANY_VERSION), + dir_name: dir.to_string(), + manifest: Manifest::new(name.to_string(), version.to_string(), None), + scope: Scope::Global, + skills: Vec::new(), + } +} + +fn registration<'a>( + root: &'a Path, + marketplace: &'a str, + plugins: &'a [&'a CompiledPlugin], + scope: Scope, +) -> Registration<'a> { + Registration { + marketplace, + root, + plugins, + scope, + } +} + +fn read(path: &Path) -> Value { + serde_json::from_str(&std::fs::read_to_string(path).expect("read")).expect("json") +} + +// ── which agent takes which scope ──────────────────────────────────── + +#[test] +fn only_claude_code_takes_a_project_scoped_plugin() { + assert!(Agent::Claude.accepts_plugin_scope(Scope::Project)); + assert!(Agent::Claude.accepts_plugin_scope(Scope::Global)); + + for agent in [Agent::Codex, Agent::Copilot, Agent::Gemini] { + assert!(agent.accepts_plugin_scope(Scope::Global), "{agent:?}"); + assert!( + !agent.accepts_plugin_scope(Scope::Project), + "{agent:?} stores plugins per user with no way to bound one to a project" + ); + } + for agent in [Agent::Goose, Agent::Kiro, Agent::OpenCode] { + assert!(!agent.accepts_plugin_scope(Scope::Global), "{agent:?}"); + assert!(!agent.accepts_plugin_scope(Scope::Project), "{agent:?}"); + } +} + +// ── claude ─────────────────────────────────────────────────────────── + +#[test] +fn claude_registers_the_root_and_copies_nothing() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = plugin("pdf-tools", "pdf-tools", "1.2.0"); + + let written = Agent::Claude + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + assert!( + written.is_empty(), + "Claude reads the staging root in place, so nothing is copied" + ); + + let settings = read(&home.join(".claude/settings.json")); + assert_eq!( + settings["extraKnownMarketplaces"]["symposium"]["source"]["source"], + "directory" + ); + assert_eq!( + settings["extraKnownMarketplaces"]["symposium"]["source"]["path"], + root.display().to_string() + ); + assert_eq!(settings["enabledPlugins"]["pdf-tools@symposium"], true); + + let known = read(&home.join(".claude/plugins/known_marketplaces.json")); + assert_eq!( + known["symposium"]["installLocation"], + root.display().to_string(), + "the record Claude needs in the same session, not just next time" + ); + assert!(known["symposium"]["lastUpdated"].is_string()); +} + +#[test] +fn claude_enables_a_project_plugin_in_the_project_settings() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let project = tmp.path().join("project"); + let root = project.join(".symposium/plugins"); + let one = plugin("house-style", "house-style", "0.0.0"); + + Agent::Claude + .install_plugins( + ®istration( + &root, + "symposium-reporter-ab12cd34", + &[&one], + Scope::Project, + ), + &home, + &project, + Duration::ZERO, + ) + .expect("install"); + + let user = read(&home.join(".claude/settings.json")); + assert!( + user["extraKnownMarketplaces"]["symposium-reporter-ab12cd34"].is_object(), + "registration is user-level even for a project-scoped plugin" + ); + assert!( + user.get("enabledPlugins") + .is_none_or(|v| v.get("house-style@symposium-reporter-ab12cd34").is_none()), + "but enablement must not leak into other projects" + ); + + let scoped = read(&project.join(".claude/settings.json")); + assert_eq!( + scoped["enabledPlugins"]["house-style@symposium-reporter-ab12cd34"], + true + ); +} + +#[test] +fn a_plugin_that_stops_applying_loses_its_entries_and_the_users_are_left_alone() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let settings_path = home.join(".claude/settings.json"); + std::fs::create_dir_all(settings_path.parent().unwrap()).expect("create"); + std::fs::write( + &settings_path, + r#"{ + "enabledPlugins": { "caveman@caveman": true }, + "extraKnownMarketplaces": { "caveman": { "source": { "source": "github" } } } + }"#, + ) + .expect("seed"); + + let one = plugin("pdf-tools", "pdf-tools", "1.2.0"); + let listed = [&one]; + Agent::Claude + .install_plugins( + ®istration(&root, "symposium", &listed, Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + assert_eq!( + read(&settings_path)["enabledPlugins"]["pdf-tools@symposium"], + true + ); + + Agent::Claude + .install_plugins( + ®istration(&root, "symposium", &[], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("uninstall"); + + let settings = read(&settings_path); + assert!( + settings["enabledPlugins"] + .get("pdf-tools@symposium") + .is_none(), + "our entry goes when the plugin stops applying" + ); + assert_eq!( + settings["enabledPlugins"]["caveman@caveman"], true, + "a plugin from a marketplace we do not own is never touched" + ); + assert!( + settings["extraKnownMarketplaces"] + .get("symposium") + .is_none() + ); + assert!(settings["extraKnownMarketplaces"]["caveman"].is_object()); + assert!( + read(&home.join(".claude/plugins/known_marketplaces.json")) + .get("symposium") + .is_none() + ); +} + +// ── codex ──────────────────────────────────────────────────────────── + +fn staged_plugin(root: &Path, dir: &str) -> CompiledPlugin { + let skill = root.join(dir).join("skills").join("probe"); + std::fs::create_dir_all(&skill).expect("create"); + std::fs::write(skill.join("SKILL.md"), "---\nname: probe\n---\nbody\n").expect("write"); + std::fs::write(root.join(dir).join("plugin.json"), "{}").expect("write"); + plugin(dir, dir, "0.4.2") +} + +#[test] +fn codex_gets_config_entries_and_a_version_keyed_copy() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools"); + + let config_path = home.join(".codex/config.toml"); + std::fs::create_dir_all(config_path.parent().unwrap()).expect("create"); + std::fs::write( + &config_path, + "[projects.\"/work/reporter\"]\ntrust_level = \"trusted\"\n", + ) + .expect("seed"); + + let written = Agent::Codex + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + + let config = std::fs::read_to_string(&config_path).expect("read"); + assert!( + config.contains("[projects.\"/work/reporter\"]"), + "unrelated config survives: {config}" + ); + assert!(config.contains("[marketplaces.symposium]"), "{config}"); + assert!(config.contains("source_type = \"local\""), "{config}"); + assert!( + config.contains("[plugins.\"pdf-tools@symposium\"]"), + "{config}" + ); + assert!(config.contains("enabled = true"), "{config}"); + + let expected = home.join(".codex/plugins/cache/symposium/pdf-tools/0.4.2"); + assert_eq!(written, vec![expected.clone()]); + assert!( + expected.join("skills/probe/SKILL.md").is_file(), + "Codex loads only from its own cache, so the content is copied" + ); +} + +#[test] +fn codex_drops_our_entries_when_a_plugin_stops_applying() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools"); + + Agent::Codex + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + Agent::Codex + .install_plugins( + ®istration(&root, "symposium", &[], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("uninstall"); + + let config = std::fs::read_to_string(home.join(".codex/config.toml")).expect("read"); + assert!(!config.contains("marketplaces.symposium"), "{config}"); + assert!(!config.contains("pdf-tools@symposium"), "{config}"); +} + +// ── copilot and gemini ─────────────────────────────────────────────── + +#[test] +fn copilot_gets_settings_entries_and_a_copy() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools"); + + let written = Agent::Copilot + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + + let settings = read(&home.join(".copilot/settings.json")); + assert_eq!( + settings["extraKnownMarketplaces"]["symposium"]["source"]["path"], + root.display().to_string() + ); + assert_eq!(settings["enabledPlugins"]["pdf-tools@symposium"], true); + + let expected = home.join(".copilot/installed-plugins/symposium/pdf-tools"); + assert_eq!(written, vec![expected.clone()]); + assert!(expected.join("skills/probe/SKILL.md").is_file()); +} + +#[test] +fn gemini_is_a_copy_with_no_configuration_at_all() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools-ab12cd34"); + + let written = Agent::Gemini + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + + let expected = home.join(".gemini/extensions/pdf-tools-ab12cd34"); + assert_eq!( + written, + vec![expected.clone()], + "the extension directory is named for the compiled directory, which is what gemini lists" + ); + assert!(expected.join("skills/probe/SKILL.md").is_file()); + assert!( + !home.join(".gemini/settings.json").exists(), + "presence in the folder is the whole installation" + ); +} + +#[test] +fn every_copy_carries_the_marker_so_it_can_be_reaped() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools"); + + for agent in [Agent::Codex, Agent::Copilot, Agent::Gemini] { + let written = agent + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + for dir in &written { + assert!( + dir.join(crate::sync::MARKER_FILE).is_file(), + "{agent:?} copy at {} has no marker", + dir.display() + ); + } + assert!( + !agent.plugin_reap_roots(&home).is_empty(), + "{agent:?} copies, so it needs a reap root" + ); + } + assert!( + Agent::Claude.plugin_reap_roots(&home).is_empty(), + "Claude copies nothing, so there is nothing of ours to reap" + ); +} diff --git a/src/report.rs b/src/report.rs index 8e1ddd1d..254a0371 100644 --- a/src/report.rs +++ b/src/report.rs @@ -74,6 +74,14 @@ pub enum ReportEvent { dest: String, }, + /// A compiled plugin directory was handed to an agent. + PluginDelivered { + plugin: String, + agent: String, + scope: String, + dest: String, + }, + /// A skill was installed to an agent's directory. SkillInstalled { skill: String, @@ -245,6 +253,14 @@ impl ReportEvent { } => { format!("📦 compiled {plugin} ({scope}, {skills} skills) → {dest}") } + Self::PluginDelivered { + plugin, + agent, + scope, + dest, + } => { + format!("🔌 delivered {plugin} ({scope}) to {agent} → {dest}") + } Self::SkillInstalled { skill, plugin, diff --git a/src/sync.rs b/src/sync.rs index 457f8182..3b2974e2 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -295,6 +295,7 @@ struct PendingSkill<'a> { name: String, origin_hash: String, plugin: String, + plugin_id: crate::pm::PackageId, source: &'a Path, } @@ -308,7 +309,14 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let project_root = loaded.root.clone(); let workspace: Vec<_> = loaded.crates.clone(); let loaded = loaded.clone(); - let debounce = Duration::from_secs(sym.config.sync_debounce_secs); + // The debounce keeps the per-event hook path cheap. A caller that asked for + // an update — an explicit `sync`, or the `SessionStart` catch-up pass — wants + // the comparison done, so a directory changed since the last sync is + // restored rather than skipped for the debounce window. + let debounce = match update { + UpdateLevel::None => Duration::from_secs(sym.config.sync_debounce_secs), + _ => Duration::ZERO, + }; tracing::debug!(root = %project_root.display(), "resolved workspace root"); // Load plugin registry (registry sources + workspace plugins) @@ -374,6 +382,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve name, origin_hash: entry.origin_hash.clone(), plugin: entry.plugin.clone(), + plugin_id: entry.plugin_id.clone(), source: &entry.skill.path, }); } @@ -382,7 +391,24 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // Compile each applicable plugin into an agent plugin directory. Nothing // reads these yet — the per-agent delivery lands with the emitters — but the // directories are owned and reaped from here on. - let compiled = crate::agent_plugin::compile(&active, &applicable, &sym.config.plugins); + // Only compile for a scope some configured agent can actually take. With + // none, the directory would sit unread and the skills still arrive through + // the per-skill path. + let configured: Vec = sym + .config + .agents + .iter() + .filter_map(|a| Agent::from_config_name(&a.name).ok()) + .collect(); + let compiled: Vec = + crate::agent_plugin::compile(&active, &applicable, &sym.config.plugins) + .into_iter() + .filter(|plugin| { + configured + .iter() + .any(|agent| agent.accepts_plugin_scope(plugin.scope)) + }) + .collect(); let owned_dir = project_root.join(crate::agent_plugin::PROJECT_OWNED_DIR); let project_staging = owned_dir.join(crate::agent_plugin::PROJECT_STAGING_SUBDIR); let global_staging = sym @@ -502,9 +528,66 @@ 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(); + // Plugin copies each agent now owns, so stale ones can be reaped below. + let mut agent_copies: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for agent_name in &agent_names { let agent = Agent::from_config_name(agent_name)?; + // Hand over the compiled directories this agent can take, and note + // which plugins that covers so their skills are not also installed + // individually. + let mut delivered: BTreeSet = BTreeSet::new(); + for (scope, root) in [ + (Scope::Project, &project_staging), + (Scope::Global, &global_staging), + ] { + let in_scope: Vec<&crate::agent_plugin::CompiledPlugin> = compiled + .iter() + .filter(|p| p.scope == scope && agent.accepts_plugin_scope(scope)) + .collect(); + if in_scope.is_empty() && !root.exists() { + continue; + } + let marketplace = crate::agent_plugin::marketplace_name(scope, &project_root); + let registration = crate::agents::Registration { + marketplace: &marketplace, + root, + plugins: &in_scope, + scope, + }; + match agent.install_plugins(®istration, sym.home_dir(), &project_root, debounce) { + Ok(copies) => { + agent_copies + .entry(agent) + .or_default() + .extend(copies.iter().cloned()); + for plugin in &in_scope { + delivered.insert(plugin.source_id.clone()); + tracing::info!( + report = %crate::report::ReportEvent::PluginDelivered { + plugin: plugin.dir_name.clone(), + agent: agent_name.clone(), + scope: scope.as_str().to_string(), + dest: display_path( + copies + .iter() + .find(|c| c.ends_with(&plugin.dir_name)) + .unwrap_or(root) + ), + }, + ); + } + } + Err(e) => tracing::info!( + report = %crate::report::ReportEvent::Warning { + message: format!("failed to install plugins for {agent_name}: {e}"), + }, + ), + } + } + let hook_root = match sym.config.hook_scope { crate::config::HookScope::Global => sym.home_dir().to_path_buf(), crate::config::HookScope::Project => project_root.clone(), @@ -523,8 +606,16 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve name: skill_name, origin_hash, plugin, + plugin_id, source, } = pending; + + // Already delivered to this agent as a plugin directory, which is + // the whole point of compiling one. Agents that cannot take the + // plugin still get the skill the old way. + if delivered.contains(plugin_id) { + continue; + } // `source` is the path to the SKILL.md file; the skill directory // is its parent. let source_dir = match source.parent() { @@ -652,6 +743,19 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } + // Reap plugin copies we no longer own, across every known agent so an agent + // dropped from the config is cleaned up too. + for &agent in Agent::all() { + let written = agent_copies.get(&agent).cloned().unwrap_or_default(); + for root in agent.plugin_reap_roots(sym.home_dir()) { + crate::agent_plugin::reap_to_depth( + &root, + crate::agent_plugin::AGENT_COPY_DEPTH, + &written, + ); + } + } + // Unregister hooks/MCP for agents no longer configured for &agent in Agent::all() { if !agent_names.contains(&agent.config_name().to_string()) { diff --git a/tests/custom_predicates.rs b/tests/custom_predicates.rs index 87217a2f..1ea3ad5d 100644 --- a/tests/custom_predicates.rs +++ b/tests/custom_predicates.rs @@ -14,6 +14,57 @@ fn write_script(path: &Path, content: &str) { } } +/// Did `skill` reach Claude Code for this workspace? +/// +/// Claude takes a compiled plugin directory, so a skill it can see is the one +/// inside `.symposium/plugins//skills/`. The older per-skill location is +/// still checked, because an agent that cannot take a plugin directory keeps +/// receiving skills that way and these tests are about predicates, not delivery. +fn skill_reached_claude(workspace_root: &Path, skill: &str) -> bool { + let compiled = workspace_root.join(".symposium").join("plugins"); + let in_a_plugin = std::fs::read_dir(&compiled).is_ok_and(|entries| { + entries.flatten().any(|entry| { + entry + .path() + .join("skills") + .join(skill) + .join("SKILL.md") + .is_file() + }) + }); + in_a_plugin + || workspace_root + .join(".claude") + .join("skills") + .join(skill) + .join("SKILL.md") + .is_file() +} + +/// Every place a skill could have landed, for assertion messages and for the +/// "nothing was installed" checks. +fn delivered_skills(workspace_root: &Path) -> Vec { + let mut found = Vec::new(); + let compiled = workspace_root.join(".symposium").join("plugins"); + if let Ok(entries) = std::fs::read_dir(&compiled) { + for entry in entries.flatten() { + if let Ok(skills) = std::fs::read_dir(entry.path().join("skills")) { + found.extend(skills.flatten().map(|s| s.path())); + } + } + } + if let Ok(entries) = std::fs::read_dir(workspace_root.join(".claude").join("skills")) { + found.extend( + entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.join("SKILL.md").is_file()), + ); + } + found.sort(); + found +} + /// `sync` installs a skill when the custom predicate passes (exit 0). #[tokio::test] async fn sync_custom_predicate_installs_skill() { @@ -27,20 +78,11 @@ async fn sync_custom_predicate_installs_skill() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); - let skill_dir = skills_dir.join("bp-skill"); + let root = ctx.workspace_root.as_ref().unwrap(); assert!( - skill_dir.join("SKILL.md").exists(), - "skill should be installed when predicate passes; skills_dir={}, contents={:?}", - skills_dir.display(), - std::fs::read_dir(&skills_dir) - .ok() - .map(|d| d.flatten().map(|e| e.path()).collect::>()), + skill_reached_claude(root, "bp-skill"), + "skill should be delivered when the predicate passes; found {:?}", + delivered_skills(root), ); Ok(()) }, @@ -62,25 +104,12 @@ async fn sync_custom_predicate_fails_skips_skill() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); - let entries: Vec<_> = std::fs::read_dir(&skills_dir) - .ok() - .map(|d| { - d.flatten() - .filter(|e| e.path().is_dir()) - .filter(|e| e.path().join("SKILL.md").exists()) - .collect() - }) - .unwrap_or_default(); + let root = ctx.workspace_root.as_ref().unwrap(); + let entries = delivered_skills(root); assert!( entries.is_empty(), "no skills should be installed when predicate fails; got: {:?}", - entries.iter().map(|e| e.path()).collect::>(), + entries, ); Ok(()) }, @@ -107,15 +136,11 @@ async fn sync_custom_predicate_receives_correct_argument() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); + let root = ctx.workspace_root.as_ref().unwrap(); assert!( - skills_dir.join("bp-skill").join("SKILL.md").exists(), - "skill should be installed when argument matches 'cli'" + skill_reached_claude(root, "bp-skill"), + "skill should be delivered when the argument matches 'cli'; found {:?}", + delivered_skills(root), ); Ok(()) }, @@ -141,21 +166,8 @@ async fn sync_custom_predicate_wrong_argument_fails() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); - let entries: Vec<_> = std::fs::read_dir(&skills_dir) - .ok() - .map(|d| { - d.flatten() - .filter(|e| e.path().is_dir()) - .filter(|e| e.path().join("SKILL.md").exists()) - .collect() - }) - .unwrap_or_default(); + let root = ctx.workspace_root.as_ref().unwrap(); + let entries = delivered_skills(root); assert!( entries.is_empty(), "skill should NOT be installed when argument doesn't match" @@ -181,14 +193,10 @@ async fn sync_custom_predicate_cross_plugin() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); + let root = ctx.workspace_root.as_ref().unwrap(); + let skills_dir = root.join(".claude").join("skills"); assert!( - skills_dir.join("consumer-skill").join("SKILL.md").exists(), + skill_reached_claude(root, "consumer-skill"), "consumer plugin skill should install when provider's predicate passes; \ skills_dir={}, contents={:?}", skills_dir.display(), @@ -217,21 +225,8 @@ async fn sync_custom_predicate_cross_plugin_fails() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let skills_dir = ctx - .workspace_root - .as_ref() - .unwrap() - .join(".claude") - .join("skills"); - let entries: Vec<_> = std::fs::read_dir(&skills_dir) - .ok() - .map(|d| { - d.flatten() - .filter(|e| e.path().is_dir()) - .filter(|e| e.path().join("SKILL.md").exists()) - .collect() - }) - .unwrap_or_default(); + let root = ctx.workspace_root.as_ref().unwrap(); + let entries = delivered_skills(root); assert!( entries.is_empty(), "consumer skill should NOT install when provider's predicate fails" diff --git a/tests/enablement.rs b/tests/enablement.rs index 45979d1d..a4dbcf47 100644 --- a/tests/enablement.rs +++ b/tests/enablement.rs @@ -7,37 +7,52 @@ 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(); - }; +/// Everywhere a skill named `` (or `-`) was +/// delivered for this workspace. +/// +/// These tests are about enablement, not about which mechanism carries a skill, +/// so both are searched: a compiled plugin directory, which is what Claude Code +/// now receives, and a standalone skill directory, which is what an agent with no +/// plugin unit still gets. +fn find_delivered_skills(workspace_root: &Path, skill_name: &str) -> Vec { + let mut parents = vec![ + workspace_root.join(".claude").join("skills"), + workspace_root.join(".agents").join("skills"), + ]; + if let Ok(compiled) = std::fs::read_dir(workspace_root.join(".symposium").join("plugins")) { + parents.extend(compiled.flatten().map(|e| e.path().join("skills"))); + } + 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 { + for parent in parents { + let Ok(entries) = std::fs::read_dir(&parent) 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); + 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 } -/// The unique installed skill directory with this name. Panics on 0 or >1. -fn find_installed_skill(parent: &Path, skill_name: &str) -> PathBuf { - let mut hits = find_installed_skills(parent, skill_name); +/// The unique delivered skill directory with this name. Panics on 0 or >1. +fn find_delivered_skill(workspace_root: &Path, skill_name: &str) -> PathBuf { + let mut hits = find_delivered_skills(workspace_root, skill_name); assert_eq!( hits.len(), 1, - "expected exactly one installed skill named `{skill_name}` under {}, found {hits:?}", - parent.display(), + "expected exactly one delivered skill named `{skill_name}` under {}, found {hits:?}", + workspace_root.display(), ); hits.pop().unwrap() } @@ -74,14 +89,14 @@ async fn use_records_workspace_entry_and_installs() { &["auto-enable0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; - let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + let root = ctx.workspace_root.clone().unwrap(); // Unconsented, the dependency's skills stay out. ctx.symposium(&["sync"]).await?; - assert!(find_installed_skills(&skills_dir, "a-guidance").is_empty()); + assert!(find_delivered_skills(&root, "a-guidance").is_empty()); ctx.symposium(&["use", "crate-a"]).await?; - find_installed_skill(&skills_dir, "a-guidance"); + find_delivered_skill(&root, "a-guidance"); let config = read_config(&ctx); assert!(config.contains("crate-a"), "entry recorded: {config}"); @@ -176,18 +191,18 @@ async fn use_wakes_and_remove_sleeps_a_dormant_plugin() { &["dormant-plugin0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; - let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + let root = ctx.workspace_root.clone().unwrap(); ctx.symposium(&["sync"]).await?; - assert!(find_installed_skills(&skills_dir, "gateless-guidance").is_empty()); + assert!(find_delivered_skills(&root, "gateless-guidance").is_empty()); ctx.symposium(&["use", "gateless-plugin"]).await?; - find_installed_skill(&skills_dir, "gateless-guidance"); + find_delivered_skill(&root, "gateless-guidance"); assert!(read_config(&ctx).contains("gateless-plugin")); ctx.symposium(&["use", "--remove", "gateless-plugin"]) .await?; - assert!(find_installed_skills(&skills_dir, "gateless-guidance").is_empty()); + assert!(find_delivered_skills(&root, "gateless-guidance").is_empty()); Ok(()) }, ) @@ -204,10 +219,10 @@ async fn use_remove_reaps_and_then_errors() { &["auto-enable0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; - let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + let root = ctx.workspace_root.clone().unwrap(); ctx.symposium(&["use", "crate-a"]).await?; - find_installed_skill(&skills_dir, "a-guidance"); + find_delivered_skill(&root, "a-guidance"); ctx.symposium(&["use", "--remove", "crate-a"]).await?; assert!( @@ -216,7 +231,7 @@ async fn use_remove_reaps_and_then_errors() { read_config(&ctx) ); assert!( - find_installed_skills(&skills_dir, "a-guidance").is_empty(), + find_delivered_skills(&root, "a-guidance").is_empty(), "skills reaped after removal" ); @@ -498,10 +513,7 @@ async fn consent_prompt_never_fires_non_interactively() { symposium::discovery::pending_candidates(&ctx.sym, &deps).await, vec!["crate-a".to_string()] ); - assert!( - find_installed_skills(&workspace_root.join(".claude/skills"), "a-guidance") - .is_empty() - ); + assert!(find_delivered_skills(&workspace_root, "a-guidance").is_empty()); Ok(()) }, ) @@ -524,7 +536,7 @@ async fn apply_consent_records_both_answers() { assert!(read_config(&ctx).contains("auto-enable")); ctx.symposium(&["sync"]).await?; - find_installed_skill(&workspace_root.join(".claude/skills"), "a-guidance"); + find_delivered_skill(&workspace_root, "a-guidance"); let deps = ctx.sym.workspace_deps(&workspace_root); assert!( 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 00000000..7c79c453 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/config.toml @@ -0,0 +1,8 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true + +[plugins] +use = ["global-tools"] diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/SYMPOSIUM.toml b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/SYMPOSIUM.toml new file mode 100644 index 00000000..781fbfa5 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/SYMPOSIUM.toml @@ -0,0 +1,5 @@ +name = "global-tools" +depends-on = ["*"] + +[[skills]] +source.path = "." diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/global-guidance/SKILL.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/global-guidance/SKILL.md new file mode 100644 index 00000000..7996a85b --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/global-tools/global-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: global-guidance +description: Guidance that applies everywhere +--- +Body. diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/SYMPOSIUM.toml b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/SYMPOSIUM.toml new file mode 100644 index 00000000..9857b1b7 --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/SYMPOSIUM.toml @@ -0,0 +1,5 @@ +name = "project-tools" +depends-on = ["serde"] + +[[skills]] +source.path = "." diff --git a/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/project-guidance/SKILL.md b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/project-guidance/SKILL.md new file mode 100644 index 00000000..a1d7f99a --- /dev/null +++ b/tests/fixtures/agent-plugin-scopes0/dot-symposium/plugins/project-tools/project-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: project-guidance +description: Guidance gated on a workspace dependency +--- +Body. diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 7fee66ed..9b90bc99 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -12,33 +12,91 @@ 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. +/// Locate every delivered skill directory named `` or +/// `-`. Sync embeds an origin-derived hash in the directory +/// name to keep distinct origins from colliding. +/// +/// `parent` is an agent's skills directory, e.g. `/.claude/skills`. Both it +/// and the compiled plugin directories of the same workspace are searched: Claude +/// Code now receives a plugin directory, while an agent with no plugin unit still +/// receives standalone skills, and which mechanism carried a skill is not what +/// most of these tests are about. fn find_installed_skills(parent: &Path, skill_name: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(parent) else { - return Vec::new(); - }; + let mut dirs = vec![parent.to_path_buf()]; + // `parent` may itself be a staging root, whose children are plugins. + if let Ok(entries) = std::fs::read_dir(parent) { + dirs.extend(entries.flatten().map(|e| e.path().join("skills"))); + } + // Or an agent's skills dir, in which case the project's staging root is a + // sibling two levels up. + if let Some(root) = parent.parent().and_then(Path::parent) + && let Ok(compiled) = std::fs::read_dir(root.join(".symposium").join("plugins")) + { + dirs.extend(compiled.flatten().map(|e| e.path().join("skills"))); + } + 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 { + for dir in dirs { + let Ok(entries) = std::fs::read_dir(&dir) 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); + 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 } +/// Is this delivered skill directory one symposium installed? +/// +/// The ownership marker sits on the directory symposium created: the skill +/// directory itself on the per-skill path, and the plugin directory when the +/// skill arrived inside a compiled plugin. +fn is_symposium_managed(skill_dir: &Path) -> bool { + if skill_dir.join(".symposium").is_file() { + return true; + } + skill_dir + .parent() + .and_then(Path::parent) + .is_some_and(|plugin| plugin.join(".symposium").is_file()) +} + +/// Is this delivered skill directory kept out of version control? +/// +/// A per-skill install carries its own wildcard `.gitignore`, because it sits in +/// agent-owned territory alongside user content. A compiled plugin instead lives +/// under `.symposium/`, which symposium owns outright and covers with one +/// `.gitignore` at its root. +fn is_gitignored(skill_dir: &Path) -> bool { + let own = skill_dir.join(".gitignore"); + if std::fs::read_to_string(&own).is_ok_and(|c| c.trim() == "*") { + return true; + } + let mut dir = skill_dir; + while let Some(parent) = dir.parent() { + if parent.file_name().is_some_and(|n| n == ".symposium") { + return std::fs::read_to_string(parent.join(".gitignore")) + .is_ok_and(|c| c.trim() == "*"); + } + dir = parent; + } + false +} + /// Locate the unique installed skill directory by name. Panics if 0 or /// >1 directories match. Use `find_installed_skills` when the test cares /// about how many were installed. @@ -151,7 +209,7 @@ async fn sync_installs_workspace_plugin_skills() { let skills_dir = workspace_root.join(".claude/skills"); let skill_dir = find_installed_skill(&skills_dir, "ws-hello"); assert!( - skill_dir.join(".symposium").exists(), + is_symposium_managed(&skill_dir), "workspace skill should install as symposium-managed" ); Ok(()) @@ -188,20 +246,15 @@ async fn sync_installs_skills() { // Each installed skill directory carries a `.symposium` marker so // future syncs (and other tools) can identify it as symposium-managed. assert!( - skill_dir.join(".symposium").exists(), - "skill dir should contain .symposium marker" + is_symposium_managed(&skill_dir), + "delivered skill should be symposium-managed" ); - // Each skill directory gets a wildcard gitignore so the marker, - // SKILL.md, and gitignore itself stay out of version control. - let gi = skill_dir.join(".gitignore"); - assert!(gi.exists(), "missing .gitignore at {}", gi.display()); - let contents = std::fs::read_to_string(&gi).unwrap(); - assert_eq!( - contents.trim(), - "*", - "unexpected .gitignore content at {}", - gi.display() + // Whatever carried the skill keeps it out of version control. + assert!( + is_gitignored(&skill_dir), + "{} is not covered by a wildcard .gitignore", + skill_dir.display() ); // Parent directories (e.g. `.claude/skills/`) should NOT get a // gitignore — they are shared namespace directories. @@ -549,13 +602,13 @@ async fn sync_installs_skill_from_crate_path() { let x_dir = find_installed_skill(&skills_dir, "x-guidance"); let content = std::fs::read_to_string(x_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-x like this")); - assert!(x_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&x_dir)); // crate-z: custom path via [package.metadata.symposium] let z_dir = find_installed_skill(&skills_dir, "z-guidance"); let content = std::fs::read_to_string(z_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-z like this")); - assert!(z_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&z_dir)); Ok(()) }, ) @@ -592,7 +645,7 @@ async fn auto_enable_admits_a_dependencys_embedded_skills() { let a_dir = find_installed_skill(&skills_dir, "a-guidance"); let content = std::fs::read_to_string(a_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-a like this")); - assert!(a_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&a_dir)); Ok(()) }, ) @@ -632,8 +685,15 @@ 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()); + // A *global* `use` entry asks for the plugin everywhere, so it + // compiles into the user's staging root rather than this project. + let dir = + find_installed_skill(&ctx.sym.config_dir().join("installed"), "gateless-guidance"); + assert!(is_symposium_managed(&dir)); + assert!( + find_installed_skills(&skills_dir, "gateless-guidance").is_empty(), + "and not into the project as well" + ); Ok(()) }, ) @@ -666,7 +726,7 @@ async fn sync_installs_skill_via_chained_plugin() { let w_dir = find_installed_skill(&skills_dir, "w-guidance"); let content = std::fs::read_to_string(w_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-w like this")); - assert!(w_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&w_dir)); Ok(()) }, ) @@ -702,7 +762,7 @@ async fn sync_installs_skill_via_crate_manifest() { let m_dir = find_installed_skill(&skills_dir, "m-guidance"); let content = std::fs::read_to_string(m_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-m via the manifest")); - assert!(m_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&m_dir)); Ok(()) }, ) @@ -891,10 +951,7 @@ async fn sync_installations_are_gitignored() { skill_dir.join("SKILL.md").exists(), "skill should be installed on disk" ); - assert!( - skill_dir.join(".symposium").exists(), - "marker should be on disk" - ); + assert!(is_symposium_managed(&skill_dir), "marker should be on disk"); // Use `-uall` so untracked dirs expand to their leaf paths — // gives deterministic output regardless of git's collapsing rules. @@ -1025,17 +1082,14 @@ async fn sync_keeps_distinct_plugin_origins_with_same_skill_name() { "two plugins each shipping a `code-review` skill must both install; got {installed:?}" ); - // Each install dir has the expected disambiguating suffix. - let names: Vec = installed + // The plugin directory is the namespace, so each skill keeps its + // plain name and the two are told apart by their owning plugin. + let owners: Vec = installed .iter() - .filter_map(|p| p.file_name().and_then(|n| n.to_str()).map(str::to_string)) + .filter_map(|p| p.parent()?.parent()?.file_name()?.to_str().map(str::to_string)) .collect(); - for n in &names { - assert!( - n.starts_with("code-review-"), - "expected hashed suffix on `{n}`" - ); - } + assert_eq!(owners.len(), 2, "each install sits under its own plugin"); + assert_ne!(owners[0], owners[1]); // And the bodies came from different plugins. let bodies: Vec = installed @@ -1061,7 +1115,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", "opencode"]).await?; // Park plugin-b *outside* any plugin source dir so it isn't // discovered. (`tempdir/` sits next to the user config root, @@ -1074,7 +1128,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. @@ -1189,12 +1243,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", "opencode"]).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(".agents/skills/serde-guidance"); std::fs::create_dir_all(&user_dir)?; std::fs::write(user_dir.join("SKILL.md"), "user content")?; @@ -1215,10 +1269,10 @@ 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(".agents/skills"), "serde-guidance"); let hashed: Vec<_> = installed .iter() - .filter(|p| p.join(".symposium").exists()) + .filter(|p| is_symposium_managed(p)) .collect(); assert_eq!( hashed.len(), @@ -1338,15 +1392,17 @@ 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 = find_installed_skill( + &workspace_root.join(".claude/skills"), + "user-authored-skill", + ); assert!(dest.join("SKILL.md").exists(), "SKILL.md propagated"); assert!( dest.join("REFERENCE.md").exists(), "companion files propagated" ); - assert!(dest.join(".symposium").exists(), "marker present"); - let gi = std::fs::read_to_string(dest.join(".gitignore"))?; - assert_eq!(gi.trim(), "*", "destination gitignore is wildcard"); + assert!(is_symposium_managed(&dest), "marker present"); + assert!(is_gitignored(&dest), "destination is gitignored"); Ok(()) }, ) @@ -1433,9 +1489,11 @@ async fn agents_syncing_cleans_up_removed_user_skill() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let propagated = workspace_root.join(".claude/skills/user-authored-skill"); - assert!(propagated.exists(), "first sync should propagate"); - assert!(propagated.join(".symposium").exists()); + let propagated = find_installed_skill( + &workspace_root.join(".claude/skills"), + "user-authored-skill", + ); + assert!(is_symposium_managed(&propagated)); // User removes the source. std::fs::remove_dir_all(workspace_root.join(".agents/skills/user-authored-skill"))?; @@ -1465,14 +1523,17 @@ async fn agents_syncing_disabling_removes_previously_propagated_skills() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.clone().unwrap(); - let propagated = workspace_root.join(".claude/skills/user-authored-skill"); - assert!(propagated.exists(), "first sync should propagate"); + let skills_dir = workspace_root.join(".claude/skills"); + assert!( + !find_installed_skills(&skills_dir, "user-authored-skill").is_empty(), + "first sync should propagate" + ); ctx.sym.config.agents_syncing = false; ctx.symposium(&["sync"]).await?; assert!( - !propagated.exists(), + find_installed_skills(&skills_dir, "user-authored-skill").is_empty(), "disabling agents-syncing should clean up previously propagated copies" ); // Source must remain untouched. @@ -1561,7 +1622,11 @@ async fn agents_syncing_detects_modified_source_skill() { 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 = find_installed_skill( + &workspace_root.join(".claude/skills"), + "user-authored-skill", + ) + .join("SKILL.md"); // Sanity: initial propagation worked. assert!(dest.exists(), "skill should be propagated on first sync"); @@ -2173,7 +2238,7 @@ async fn sync_installs_skill_from_named_crate_source() { let b_dir = find_installed_skill(&skills_dir, "b-guidance"); let content = std::fs::read_to_string(b_dir.join("SKILL.md"))?; assert!(content.contains("Use crate-b like this")); - assert!(b_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&b_dir)); Ok(()) }, ) @@ -2231,7 +2296,7 @@ async fn sync_crate_metadata_multihop_redirect() { content.contains("A → B → C redirect chain"), "skill from end of multi-hop chain should be installed" ); - assert!(c_dir.join(".symposium").exists()); + assert!(is_symposium_managed(&c_dir)); Ok(()) }, ) @@ -2327,12 +2392,7 @@ async fn sync_crate_metadata_hyphen_underscore_cycle() { ); // Should be exactly one skill (no duplicates from the self-redirect). - let all_skills: Vec<_> = std::fs::read_dir(&skills_dir) - .into_iter() - .flatten() - .flatten() - .filter(|e| e.path().is_dir() && e.path().join("SKILL.md").is_file()) - .collect(); +let all_skills = find_installed_skills(&skills_dir, "foo-guidance"); assert_eq!( all_skills.len(), 1, @@ -2471,26 +2531,26 @@ async fn sync_crate_metadata_missing_path_dir() { async fn sync_compiles_plugins_into_scoped_staging_roots() { with_fixture( TestMode::SimulationOnly, - &["plugin-skill-group0", "workspace0"], + &["agent-plugin-scopes0", "workspace0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; let events = ctx.sync_with_report(tracing::Level::INFO).await?; let root = ctx.workspace_root.clone().expect("workspace root"); - let project = root.join(".symposium/plugins/my-plugin"); - let global = ctx.sym.config_dir().join("installed/wildcard-plugin"); + let project = root.join(".symposium/plugins/project-tools"); + let global = ctx.sym.config_dir().join("installed/global-tools"); let manifest: Value = serde_json::from_str( &std::fs::read_to_string(project.join("plugin.json")).expect("read manifest"), ) .expect("parse manifest"); - assert_eq!(manifest["name"], "my-plugin"); + assert_eq!(manifest["name"], "project-tools"); assert_eq!( manifest["$schema"], "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" ); assert!( - project.join("skills/serde-guidance/SKILL.md").is_file(), + project.join("skills/project-guidance/SKILL.md").is_file(), "the plugin's skills are resolved into its own skills/ directory" ); assert!( @@ -2507,7 +2567,7 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { ); assert!( - global.join("skills/wildcard-guidance/SKILL.md").is_file(), + global.join("skills/global-guidance/SKILL.md").is_file(), "a workspace-independent plugin compiles to the global root, not the project" ); @@ -2532,8 +2592,8 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { ) .expect("parse index"); assert_eq!(index["name"], "symposium"); - assert_eq!(index["plugins"][0]["name"], "wildcard-plugin"); - assert_eq!(index["plugins"][0]["source"], "./wildcard-plugin"); + assert_eq!(index["plugins"][0]["name"], "global-tools"); + assert_eq!(index["plugins"][0]["source"], "./global-tools"); let project_index: Value = serde_json::from_str( &std::fs::read_to_string( @@ -2550,7 +2610,7 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { "a project marketplace is named per workspace, since registration is user-level" ); assert!( - !root.join(".symposium/plugins/wildcard-plugin").exists(), + !root.join(".symposium/plugins/global-tools").exists(), "and not to both" ); @@ -2570,7 +2630,7 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { reported.sort(); assert_eq!( reported, - vec![("my-plugin", "project"), ("wildcard-plugin", "global")] + vec![("global-tools", "global"), ("project-tools", "project")] ); Ok(()) @@ -2586,26 +2646,26 @@ async fn sync_compiles_plugins_into_scoped_staging_roots() { async fn compiled_directories_are_reaped_when_a_plugin_stops_applying() { with_fixture( TestMode::SimulationOnly, - &["plugin-skill-group0", "workspace0"], + &["agent-plugin-scopes0", "workspace0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; let root = ctx.workspace_root.clone().expect("workspace root"); - let compiled = root.join(".symposium/plugins/my-plugin"); + let compiled = root.join(".symposium/plugins/project-tools"); assert!(compiled.is_dir()); assert!( - find_installed_skills(&root.join(".claude/skills"), "serde-guidance").len() == 1, - "the per-skill install is untouched by compilation" + find_installed_skills(&root.join(".claude/skills"), "project-guidance").len() == 1, + "exactly one copy reaches the agent" ); let manifest = ctx .sym .config_dir() - .join("plugins/my-plugin/SYMPOSIUM.toml"); + .join("plugins/project-tools/SYMPOSIUM.toml"); std::fs::write( &manifest, - "name = \"my-plugin\"\ndepends-on = [\"nowhere-crate\"]\n\n[[skills]]\nsource.path = \".\"\n", + "name = \"project-tools\"\ndepends-on = [\"nowhere-crate\"]\n\n[[skills]]\nsource.path = \".\"\n", )?; ctx.symposium(&["sync"]).await?; @@ -2627,15 +2687,15 @@ async fn compiled_directories_are_reaped_when_a_plugin_stops_applying() { async fn syncing_another_workspace_leaves_global_plugins_alone() { with_fixture( TestMode::SimulationOnly, - &["plugin-skill-group0", "workspace0"], + &["agent-plugin-scopes0", "workspace0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "claude"]).await?; ctx.symposium(&["sync"]).await?; - let global = ctx.sym.config_dir().join("installed/wildcard-plugin"); + let global = ctx.sym.config_dir().join("installed/global-tools"); assert!(global.is_dir(), "first sync installs the global plugin"); assert!( - ctx.sym.config_dir().join("installed/my-plugin").exists().not(), + ctx.sym.config_dir().join("installed/project-tools").exists().not(), "a dependency-gated plugin must never reach the global root, or the \ next project's sync would reap it" ); @@ -2655,11 +2715,11 @@ async fn syncing_another_workspace_leaves_global_plugins_alone() { "syncing a project with no serde must not disturb the global set" ); assert!( - other.join(".symposium/plugins/wildcard-plugin").exists().not(), + other.join(".symposium/plugins/global-tools").exists().not(), "a global plugin is not also compiled into each project" ); assert!( - other.join(".symposium/plugins/my-plugin").exists().not(), + other.join(".symposium/plugins/project-tools").exists().not(), "the serde-gated plugin does not apply in a workspace without serde" ); Ok(()) @@ -2683,24 +2743,26 @@ async fn report_json_info_emits_installed_events() { assert!(!events.is_empty(), "expected at least one report event"); - let installed: Vec<&Value> = events + // Claude Code takes the compiled plugin directory, so the delivery + // is reported per plugin rather than per skill. + let compiled: Vec<&Value> = events .iter() - .filter(|e| e["kind"] == "skill_installed") + .filter(|e| e["kind"] == "plugin_compiled") .collect(); + assert_eq!(compiled[0]["plugin"], "serde-guidance"); + assert_eq!(compiled[0]["skills"], 1); + let delivered: Vec<&Value> = events + .iter() + .filter(|e| e["kind"] == "plugin_delivered") + .collect(); assert!( - !installed.is_empty(), - "expected at least one skill_installed event, got: {events:?}" - ); - assert_eq!(installed[0]["skill"], "serde-guidance"); - assert_eq!(installed[0]["plugin"], "serde-guidance"); - assert_eq!(installed[0]["agent"], "claude"); - assert!( - installed[0]["dest"] - .as_str() - .unwrap() - .contains("serde-guidance") + !delivered.is_empty(), + "expected at least one plugin_delivered event, got: {events:?}" ); + assert_eq!(delivered[0]["plugin"], "serde-guidance"); + assert_eq!(delivered[0]["agent"], "claude"); + assert_eq!(delivered[0]["scope"], "project"); // At INFO level, no plugin_considered or skill_considered events let considered: Vec<&Value> = events @@ -2743,14 +2805,14 @@ async fn report_json_debug_emits_decision_events() { "expected skill_considered matched event for serde-guidance, got: {events:#?}" ); - // Should also have skill_installed + // The install events show up at DEBUG too. let installed: Vec<&Value> = events .iter() - .filter(|e| e["kind"] == "skill_installed") + .filter(|e| e["kind"] == "plugin_delivered" || e["kind"] == "skill_installed") .collect(); assert!( !installed.is_empty(), - "expected skill_installed events at DEBUG level too" + "expected delivery events at DEBUG level too" ); Ok(()) From e4aef669d972da0788ce1641574618a8fb6d1162 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 15:49:12 -0300 Subject: [PATCH 05/14] feat(agent-plugin): read plugin.json packages as symposium plugins --- md/design/important-flows.md | 11 + md/design/module-structure.md | 12 + src/agent_plugin/manifest.rs | 84 ++++- src/agent_plugin/mod.rs | 3 + src/agent_plugin/read.rs | 122 ++++++++ src/agent_plugin/read_tests.rs | 287 ++++++++++++++++++ src/agent_plugin/tests.rs | 3 +- src/plugins.rs | 112 ++++++- src/pm/cargo/mod.rs | 41 ++- src/pm/layout.rs | 21 +- src/skills.rs | 99 ++++-- tests/fixtures/agent-plugin-read0/Cargo.toml | 11 + .../agent-plugin-read0/dep-crate/Cargo.toml | 6 + .../agent-plugin-read0/dep-crate/plugin.json | 1 + .../dep-crate/skills/dep-guidance/SKILL.md | 5 + .../agent-plugin-read0/dep-crate/src/lib.rs | 0 .../dot-symposium/config.toml | 8 + .../plugins/dormant-portable/mcp.json | 1 + .../plugins/dormant-portable/plugin.json | 4 + .../skills/dormant-guidance/SKILL.md | 5 + .../plugins/portable-tools/plugin.json | 10 + .../skills/nested/too-deep/SKILL.md | 5 + .../skills/portable-guidance/SKILL.md | 5 + .../agent-plugin-read0/member/Cargo.toml | 6 + .../agent-plugin-read0/member/plugin.json | 1 + .../member/skills/member-guidance/SKILL.md | 5 + .../agent-plugin-read0/member/src/lib.rs | 0 tests/fixtures/agent-plugin-read0/src/lib.rs | 0 tests/init_sync.rs | 81 +++++ 29 files changed, 912 insertions(+), 37 deletions(-) create mode 100644 src/agent_plugin/read.rs create mode 100644 src/agent_plugin/read_tests.rs create mode 100644 tests/fixtures/agent-plugin-read0/Cargo.toml create mode 100644 tests/fixtures/agent-plugin-read0/dep-crate/Cargo.toml create mode 100644 tests/fixtures/agent-plugin-read0/dep-crate/plugin.json create mode 100644 tests/fixtures/agent-plugin-read0/dep-crate/skills/dep-guidance/SKILL.md create mode 100644 tests/fixtures/agent-plugin-read0/dep-crate/src/lib.rs create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/config.toml create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/mcp.json create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/plugin.json create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/skills/dormant-guidance/SKILL.md create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/plugin.json create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/nested/too-deep/SKILL.md create mode 100644 tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/portable-guidance/SKILL.md create mode 100644 tests/fixtures/agent-plugin-read0/member/Cargo.toml create mode 100644 tests/fixtures/agent-plugin-read0/member/plugin.json create mode 100644 tests/fixtures/agent-plugin-read0/member/skills/member-guidance/SKILL.md create mode 100644 tests/fixtures/agent-plugin-read0/member/src/lib.rs create mode 100644 tests/fixtures/agent-plugin-read0/src/lib.rs diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 47de87a5..3c300c6c 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -43,6 +43,17 @@ Every `cargo agents sync` compiles the plugins that apply into the directory uni The key code paths are in `agent_plugin/mod.rs` (`compile`, `Scope::of`, `write`, `write_marketplace`, `reap_to_depth`), `agent_plugin/manifest.rs` (`slug`, `is_valid_name`, the three manifest shapes), `agents/plugin_install.rs` (`accepts_plugin_scope`, `install_plugins`, `plugin_reap_roots`), `predicate.rs` (`is_workspace_independent`), and `sync.rs`. +## Reading an externally authored package + +A directory holding a `plugin.json` loads as an ordinary symposium plugin, so everything downstream — compilation, delivery, `status` — treats it like any other. + +1. `pm::layout::classify` returns `EntryKind::AgentPlugin` for a directory carrying the manifest. Precedence runs `SYMPOSIUM.toml`, `plugin.json`, `SKILL.md`, so a directory with both TOML and JSON loads as a symposium plugin. A claimed directory is not descended into, so nesting a package inside a package is not a way to ship two; a source root that is itself a package is an error. +2. `agent_plugin::read::load` parses the manifest, reports unknown top-level fields and an unsupported `mcp.json`, reads the gate from `extensions["dev.symposium"]`, and returns a `Plugin` with one `skills/` group limited to immediate children. +3. The three positions call it: `plugins::load_entry` for a registry entry (dormancy applies), `workspace_plugin_for_dir` for a member, and `CargoPm::build_from_fetched` for a dependency (both gated by position, so no `use` entry is needed). `embedded_plugin_kind` counts a `plugin.json` as plugin content, so a dependency carrying one is offered for consent. +4. Containment is per unit: a bad manifest rejects that package alone, an unknown field is reported and ignored, a broken skill is skipped while the rest load, and a skill resolving outside the package is refused. + +The key code paths are in `agent_plugin/read.rs`, `agent_plugin/manifest.rs` (`IncomingManifest`), `pm/layout.rs` (`classify`, `AGENT_PLUGIN_FILE`), `plugins.rs` (`load_entry`, `workspace_plugin_for_dir`, `apply_sibling_identity`, `dormant_without_gate`), and `skills.rs` (`discover_skills`, `SkillDepth`). + ## 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 164500cd..098e9453 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -74,6 +74,18 @@ The manifest always carries a `version`, even though the format allows omitting `write` assembles the directory in a temporary directory and hands it to `sync::sync_managed_dir`, so the install is change-aware and debounced exactly like a skill directory: recompiling identical content leaves the destination untouched. `reap_to_depth` removes marked directories the current sync did not write, keyed on the `.symposium` marker so a directory the user placed there is left alone; the depth lets one function serve both a staging root and an agent's own tree, where Codex nests copies as `//`. +### `agent_plugin/read.rs` — reading an externally authored package + +A directory holding a `plugin.json` is a third kind of plugin entry beside one holding a `SYMPOSIUM.toml` and one holding a bare `SKILL.md`, recognized in the same three positions with each position keeping its meaning: a registry entry is curated but ungated, a workspace member is gated by membership, a dependency is an untrusted offer subject to consent. + +`IncomingManifest` is the read counterpart of the [`Manifest`](#agent_plugin--compiling-an-agent-plugin-directory) symposium writes, and the two are deliberately separate: an incoming package may carry fields we never emit. Unknown top-level keys are captured through a flattened map and reported rather than rejected, since the format asks a client to tolerate what it does not recognize. A name that breaks the format's grammar does reject the package, because a package with an unusable identity cannot be installed anywhere. + +The format cannot express *when* a package applies, so gating comes from `extensions["dev.symposium"]` (`depends-on` and `predicates`, in the same syntax a `SYMPOSIUM.toml` gate uses — the existing deserializers read them straight from JSON). Other namespaces are ignored without being inspected, as the format requires. A malformed `dev.symposium` object *is* an error: it was written for symposium, so ignoring it would activate the package more widely than its author asked. A package declaring no gate is dormant under the ordinary rule, unless its position already gates it. + +Skills map without adaptation except in one respect: the format fixes `skills/` at one level, so the group carries `SkillDepth::ImmediateChildren` while every symposium-declared group keeps the recursive walk. Discovery also refuses a skill whose real path leaves the directory it was found in — a symlink out would otherwise be read here and silently dropped at install time, since the copy ignores symlinks, producing an empty skill rather than a reported one. + +A directory carrying both manifests loads as a symposium plugin, `SYMPOSIUM.toml` being the richer one, but `sibling_identity` fills the name, version, and description the TOML omits so an author need not repeat what they already declared portably. A broken companion is reported and ignored rather than rejecting the plugin the TOML defines. + ### `agents/plugin_install.rs` — handing a directory to an agent Two mechanisms, and which one applies is a property of the agent. Each row below was established by installing a directory and asking the running agent what it could see, then deleting parts of the installation to find what was actually required: diff --git a/src/agent_plugin/manifest.rs b/src/agent_plugin/manifest.rs index 6d91d1cb..f0ef686b 100644 --- a/src/agent_plugin/manifest.rs +++ b/src/agent_plugin/manifest.rs @@ -5,7 +5,7 @@ //! names (which are crate names or free-form manifest strings), so a name has //! to be slugged before it can be written. -use serde::Serialize; +use serde::{Deserialize, Serialize}; pub const SCHEMA_URL: &str = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json"; @@ -42,6 +42,88 @@ impl Manifest { } } +/// An externally authored `plugin.json`, as symposium reads it. +/// +/// Separate from [`Manifest`], which is what symposium *writes*: a package we +/// read may carry fields we do not emit, and the format requires a client to +/// ignore a namespace it does not implement without inspecting it. Unknown +/// top-level keys are captured rather than rejected so the package still loads +/// and the surprise can be reported. +#[derive(Debug, Clone, Deserialize)] +pub struct IncomingManifest { + pub name: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub extensions: std::collections::BTreeMap, + #[serde(flatten)] + pub unknown: std::collections::BTreeMap, +} + +/// The `extensions` namespace through which a portable package can carry +/// symposium gating. Keyed on a domain the project controls, as the format asks. +pub const SYMPOSIUM_NAMESPACE: &str = "dev.symposium"; + +/// Fields the format itself defines, so an unknown-key report does not flag the +/// ones we simply do not use. +const KNOWN_FIELDS: &[&str] = &[ + "$schema", + "author", + "homepage", + "repository", + "license", + "keywords", +]; + +/// Symposium's gate, read from `extensions["dev.symposium"]`. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SymposiumExtension { + #[serde(default, rename = "depends-on")] + pub depends_on: Option, + #[serde(default)] + pub predicates: crate::predicate::PredicateSet, +} + +impl IncomingManifest { + /// Parse a manifest, rejecting one whose name breaks the format's grammar — + /// a package with an unusable identity cannot be installed anywhere. + pub fn parse(text: &str) -> anyhow::Result { + let manifest: Self = serde_json::from_str(text)?; + if !is_valid_name(&manifest.name) { + anyhow::bail!( + "plugin name `{}` is not 1 to 64 characters of lowercase letters, digits, \ + hyphens, and periods starting and ending alphanumeric", + manifest.name + ); + } + Ok(manifest) + } + + /// Top-level keys that are neither ours nor the format's, for reporting. + pub fn unknown_fields(&self) -> Vec<&str> { + self.unknown + .keys() + .map(String::as_str) + .filter(|key| !KNOWN_FIELDS.contains(key)) + .collect() + } + + /// The symposium gate, or `None` when the package declares none. An + /// unparseable one is an error: it was written for us, so ignoring it + /// silently would activate the package more widely than intended. + pub fn symposium_extension(&self) -> anyhow::Result> { + let Some(raw) = self.extensions.get(SYMPOSIUM_NAMESPACE) else { + return Ok(None); + }; + let parsed = serde_json::from_value(raw.clone()) + .map_err(|e| anyhow::anyhow!("invalid `extensions.{SYMPOSIUM_NAMESPACE}`: {e}"))?; + Ok(Some(parsed)) + } +} + /// Gemini CLI reads its own manifest name, carrying just the identity. The /// directory is otherwise the same, so this is a second file rather than a /// second layout. diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index 74ffe2a1..e8bc93c3 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -6,6 +6,7 @@ //! receives a gate and never resolves one. pub mod manifest; +pub mod read; use std::fs; use std::path::{Path, PathBuf}; @@ -398,5 +399,7 @@ pub fn reap(root: &Path, written: &std::collections::BTreeSet) { /// `//`, the others one or two levels. pub const AGENT_COPY_DEPTH: usize = 3; +#[cfg(test)] +mod read_tests; #[cfg(test)] mod tests; diff --git a/src/agent_plugin/read.rs b/src/agent_plugin/read.rs new file mode 100644 index 00000000..5c800e26 --- /dev/null +++ b/src/agent_plugin/read.rs @@ -0,0 +1,122 @@ +//! Reading an externally authored agent plugin package as a symposium plugin. +//! +//! A directory holding a `plugin.json` becomes a third kind of plugin entry +//! beside one holding a `SYMPOSIUM.toml` and one holding a bare `SKILL.md`, and +//! it is recognized in the same three positions: a registry entry, a workspace +//! member, and a dependency's source. +//! +//! Failures are contained to the smallest affected unit and reported rather than +//! suppressed, which is what the format requires: a manifest that breaks its +//! schema rejects that package alone, an unknown top-level field is reported and +//! ignored, and a broken skill is skipped while the rest of the package loads. + +use std::path::Path; + +use anyhow::{Context, Result}; + +use super::manifest::IncomingManifest; +use crate::plugins::{Plugin, PluginSource, SkillDepth, SkillGroup}; +use crate::report::ReportEvent; + +/// The manifest that marks a directory as an agent plugin package. +pub const MANIFEST_FILE: &str = "plugin.json"; + +/// The format's other component type. Symposium reads the skills half, so this +/// is reported as unsupported rather than silently ignored. +const MCP_FILE: &str = "mcp.json"; + +/// Fixed by the format: skills live in `skills/`, one per immediate child, and +/// the manifest cannot point somewhere else. +const SKILLS_DIR: &str = "skills"; + +/// Load the package in `dir`. +/// +/// `gated_by_position` is true where finding the package is itself the gate — a +/// workspace member, or a crate reached through a reference. Elsewhere the +/// ordinary dormancy rule applies: the format cannot express when a package +/// applies, so one that declares no `dev.symposium` gate waits for a `use` entry +/// rather than activating everywhere. +pub fn load(dir: &Path, gated_by_position: bool) -> Result { + let manifest_path = dir.join(MANIFEST_FILE); + let text = std::fs::read_to_string(&manifest_path) + .with_context(|| format!("read {}", manifest_path.display()))?; + let manifest = IncomingManifest::parse(&text) + .with_context(|| format!("invalid {}", manifest_path.display()))?; + + let unknown = manifest.unknown_fields(); + if !unknown.is_empty() { + report_warning(format!( + "{}: ignoring unknown field(s) {}", + crate::output::display_path(&manifest_path), + unknown.join(", ") + )); + } + + if dir.join(MCP_FILE).is_file() { + report_warning(format!( + "{}: MCP servers in an agent plugin are not supported yet; its skills still load", + crate::output::display_path(&dir.join(MCP_FILE)) + )); + } + + let extension = manifest.symposium_extension()?.unwrap_or_default(); + let predicates = + crate::predicate::PredicateSet::merged(extension.depends_on, extension.predicates); + let requires_use = !gated_by_position && crate::plugins::dormant_without_gate(&predicates); + + Ok(Plugin { + name: manifest.name, + version: manifest.version, + description: manifest.description, + predicates, + skills: vec![SkillGroup { + source: PluginSource::Path(SKILLS_DIR.into()), + depth: SkillDepth::ImmediateChildren, + ..Default::default() + }], + requires_use, + ..Default::default() + }) +} + +/// Identity a `plugin.json` supplies to a `SYMPOSIUM.toml` sitting beside it. +/// +/// A directory carrying both loads as a symposium plugin, since the TOML is the +/// richer manifest, but takes what the TOML leaves out from the JSON. +#[derive(Debug, Default)] +pub struct SiblingIdentity { + pub name: Option, + pub version: Option, + pub description: Option, +} + +/// Read the identity from a `plugin.json` in `dir`, if there is a usable one. +/// A malformed sibling is reported and ignored: the TOML is what defines this +/// plugin, so a broken companion must not reject it. +pub fn sibling_identity(dir: &Path) -> SiblingIdentity { + let path = dir.join(MANIFEST_FILE); + if !path.is_file() { + return SiblingIdentity::default(); + } + let parsed = std::fs::read_to_string(&path) + .map_err(anyhow::Error::from) + .and_then(|text| IncomingManifest::parse(&text)); + match parsed { + Ok(manifest) => SiblingIdentity { + name: Some(manifest.name), + version: manifest.version, + description: manifest.description, + }, + Err(e) => { + report_warning(format!( + "{}: ignoring companion manifest: {e:#}", + crate::output::display_path(&path) + )); + SiblingIdentity::default() + } + } +} + +fn report_warning(message: String) { + tracing::info!(report = %ReportEvent::Warning { message }); +} diff --git a/src/agent_plugin/read_tests.rs b/src/agent_plugin/read_tests.rs new file mode 100644 index 00000000..40128c41 --- /dev/null +++ b/src/agent_plugin/read_tests.rs @@ -0,0 +1,287 @@ +use std::path::{Path, PathBuf}; + +use super::read; +use crate::plugins::{PluginSource, SkillDepth}; + +fn package(dir: &Path, manifest: &str) -> PathBuf { + std::fs::create_dir_all(dir).expect("create package dir"); + std::fs::write(dir.join("plugin.json"), manifest).expect("write manifest"); + dir.to_path_buf() +} + +fn skill(dir: &Path, rel: &str, name: &str) { + let skill_dir = dir.join(rel); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: d\n---\nbody\n"), + ) + .expect("write SKILL.md"); +} + +const MINIMAL: &str = r#"{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "pdf-tools" +}"#; + +#[test] +fn a_package_becomes_a_plugin_with_one_immediate_children_group() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pdf-tools"), MINIMAL); + + let plugin = read::load(&dir, false).expect("load"); + assert_eq!(plugin.name, "pdf-tools"); + assert_eq!(plugin.skills.len(), 1); + assert_eq!( + plugin.skills[0].source, + PluginSource::Path(PathBuf::from("skills")), + "the format fixes the location and the manifest cannot redirect it" + ); + assert_eq!(plugin.skills[0].depth, SkillDepth::ImmediateChildren); +} + +#[test] +fn identity_fields_carry_over() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{ + "name": "pdf-tools", + "version": "1.2.0", + "description": "Table extraction guidance" + }"#, + ); + let plugin = read::load(&dir, false).expect("load"); + assert_eq!(plugin.version.as_deref(), Some("1.2.0")); + assert_eq!( + plugin.description.as_deref(), + Some("Table extraction guidance") + ); +} + +#[test] +fn a_package_with_no_gate_is_dormant_unless_its_position_gates_it() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pdf-tools"), MINIMAL); + + assert!( + read::load(&dir, false).expect("load").requires_use, + "the format cannot say when a package applies, so a registry entry waits to be used" + ); + assert!( + !read::load(&dir, true).expect("load").requires_use, + "a workspace member or a referenced crate is already gated by where it was found" + ); +} + +#[test] +fn the_symposium_namespace_supplies_a_gate() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{ + "name": "pdf-tools", + "extensions": { + "dev.symposium": { + "depends-on": ["lopdf"], + "predicates": ["path_exists(pdftotext)"] + } + } + }"#, + ); + let plugin = read::load(&dir, false).expect("load"); + assert!( + !plugin.requires_use, + "a declared gate takes the package out of dormancy" + ); + assert!(plugin.predicates.references_dep("lopdf")); + assert_eq!(plugin.predicates.predicates.len(), 2); +} + +#[test] +fn an_unrelated_extensions_namespace_is_ignored_without_being_inspected() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{ + "name": "pdf-tools", + "extensions": { "com.example.other": { "whatever": [1, 2, 3] } } + }"#, + ); + let plugin = read::load(&dir, false).expect("load"); + assert!(plugin.requires_use, "still no gate of ours"); + assert!(plugin.predicates.predicates.is_empty()); +} + +#[test] +fn a_malformed_symposium_gate_rejects_the_package() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{ + "name": "pdf-tools", + "extensions": { "dev.symposium": { "depends-on": ["lopdf"], "typo": true } } + }"#, + ); + let err = read::load(&dir, false).expect_err("must not load"); + assert!( + format!("{err:#}").contains("dev.symposium"), + "the gate was written for us, so ignoring it would over-activate: {err:#}" + ); +} + +#[test] +fn a_name_breaking_the_formats_grammar_rejects_the_package() { + let tmp = tempfile::tempdir().expect("tmp"); + for bad in ["Pdf_Tools", "-leading", ""] { + let dir = package(&tmp.path().join("pkg"), &format!("{{\"name\": \"{bad}\"}}")); + let err = read::load(&dir, false).expect_err("must not load"); + assert!( + format!("{err:#}").contains("not 1 to 64 characters"), + "unexpected error for {bad:?}: {err:#}" + ); + } +} + +#[test] +fn a_missing_name_or_broken_json_rejects_the_package() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pkg"), r#"{"version": "1.0.0"}"#); + assert!(read::load(&dir, false).is_err(), "name is required"); + + let dir = package(&tmp.path().join("pkg2"), "{ not json"); + assert!(read::load(&dir, false).is_err()); +} + +#[test] +fn unknown_top_level_fields_do_not_stop_the_package_loading() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{ + "name": "pdf-tools", + "license": "MIT", + "keywords": ["pdf"], + "somethingNew": { "from": "a later spec" } + }"#, + ); + let plugin = read::load(&dir, false).expect("load"); + assert_eq!(plugin.name, "pdf-tools"); +} + +#[test] +fn only_immediate_children_of_skills_hold_skills() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pdf-tools"), MINIMAL); + skill(&dir, "skills/extract", "extract"); + skill(&dir, "skills/nested/deeper", "deeper"); + + let plugin = read::load(&dir, false).expect("load"); + let found = crate::skills::discover_skills( + &dir.join("skills"), + false, + &crate::predicate::PredicateSet::default(), + plugin.skills[0].depth, + ); + let names: Vec = found + .iter() + .filter_map(|r| r.as_ref().ok()) + .map(|s| s.name().to_string()) + .collect(); + assert_eq!( + names, + vec!["extract"], + "deeper folders are not searched, per the format" + ); +} + +#[test] +fn a_broken_skill_is_skipped_and_the_others_load() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pdf-tools"), MINIMAL); + skill(&dir, "skills/good", "good"); + std::fs::create_dir_all(dir.join("skills/broken")).expect("create"); + std::fs::write(dir.join("skills/broken/SKILL.md"), "no frontmatter here").expect("write"); + + let found = crate::skills::discover_skills( + &dir.join("skills"), + false, + &crate::predicate::PredicateSet::default(), + SkillDepth::ImmediateChildren, + ); + assert_eq!(found.len(), 2); + assert_eq!(found.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!(found.iter().filter(|r| r.is_err()).count(), 1); +} + +#[cfg(unix)] +#[test] +fn a_skill_symlinked_out_of_the_package_is_refused() { + let tmp = tempfile::tempdir().expect("tmp"); + let outside = tmp.path().join("outside"); + skill(&outside, "secret", "secret"); + + let dir = package(&tmp.path().join("pdf-tools"), MINIMAL); + std::fs::create_dir_all(dir.join("skills")).expect("create"); + std::os::unix::fs::symlink(outside.join("secret"), dir.join("skills/secret")).expect("symlink"); + + let found = crate::skills::discover_skills( + &dir.join("skills"), + false, + &crate::predicate::PredicateSet::default(), + SkillDepth::ImmediateChildren, + ); + assert_eq!(found.len(), 1); + let err = found[0].as_ref().expect_err("must be refused"); + assert!( + format!("{err:#}").contains("resolves outside"), + "the copy would silently drop it, so it has to be reported: {err:#}" + ); +} + +#[test] +fn a_sibling_manifest_supplies_only_what_the_toml_omits() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{"name": "portable-name", "version": "1.2.0", "description": "from json"}"#, + ); + + let identity = read::sibling_identity(&dir); + assert_eq!(identity.name.as_deref(), Some("portable-name")); + assert_eq!(identity.version.as_deref(), Some("1.2.0")); + assert_eq!(identity.description.as_deref(), Some("from json")); + + let none = read::sibling_identity(&tmp.path().join("empty")); + assert!(none.name.is_none() && none.version.is_none()); +} + +#[test] +fn a_broken_sibling_manifest_is_ignored_rather_than_rejecting_the_toml() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package(&tmp.path().join("pdf-tools"), "{ not json"); + let identity = read::sibling_identity(&dir); + assert!( + identity.name.is_none(), + "the TOML defines this plugin; a broken companion must not reject it" + ); +} + +#[test] +fn an_mcp_component_does_not_stop_the_skills_loading() { + let tmp = tempfile::tempdir().expect("tmp"); + let dir = package( + &tmp.path().join("pdf-tools"), + r#"{"name": "pdf-tools", "extensions": {"dev.symposium": {"depends-on": ["lopdf"]}}}"#, + ); + std::fs::write(dir.join("mcp.json"), r#"{"mcpServers": {}}"#).expect("write mcp.json"); + skill(&dir, "skills/extract", "extract"); + + let plugin = read::load(&dir, false).expect("load"); + assert_eq!( + plugin.skills.len(), + 1, + "the format's other component type is reported as unsupported, not fatal" + ); + assert!(plugin.predicates.references_dep("lopdf")); +} diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index ea631a7a..caade6a6 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -137,8 +137,7 @@ fn a_dependency_gated_group_or_skill_keeps_the_plugin_project_scoped() { grouped.plugin.skills = vec![SkillGroup { predicates: on_serde(), source: PluginSource::Path(PathBuf::from("skills")), - source_label: None, - workspace_member: false, + ..Default::default() }]; assert_eq!(Scope::of(&grouped, &[], &globally), Scope::Project); diff --git a/src/plugins.rs b/src/plugins.rs index 5d1335b4..c32c63da 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -81,6 +81,12 @@ pub enum PluginSource { Git(String), } +impl Default for PluginSource { + fn default() -> Self { + PluginSource::Path(PathBuf::new()) + } +} + #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawPluginSource { @@ -161,11 +167,22 @@ impl serde::Serialize for PluginSource { } } +/// How deep a skill group's directory is searched. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +pub enum SkillDepth { + /// Walk the whole tree, keeping the shallowest `SKILL.md` on each branch. + #[default] + Recursive, + /// Only direct children hold skills. The Agent Plugins format fixes `skills/` + /// at one level, so a package read in that format uses this. + ImmediateChildren, +} + /// A `[[skills]]` entry from a plugin manifest. /// /// The group's `depends-on` and `predicates` fields are merged into one /// [`PredicateSet`](crate::predicate::PredicateSet) that gates the group. -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Default, Serialize)] pub struct SkillGroup { #[serde( default, @@ -187,6 +204,8 @@ pub struct SkillGroup { /// name and `description` (with the frontmatter itself) is optional. #[serde(skip)] pub workspace_member: bool, + #[serde(skip)] + pub depth: SkillDepth, } #[derive(Debug, Deserialize)] @@ -215,6 +234,7 @@ impl RawSkillGroup { source, source_label: None, workspace_member: false, + depth: SkillDepth::default(), }) } } @@ -1254,9 +1274,10 @@ pub async fn find_plugin(sym: &Symposium, name: &str) -> Option { } /// Load the plugin at `root/subpath` as a registry entry: a `SYMPOSIUM.toml` -/// manifest loads as an ordinary registry plugin; a bare `SKILL.md` is +/// manifest loads as an ordinary registry plugin, a `plugin.json` as an +/// [agent plugin package](crate::agent_plugin::read), and a bare `SKILL.md` is /// synthesized into a default plugin ([`load_standalone_skill_plugin`]). `None` -/// when the directory is neither. Called by [`PathPm`](crate::pm::PathPm). +/// when the directory is none of them. Called by [`PathPm`](crate::pm::PathPm). pub(crate) fn load_entry( root: &Path, subpath: &Path, @@ -1268,6 +1289,10 @@ pub(crate) fn load_entry( load_plugin_as(&toml_path, source_name, root, ManifestOrigin::Registry) .with_context(|| format!("loading plugin from `{}`", toml_path.display())), ), + crate::pm::layout::EntryKind::AgentPlugin(json_path) => Some( + load_agent_plugin_entry(&dir, source_name, root) + .with_context(|| format!("loading plugin from `{}`", json_path.display())), + ), crate::pm::layout::EntryKind::Skill(skill_md) => Some( load_standalone_skill_plugin(&skill_md, source_name, root) .with_context(|| format!("loading skill from `{}`", skill_md.display())), @@ -1275,6 +1300,23 @@ pub(crate) fn load_entry( } } +/// An agent plugin package found by its position in a registry. A registry is +/// curated, but the format cannot say when a package applies, so the ordinary +/// dormancy rule decides whether it activates. +fn load_agent_plugin_entry( + dir: &Path, + source_name: &str, + source_dir: &Path, +) -> Result { + let mut plugin = crate::agent_plugin::read::load(dir, false)?; + resolve_group_sources(&mut plugin, dir, source_dir); + Ok(ParsedPlugin { + canonical: PackageId::new(source_name, &plugin.name, ANY_VERSION), + plugin, + workspace_member: false, + }) +} + /// Resolve each `source.path` skill group to an absolute directory and a /// display label, given the plugin's own base directory (what the relative /// path is joined onto) and the attribution root the label is shown relative @@ -1301,6 +1343,19 @@ pub(crate) fn resolve_group_sources(plugin: &mut Plugin, base_dir: &Path, attrib } } +/// Does this gate leave a plugin with nothing to infer activation from? +/// +/// Such a plugin is *dormant*: known and loaded, but inactive until a +/// `[plugins] use` entry names it. A custom predicate counts as a gate even +/// though it names no dependency, since its answer is computed. +pub(crate) fn dormant_without_gate(predicates: &crate::predicate::PredicateSet) -> bool { + let has_custom = predicates + .predicates + .iter() + .any(|p| matches!(p, crate::predicate::Predicate::Custom { .. })); + !(has_custom || predicates.mentions_dep()) +} + /// Build a plugin from a bare `SKILL.md` entry (no manifest): a plugin whose /// single `source.path = "."` skill group discovers that skill. The plugin is /// named for the skill's declared `name` (its identity, falling back to the @@ -1325,11 +1380,7 @@ fn load_standalone_skill_plugin( }) .context("standalone skill has neither a frontmatter `name` nor a named directory")?; - let has_custom = predicates - .predicates - .iter() - .any(|p| matches!(p, crate::predicate::Predicate::Custom { .. })); - let requires_use = !(has_custom || predicates.mentions_dep()); + let requires_use = dormant_without_gate(&predicates); // A single group scanning the entry directory (the SKILL.md's parent, via // `path`) discovers the skill itself. @@ -1598,9 +1649,20 @@ fn workspace_plugin_for_dir( agents_skills: bool, ) -> Result> { let manifest_path = dir.join("SYMPOSIUM.toml"); + if !manifest_path.is_file() && dir.join(crate::pm::layout::AGENT_PLUGIN_FILE).is_file() { + // Membership is the gate, so the package activates without a `use` entry. + let mut plugin = crate::agent_plugin::read::load(dir, true)?; + resolve_group_sources(&mut plugin, dir, workspace_root); + return Ok(Some(ParsedPlugin { + canonical: PackageId::new("local", &plugin.name, ANY_VERSION), + plugin, + workspace_member: true, + })); + } + let bare_convention = dir.join(CRATE_DEFAULT_SKILLS_PATH).is_dir() || (agents_skills && dir.join(AGENTS_SKILLS_PATH).is_dir()); - let raw: RawPluginManifest = if manifest_path.is_file() { + let mut raw: RawPluginManifest = if manifest_path.is_file() { toml::from_str(&fs::read_to_string(&manifest_path)?)? } else if bare_convention { // Bare convention: a `skills/` (or `.agents/skills/`) directory with @@ -1610,6 +1672,7 @@ fn workspace_plugin_for_dir( return Ok(None); }; + apply_sibling_identity(&mut raw, dir); let dir_name = dir .file_name() .and_then(|n| n.to_str()) @@ -1656,6 +1719,13 @@ fn scan_source_dir>(dir: P, source_name: &str) -> Result { + let entry_dir = dir.join(&entry.subpath); + let plugin = load_agent_plugin_entry(&entry_dir, source_name, dir) + .with_context(|| format!("loading plugin from `{}`", json_path.display())); + tracing::debug!(path = %json_path.display(), "loaded agent plugin package"); + plugins.push(plugin); + } Some(crate::pm::layout::EntryKind::Skill(skill_md_path)) => { let plugin = load_standalone_skill_plugin(&skill_md_path, source_name, dir) .with_context(|| format!("loading skill from `{}`", skill_md_path.display())); @@ -1728,6 +1798,7 @@ pub fn validate_source_dir(dir: &Path) -> Result> { &skills_dir, group.workspace_member, &group.predicates, + group.depth, ); let group_label = group .source_label @@ -1850,10 +1921,11 @@ fn load_plugin_as( origin: ManifestOrigin<'_>, ) -> Result { let content = fs::read_to_string(manifest_path)?; - let manifest: RawPluginManifest = toml::from_str(&content)?; + let mut manifest: RawPluginManifest = toml::from_str(&content)?; + let base = manifest_path.parent().unwrap_or(source_dir); + apply_sibling_identity(&mut manifest, base); let mut plugin = validate_manifest(manifest, origin) .with_context(|| format!("validating `{}`", manifest_path.display()))?; - let base = manifest_path.parent().unwrap_or(source_dir); resolve_group_sources(&mut plugin, base, source_dir); Ok(ParsedPlugin { canonical: PackageId::new(source_name, &plugin.name, ANY_VERSION), @@ -1864,6 +1936,24 @@ fn load_plugin_as( }) } +/// Fill identity the TOML leaves out from a `plugin.json` beside it. +/// +/// A directory carrying both manifests loads as a symposium plugin, since the +/// TOML is the richer one, but there is no reason to make an author repeat the +/// name and version they already declared portably. +fn apply_sibling_identity(raw: &mut RawPluginManifest, dir: &Path) { + let identity = crate::agent_plugin::read::sibling_identity(dir); + if raw.name.is_none() { + raw.name = identity.name; + } + if raw.version.is_none() { + raw.version = identity.version; + } + if raw.description.is_none() { + raw.description = identity.description; + } +} + fn raw_crate_manifest(content: &str) -> Result { Ok(toml::from_str(content)?) } diff --git a/src/pm/cargo/mod.rs b/src/pm/cargo/mod.rs index 7b6dd7fc..d92e7114 100644 --- a/src/pm/cargo/mod.rs +++ b/src/pm/cargo/mod.rs @@ -72,6 +72,35 @@ impl CargoPm { None }); + // A crate whose only plugin content is an agent plugin package is read + // in that format. The reference that reached the crate is its gate. + if metadata.is_none() + && !fetched.root.join("SYMPOSIUM.toml").is_file() + && fetched + .root + .join(crate::pm::layout::AGENT_PLUGIN_FILE) + .is_file() + { + return match crate::agent_plugin::read::load(&fetched.root, true) { + Ok(mut plugin) => { + crate::plugins::resolve_group_sources( + &mut plugin, + &fetched.root, + &fetched.root, + ); + Some(ParsedPlugin { + canonical: fetched.id.clone(), + plugin, + workspace_member: false, + }) + } + Err(e) => { + tracing::warn!(crate_name = %name, error = %e, "invalid agent plugin package"); + None + } + }; + } + let manifest_path = fetched.root.join("SYMPOSIUM.toml"); let file = if manifest_path.is_file() { match std::fs::read_to_string(&manifest_path) { @@ -105,6 +134,12 @@ impl CargoPm { } }; + // A `plugin.json` beside the TOML supplies identity the TOML omits. The + // name stays the crate's, which is a crate's real identity either way. + let sibling = crate::agent_plugin::read::sibling_identity(&fetched.root); + plugin.version = plugin.version.or(sibling.version); + plugin.description = plugin.description.or(sibling.description); + // The crate source root is both the base for `source.path` groups and // the attribution root for their labels. crate::plugins::resolve_group_sources(&mut plugin, &fetched.root, &fetched.root); @@ -120,11 +155,15 @@ impl CargoPm { /// What plugin content a crate source tree at `dir` embeds, as a short /// human-readable phrase — or `None` when it embeds none. Mirrors what /// [`CargoPm::load_plugin`] would build a plugin from: a `SYMPOSIUM.toml`, -/// `[package.metadata.symposium]`, or the default `skills/` directory. +/// `[package.metadata.symposium]`, a `plugin.json` agent plugin package, or the +/// default `skills/` directory. fn embedded_plugin_kind(dir: &std::path::Path) -> Option<&'static str> { if dir.join("SYMPOSIUM.toml").is_file() { return Some("plugin manifest (SYMPOSIUM.toml)"); } + if dir.join(crate::pm::layout::AGENT_PLUGIN_FILE).is_file() { + return Some("agent plugin package (plugin.json)"); + } if matches!( crate::crate_metadata::symposium_metadata(&dir.join("Cargo.toml")), Ok(Some(_)) diff --git a/src/pm/layout.rs b/src/pm/layout.rs index 85a93ce0..1d892e92 100644 --- a/src/pm/layout.rs +++ b/src/pm/layout.rs @@ -24,22 +24,35 @@ pub const MANIFEST_FILE: &str = "SYMPOSIUM.toml"; /// Skill file that marks a directory as a standalone-skill entry. pub const SKILL_FILE: &str = "SKILL.md"; +/// Manifest that marks a directory as an externally authored +/// [agent plugin](crate::agent_plugin::read) package. +pub const AGENT_PLUGIN_FILE: &str = crate::agent_plugin::read::MANIFEST_FILE; + /// What kind of entry a directory is. #[derive(Debug)] pub enum EntryKind { /// A plugin entry; carries the path to its `SYMPOSIUM.toml`. Plugin(PathBuf), + /// An agent plugin package; carries the path to its `plugin.json`. + AgentPlugin(PathBuf), /// A standalone-skill entry; carries the path to its `SKILL.md`. Skill(PathBuf), } -/// Classify a directory as an entry, or `None` when it is neither. -/// [`MANIFEST_FILE`] takes precedence over [`SKILL_FILE`]. +/// Classify a directory as an entry, or `None` when it is none of them. +/// +/// Precedence runs [`MANIFEST_FILE`], [`AGENT_PLUGIN_FILE`], [`SKILL_FILE`]: +/// `SYMPOSIUM.toml` is the richer manifest, so a directory carrying both it and +/// a `plugin.json` loads as a symposium plugin. pub fn classify(dir: &Path) -> Option { let manifest = dir.join(MANIFEST_FILE); if manifest.is_file() { return Some(EntryKind::Plugin(manifest)); } + let agent_plugin = dir.join(AGENT_PLUGIN_FILE); + if agent_plugin.is_file() { + return Some(EntryKind::AgentPlugin(agent_plugin)); + } let skill_md = dir.join(SKILL_FILE); if skill_md.is_file() { return Some(EntryKind::Skill(skill_md)); @@ -63,6 +76,10 @@ pub fn enumerate(root: &Path) -> Result> { "plugin source root contains SYMPOSIUM.toml — it should contain subdirectories with plugins, not be a plugin itself: {}", root.display() ), + Some(EntryKind::AgentPlugin(_)) => anyhow::bail!( + "plugin source root contains {AGENT_PLUGIN_FILE} — it should contain subdirectories with plugins, not be a plugin itself: {}", + root.display() + ), Some(EntryKind::Skill(_)) => anyhow::bail!( "plugin source root contains SKILL.md — it should contain subdirectories with skills, not be a skill itself: {}", root.display() diff --git a/src/skills.rs b/src/skills.rs index 1379bf8e..25a96061 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -291,7 +291,12 @@ fn collect_skills_from_dirs( ) -> Vec<(Skill, String)> { let mut skills = Vec::new(); for entry in resolved { - let discovered = discover_skills(&entry.dir, group.workspace_member, &group.predicates); + let discovered = discover_skills( + &entry.dir, + group.workspace_member, + &group.predicates, + group.depth, + ); tracing::debug!( report = %crate::report::ReportEvent::SkillSourceSearched { plugin: entry.plugin_label.clone(), @@ -352,21 +357,61 @@ pub(crate) fn discover_skills( skills_dir: &Path, workspace_member: bool, group_predicates: &PredicateSet, + depth: crate::plugins::SkillDepth, ) -> Vec> { if !skills_dir.is_dir() { return Vec::new(); } let mut skill_files = Vec::new(); - find_skill_files_recursive(skills_dir, &mut skill_files); - prune_nested_skills(&mut skill_files); + match depth { + crate::plugins::SkillDepth::Recursive => { + find_skill_files_recursive(skills_dir, &mut skill_files); + prune_nested_skills(&mut skill_files); + } + crate::plugins::SkillDepth::ImmediateChildren => { + find_skill_files_in_children(skills_dir, &mut skill_files) + } + } skill_files .into_iter() - .map(|skill_md| load_skill(&skill_md, workspace_member, group_predicates)) + .map(|skill_md| { + contained_in(skills_dir, &skill_md)?; + load_skill(&skill_md, workspace_member, group_predicates) + }) .collect() } +/// `SKILL.md` in each direct child of `dir`, and nowhere deeper. +fn find_skill_files_in_children(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut found: Vec = entries + .flatten() + .map(|entry| entry.path().join("SKILL.md")) + .filter(|path| path.is_file()) + .collect(); + found.sort(); + out.extend(found); +} + +/// Refuse a skill whose real path leaves the directory it was discovered in. +/// +/// A symlink pointing outside would otherwise be read here and then silently +/// dropped at install time, since the copy ignores symlinks — an empty skill +/// rather than a reported one. +fn contained_in(base: &Path, skill_md: &Path) -> Result<()> { + let (Ok(base), Ok(real)) = (base.canonicalize(), skill_md.canonicalize()) else { + return Ok(()); + }; + if real.starts_with(&base) { + return Ok(()); + } + anyhow::bail!("{} resolves outside {}", skill_md.display(), base.display()) +} + /// Recursively walk a directory collecting paths to `SKILL.md` files. /// /// Directories carrying the `.symposium` marker are skipped: the marker means @@ -1084,8 +1129,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group targets serde source: PluginSource::Path(PathBuf::from("skills")), - source_label: None, - workspace_member: false, + ..Default::default() }], ..Default::default() }; @@ -1137,8 +1181,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("other-crate"), // But group targets other-crate source: PluginSource::Path(PathBuf::from("skills")), - source_label: None, - workspace_member: false, + ..Default::default() }], ..Default::default() }; @@ -1208,8 +1251,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group also targets serde source: PluginSource::Path(skill_dir.to_path_buf()), - source_label: None, - workspace_member: false, + ..Default::default() }], ..Default::default() }; @@ -1284,8 +1326,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), source: PluginSource::Path(skill_dir.to_path_buf()), - source_label: None, - workspace_member: false, + ..Default::default() }], subcommands: Default::default(), ..Default::default() @@ -1362,8 +1403,7 @@ mod tests { ], }, source: PluginSource::Path(skill_dir.to_path_buf()), - source_label: None, - workspace_member: false, + ..Default::default() }], subcommands: Default::default(), ..Default::default() @@ -1486,8 +1526,7 @@ mod tests { // A PM returns absolute skill dirs; the bare-skill group's "." // resolves to the skill's own directory. source: PluginSource::Path(skill_dir.clone()), - source_label: None, - workspace_member: false, + ..Default::default() }], subcommands: Default::default(), ..Default::default() @@ -1546,7 +1585,12 @@ mod tests { ) .unwrap(); - let skills = discover_skills(&plugin_dir.join("skills"), false, &PredicateSet::default()); + let skills = discover_skills( + &plugin_dir.join("skills"), + false, + &PredicateSet::default(), + crate::plugins::SkillDepth::Recursive, + ); assert_eq!(skills.len(), 1); let skill = skills.into_iter().next().unwrap().unwrap(); @@ -1575,7 +1619,12 @@ mod tests { ) .unwrap(); - let skills = discover_skills(root, false, &PredicateSet::default()); + let skills = discover_skills( + root, + false, + &PredicateSet::default(), + crate::plugins::SkillDepth::Recursive, + ); assert_eq!(skills.len(), 1); let skill = skills.into_iter().next().unwrap().unwrap(); @@ -1638,7 +1687,12 @@ mod tests { ) .unwrap(); - let skills = discover_skills(root, false, &PredicateSet::default()); + let skills = discover_skills( + root, + false, + &PredicateSet::default(), + crate::plugins::SkillDepth::Recursive, + ); // Should find shallow + sibling, but NOT nested (pruned by shallow) let names: Vec = skills @@ -1654,7 +1708,12 @@ mod tests { #[test] fn discover_skills_no_skills_dir() { let tmp = tempfile::tempdir().unwrap(); - let skills = discover_skills(tmp.path(), false, &PredicateSet::default()); + let skills = discover_skills( + tmp.path(), + false, + &PredicateSet::default(), + crate::plugins::SkillDepth::Recursive, + ); assert!(skills.is_empty()); } diff --git a/tests/fixtures/agent-plugin-read0/Cargo.toml b/tests/fixtures/agent-plugin-read0/Cargo.toml new file mode 100644 index 00000000..d51e62e1 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/Cargo.toml @@ -0,0 +1,11 @@ +[workspace] +members = ["member"] + +[package] +name = "read-root" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = "1.0" +dep-crate = { path = "dep-crate" } diff --git a/tests/fixtures/agent-plugin-read0/dep-crate/Cargo.toml b/tests/fixtures/agent-plugin-read0/dep-crate/Cargo.toml new file mode 100644 index 00000000..11d91561 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dep-crate/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "dep-crate" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/tests/fixtures/agent-plugin-read0/dep-crate/plugin.json b/tests/fixtures/agent-plugin-read0/dep-crate/plugin.json new file mode 100644 index 00000000..bee84391 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dep-crate/plugin.json @@ -0,0 +1 @@ +{ "name": "dep-portable", "version": "1.0.0" } diff --git a/tests/fixtures/agent-plugin-read0/dep-crate/skills/dep-guidance/SKILL.md b/tests/fixtures/agent-plugin-read0/dep-crate/skills/dep-guidance/SKILL.md new file mode 100644 index 00000000..d78993ac --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dep-crate/skills/dep-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: dep-guidance +description: Guidance from a dependency's package +--- +Body. diff --git a/tests/fixtures/agent-plugin-read0/dep-crate/src/lib.rs b/tests/fixtures/agent-plugin-read0/dep-crate/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/config.toml b/tests/fixtures/agent-plugin-read0/dot-symposium/config.toml new file mode 100644 index 00000000..dcc4ef06 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/config.toml @@ -0,0 +1,8 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true + +[plugins] +auto-enable = ["dep-crate"] diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/mcp.json b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/mcp.json new file mode 100644 index 00000000..a3f676c8 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/mcp.json @@ -0,0 +1 @@ +{ "mcpServers": {} } diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/plugin.json b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/plugin.json new file mode 100644 index 00000000..7eab41be --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/plugin.json @@ -0,0 +1,4 @@ +{ + "name": "dormant-portable", + "description": "No gate, so it waits to be used" +} diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/skills/dormant-guidance/SKILL.md b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/skills/dormant-guidance/SKILL.md new file mode 100644 index 00000000..9dc9bc12 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/dormant-portable/skills/dormant-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: dormant-guidance +description: Guidance that waits for a use entry +--- +Body. diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/plugin.json b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/plugin.json new file mode 100644 index 00000000..27728d21 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/plugin.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "portable-tools", + "version": "2.1.0", + "description": "An externally authored package", + "license": "MIT", + "extensions": { + "dev.symposium": { "depends-on": ["serde"] } + } +} diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/nested/too-deep/SKILL.md b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/nested/too-deep/SKILL.md new file mode 100644 index 00000000..96ab2baa --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/nested/too-deep/SKILL.md @@ -0,0 +1,5 @@ +--- +name: too-deep +description: Should never be discovered +--- +Body. diff --git a/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/portable-guidance/SKILL.md b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/portable-guidance/SKILL.md new file mode 100644 index 00000000..f3876d56 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/dot-symposium/plugins/portable-tools/skills/portable-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: portable-guidance +description: Guidance from a portable package +--- +Body. diff --git a/tests/fixtures/agent-plugin-read0/member/Cargo.toml b/tests/fixtures/agent-plugin-read0/member/Cargo.toml new file mode 100644 index 00000000..30dff7eb --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/member/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "member" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/tests/fixtures/agent-plugin-read0/member/plugin.json b/tests/fixtures/agent-plugin-read0/member/plugin.json new file mode 100644 index 00000000..19729a62 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/member/plugin.json @@ -0,0 +1 @@ +{ "name": "member-portable", "version": "0.3.0" } diff --git a/tests/fixtures/agent-plugin-read0/member/skills/member-guidance/SKILL.md b/tests/fixtures/agent-plugin-read0/member/skills/member-guidance/SKILL.md new file mode 100644 index 00000000..10192764 --- /dev/null +++ b/tests/fixtures/agent-plugin-read0/member/skills/member-guidance/SKILL.md @@ -0,0 +1,5 @@ +--- +name: member-guidance +description: Guidance from a workspace member package +--- +Body. diff --git a/tests/fixtures/agent-plugin-read0/member/src/lib.rs b/tests/fixtures/agent-plugin-read0/member/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/tests/fixtures/agent-plugin-read0/src/lib.rs b/tests/fixtures/agent-plugin-read0/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 9b90bc99..4b57ef7a 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2862,3 +2862,84 @@ async fn report_json_shows_skipped_skills() { .await .unwrap(); } + +// ── Externally authored agent plugin packages ──────────────────────── + +/// A `plugin.json` directory is recognized in all three positions a plugin can +/// occupy, and each position keeps its existing meaning. +#[tokio::test] +async fn agent_plugin_packages_load_in_every_position() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-read0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let skills_dir = root.join(".claude/skills"); + + // Registry entry, gated through the `dev.symposium` namespace. + find_installed_skill(&skills_dir, "portable-guidance"); + // Workspace member: membership is the gate. + find_installed_skill(&skills_dir, "member-guidance"); + // Dependency, consented to through `auto-enable`. + find_installed_skill(&skills_dir, "dep-guidance"); + + assert!( + find_installed_skills(&skills_dir, "too-deep").is_empty(), + "the format fixes skills at one level, so deeper folders are not searched" + ); + assert!( + find_installed_skills(&skills_dir, "dormant-guidance").is_empty(), + "a package with no gate waits for a `use` entry" + ); + + // Each package is compiled like any other plugin. + let compiled = root.join(".symposium/plugins"); + for name in ["portable-tools", "member-portable", "dep-portable"] { + assert!( + compiled.join(name).join("plugin.json").is_file(), + "{name} should have a compiled directory" + ); + } + assert!( + !compiled.join("dormant-portable").exists(), + "a dormant package compiles to nothing" + ); + + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// The package's identity reaches the compiled manifest, so an agent sees the +/// name and version its author declared. +#[tokio::test] +async fn a_packages_declared_identity_reaches_the_compiled_manifest() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-read0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let manifest: Value = serde_json::from_str( + &std::fs::read_to_string( + root.join(".symposium/plugins/portable-tools/plugin.json"), + ) + .expect("read compiled manifest"), + ) + .expect("parse"); + assert_eq!(manifest["name"], "portable-tools"); + assert_eq!(manifest["version"], "2.1.0"); + assert_eq!(manifest["description"], "An externally authored package"); + Ok(()) + }, + ) + .await + .unwrap(); +} From 7afbd1fdd9ebef5e9a63d61eea5ec6eb15f9a9ec Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 16:00:33 -0300 Subject: [PATCH 06/14] feat(agent-plugin): cover packages in validate, search, status, and use --- md/design/module-structure.md | 4 +- src/agent_plugin/read.rs | 3 +- src/discovery.rs | 3 + src/plugins.rs | 41 +++++- src/report.rs | 24 ++- src/search_command.rs | 21 ++- src/status_command.rs | 9 +- tests/enablement.rs | 138 +++++++++++++++++- .../partly-broken/plugin.json | 1 + .../partly-broken/skills/broken/SKILL.md | 1 + .../partly-broken/skills/good/SKILL.md | 5 + .../agent-plugin-broken0/rejected/plugin.json | 1 + 12 files changed, 237 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/agent-plugin-broken0/partly-broken/plugin.json create mode 100644 tests/fixtures/agent-plugin-broken0/partly-broken/skills/broken/SKILL.md create mode 100644 tests/fixtures/agent-plugin-broken0/partly-broken/skills/good/SKILL.md create mode 100644 tests/fixtures/agent-plugin-broken0/rejected/plugin.json diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 098e9453..ae716f9b 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -182,7 +182,9 @@ The user-facing surface over `discovery` and `[plugins]`. `use_command` records enablement. `use_plugin` first checks whether a configured registry already offers the name — registries are trust roots, so that is a no-op — with dormant plugins the exception, since `use` is exactly how they wake. It then requires the name to resolve to *something* (a workspace dependency, checked offline first, or a `PmRegistry::search` hit — which reaches crates.io via `CargoPm::search`, so a crate you don't depend on still resolves) before pushing a `UseEntry` (workspace-scoped by default, `Global` with `--global`) and saving. Both it and `remove_plugin` re-run `sync::sync` afterward, so skills install or are reaped immediately. `remove_plugin` matches on scope and errors when nothing matched rather than silently succeeding. -`search_command` unions two arms: plugin names in the loaded `PluginRegistry` (bare skills included, since they are now plugins) and `PmRegistry::search` across every instance (which matches registry entry subpaths, e.g. a skill's directory name). A PM without a searchable registry returns an empty list and a failing instance is skipped, so an offline registry degrades the results instead of failing the command. Hits are grouped by originating instance for display; the `SearchMatch` report event carries the origin for the JSON form. +All four surface a plugin's `kind` where they know it, so a user can tell an externally authored package from a `SYMPOSIUM.toml` one: `plugin validate` labels the entry `agent plugin`, and `search` and `status` annotate the line. A hit found by asking a package manager has not been loaded, so it carries no kind. + +`search_command` reports a registry plugin's own version and description from its manifest, with dormancy as a separate flag rather than borrowing the description field. It unions two arms: plugin names in the loaded `PluginRegistry` (bare skills included, since they are now plugins) and `PmRegistry::search` across every instance (which matches registry entry subpaths, e.g. a skill's directory name). A PM without a searchable registry returns an empty list and a failing instance is skipped, so an offline registry degrades the results instead of failing the command. Hits are grouped by originating instance for display; the `SearchMatch` report event carries the origin for the JSON form. `status_command` renders the enablement report. `workspace_status` walks the registry plugins (root: workspace membership, `use`, or the registry name; state from `ParsedPlugin::applies` plus the `requires_use` gate) — this is where every recommendations-registry plugin appears — then every `Discovery` bucket of dependency-embedded plugins (`Used` / `AutoEnabled` → active with that root, `Candidate` → awaiting consent, `Declined`), then the `use`d crates that aren't dependency offers (from `enabled_dependencies`, e.g. `use`-ing a crate the workspace doesn't depend on — otherwise invisible to discovery), then any `[plugins] disable` name discovery never saw. The four `StatusState` values — `Active`, `Dormant`, `Candidate`, `Declined` — are the report's vocabulary. diff --git a/src/agent_plugin/read.rs b/src/agent_plugin/read.rs index 5c800e26..3d5509df 100644 --- a/src/agent_plugin/read.rs +++ b/src/agent_plugin/read.rs @@ -15,7 +15,7 @@ use std::path::Path; use anyhow::{Context, Result}; use super::manifest::IncomingManifest; -use crate::plugins::{Plugin, PluginSource, SkillDepth, SkillGroup}; +use crate::plugins::{Plugin, PluginKind, PluginSource, SkillDepth, SkillGroup}; use crate::report::ReportEvent; /// The manifest that marks a directory as an agent plugin package. @@ -66,6 +66,7 @@ pub fn load(dir: &Path, gated_by_position: bool) -> Result { Ok(Plugin { name: manifest.name, + kind: PluginKind::AgentPlugin, version: manifest.version, description: manifest.description, predicates, diff --git a/src/discovery.rs b/src/discovery.rs index 9296324f..59e51f7c 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -80,6 +80,8 @@ pub struct DiscoveredPlugin { pub description: Option, /// How the `[plugins]` config decided this offer. pub enablement: Enablement, + /// Which manifest defined the offered plugin, for display. + pub kind: Option, } impl DiscoveredPlugin { @@ -138,6 +140,7 @@ pub async fn discover(sym: &Symposium, deps: &Arc) -> Discovery { recommends: name, description, enablement, + kind: plugin.plugin.kind.label().map(str::to_string), }; match enablement { Enablement::Used => discovery.active.push(discovered), diff --git a/src/plugins.rs b/src/plugins.rs index c32c63da..297f6ef3 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -167,6 +167,35 @@ impl serde::Serialize for PluginSource { } } +/// Which manifest format defined a plugin. +/// +/// Everything downstream treats the two alike; the distinction exists so the +/// user-facing commands can say where a plugin came from. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum PluginKind { + /// A `SYMPOSIUM.toml` manifest, or a convention symposium infers. + #[default] + Symposium, + /// An externally authored [`plugin.json`](crate::agent_plugin::read) package. + AgentPlugin, +} + +fn is_symposium_kind(kind: &PluginKind) -> bool { + *kind == PluginKind::Symposium +} + +impl PluginKind { + /// How to annotate this kind in command output, or `None` for the ordinary + /// case that needs no annotation. + pub fn label(&self) -> Option<&'static str> { + match self { + PluginKind::Symposium => None, + PluginKind::AgentPlugin => Some("agent plugin"), + } + } +} + /// How deep a skill group's directory is searched. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)] pub enum SkillDepth { @@ -471,6 +500,8 @@ impl ParsedPlugin { #[derive(Debug, Clone, Default, Serialize)] pub struct Plugin { pub name: String, + #[serde(skip_serializing_if = "is_symposium_kind")] + pub kind: PluginKind, #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -1390,6 +1421,7 @@ fn load_standalone_skill_plugin( let mut plugin = Plugin { name: name.clone(), + kind: PluginKind::Symposium, version: None, description: None, predicates, @@ -1759,6 +1791,7 @@ pub struct ValidationResult { #[derive(Debug)] pub enum ValidationKind { Plugin, + AgentPlugin, Skill, } @@ -1766,6 +1799,7 @@ impl std::fmt::Display for ValidationKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ValidationKind::Plugin => write!(f, "plugin"), + ValidationKind::AgentPlugin => write!(f, "agent plugin"), ValidationKind::Skill => write!(f, "skill"), } } @@ -1786,6 +1820,10 @@ pub fn validate_source_dir(dir: &Path) -> Result> { Ok(parsed) => (parsed.canonical.name.clone(), Some(parsed), Ok(())), Err(e) => ("".to_string(), None, Err(e)), }; + let kind = match plugin.as_ref().map(|p| p.plugin.kind) { + Some(PluginKind::AgentPlugin) => ValidationKind::AgentPlugin, + _ => ValidationKind::Plugin, + }; let mut children = Vec::new(); @@ -1844,7 +1882,7 @@ pub fn validate_source_dir(dir: &Path) -> Result> { }); results.push(ValidationResult { id, - kind: ValidationKind::Plugin, + kind, result, warning, children, @@ -2182,6 +2220,7 @@ fn validate_manifest( Ok(Plugin { name, + kind: PluginKind::Symposium, version: manifest.version.take(), description: manifest.description.take(), predicates, diff --git a/src/report.rs b/src/report.rs index 254a0371..ffdd0280 100644 --- a/src/report.rs +++ b/src/report.rs @@ -151,6 +151,12 @@ pub enum ReportEvent { version: Option, #[serde(skip_serializing_if = "Option::is_none")] description: Option, + /// Which manifest defined it, when the plugin has been loaded. + #[serde(skip_serializing_if = "Option::is_none")] + plugin_kind: Option, + /// Loaded but inactive until a `use` entry names it. + #[serde(skip_serializing_if = "std::ops::Not::not")] + dormant: bool, }, /// A `[plugins] use` entry was recorded by `cargo agents use`. @@ -164,6 +170,9 @@ pub enum ReportEvent { name: String, #[serde(skip_serializing_if = "Option::is_none")] version: Option, + /// Which manifest defined it, when the plugin has been loaded. + #[serde(skip_serializing_if = "Option::is_none")] + plugin_kind: Option, /// Why the entry is in the state it is: its enablement root, or the /// reason it will not load. root: String, @@ -341,14 +350,22 @@ impl ReportEvent { name, version, description, + plugin_kind, + dormant, } => { let mut line = format!(" {name}"); if let Some(v) = version { line.push_str(&format!(" {v}")); } + if let Some(k) = plugin_kind { + line.push_str(&format!(" ({k})")); + } if let Some(d) = description { line.push_str(&format!("\n {d}")); } + if *dormant { + line.push_str("\n dormant — enable with `cargo agents use`"); + } line } Self::PluginEnabled { name, global } => { @@ -370,6 +387,7 @@ impl ReportEvent { Self::PluginStatus { name, version, + plugin_kind, root, state, } => { @@ -383,7 +401,11 @@ impl ReportEvent { .as_deref() .map(|v| format!(" {v}")) .unwrap_or_default(); - format!("{marker} {name}{version} — {root}") + let kind = plugin_kind + .as_deref() + .map(|k| format!(" ({k})")) + .unwrap_or_default(); + format!("{marker} {name}{version}{kind} — {root}") } Self::ProviderListed { diff --git a/src/search_command.rs b/src/search_command.rs index ae022d42..d9d103a3 100644 --- a/src/search_command.rs +++ b/src/search_command.rs @@ -29,6 +29,12 @@ pub struct SearchMatch { pub name: String, pub version: Option, pub description: Option, + /// Which manifest defined the plugin, when that is known. A hit found by + /// searching a package manager has not been loaded, so there is nothing to + /// report yet. + pub kind: Option, + /// Set when the plugin is loaded but inactive until a `use` entry names it. + pub dormant: bool, } /// Case-insensitive substring match — the same looseness `cargo search` has. @@ -46,11 +52,10 @@ pub async fn find_matches(sym: &Symposium, query: &str) -> Vec { matches.push(SearchMatch { origin: parsed.canonical.pm.clone(), name: parsed.plugin.name.clone(), - version: None, - description: parsed - .plugin - .requires_use - .then(|| "dormant — enable with `cargo agents use`".to_string()), + version: parsed.plugin.version.clone(), + description: parsed.plugin.description.clone(), + kind: parsed.plugin.kind.label().map(str::to_string), + dormant: parsed.plugin.requires_use, }); } } @@ -61,6 +66,10 @@ pub async fn find_matches(sym: &Symposium, query: &str) -> Vec { name: info.id.name.clone(), version: Some(info.id.version.clone()), description: info.description, + // Found by asking a package manager, so nothing has been loaded and + // there is no manifest to report yet. + kind: None, + dormant: false, }); } @@ -99,6 +108,8 @@ pub async fn search(sym: &Symposium, query: &str) -> Result<()> { name: m.name.clone(), version: m.version.clone(), description: m.description.clone(), + plugin_kind: m.kind.clone(), + dormant: m.dormant, }, ); } diff --git a/src/status_command.rs b/src/status_command.rs index 9ceb66f0..efcc5d00 100644 --- a/src/status_command.rs +++ b/src/status_command.rs @@ -65,6 +65,8 @@ pub struct StatusEntry { /// the plugin will not load. pub root: String, pub state: StatusState, + /// Which manifest defined it, for a plugin that has been loaded. + pub kind: Option, } /// Compute the enablement report for the workspace `deps` points at. @@ -97,7 +99,8 @@ pub async fn workspace_status( let active = parsed.applies(&mut ctx); entries.push(StatusEntry { name: parsed.plugin.name.clone(), - version: None, + version: parsed.plugin.version.clone(), + kind: parsed.plugin.kind.label().map(str::to_string), root: if active || !parsed.plugin.requires_use { root } else { @@ -155,6 +158,7 @@ pub async fn workspace_status( entries.push(StatusEntry { name, version: None, + kind: None, root: "`[plugins] use` (not a dependency)".to_string(), state: StatusState::Active, }); @@ -169,6 +173,7 @@ pub async fn workspace_status( entries.push(StatusEntry { name: name.clone(), version: None, + kind: None, root: "declined (`[plugins] disable`)".to_string(), state: StatusState::Declined, }); @@ -198,6 +203,7 @@ fn entry_for(found: &DiscoveredPlugin) -> StatusEntry { StatusEntry { name: found.name().to_string(), version: Some(found.id.version.clone()), + kind: found.kind.clone(), root, state, } @@ -220,6 +226,7 @@ pub async fn status(sym: &Symposium, cwd: &Path) -> Result<()> { report = %ReportEvent::PluginStatus { name: entry.name, version: entry.version, + plugin_kind: entry.kind, root: entry.root, state: entry.state.as_str().to_string(), }, diff --git a/tests/enablement.rs b/tests/enablement.rs index a4dbcf47..5ececacb 100644 --- a/tests/enablement.rs +++ b/tests/enablement.rs @@ -444,10 +444,8 @@ async fn status_reports_dormant_registry_plugin() { .expect("search finds the manifest plugin"); assert_eq!(hit.origin, "user-plugins"); assert!( - hit.description - .as_deref() - .is_some_and(|d| d.contains("dormant")), - "{hit:?}" + hit.dormant, + "dormancy is its own flag, so a description stays the plugin's own: {hit:?}" ); ctx.symposium(&["use", "gateless-plugin"]).await?; @@ -600,3 +598,135 @@ async fn session_start_hints_pending_candidates() { .await .unwrap(); } + +// ── Command coverage for agent plugin packages ─────────────────────── + +/// `plugin validate` names the manifest that defined each entry, and contains a +/// failure at the level where it takes effect: a rejected package, or a skipped +/// skill inside a package that otherwise loads. +#[test] +fn validate_reports_agent_plugin_packages_per_level() { + let dir = Path::new("tests/fixtures/agent-plugin-broken0"); + let results = symposium::plugins::validate_source_dir(dir).expect("validate"); + + let rejected = results + .iter() + .find(|r| r.result.is_err()) + .expect("the package with an unusable name is rejected"); + assert!( + format!("{:#}", rejected.result.as_ref().unwrap_err()).contains("not 1 to 64 characters"), + "{:#}", + rejected.result.as_ref().unwrap_err() + ); + + let partly = results + .iter() + .find(|r| r.id == "partly-broken") + .expect("the other package still loads"); + assert!(partly.result.is_ok(), "a sibling's failure is contained"); + assert_eq!( + partly.kind.to_string(), + "agent plugin", + "the output says which manifest defined it" + ); + assert_eq!( + partly.children.iter().filter(|c| c.result.is_ok()).count(), + 1, + "the good skill loads" + ); + assert_eq!( + partly.children.iter().filter(|c| c.result.is_err()).count(), + 1, + "and the broken one is reported as a skill, not as the package" + ); +} + +/// `search` and `status` describe a package in the vocabulary they already use, +/// annotated with the manifest it came from. +#[tokio::test] +async fn search_and_status_describe_agent_plugin_packages() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-read0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + + let hit = symposium::search_command::find_matches(&ctx.sym, "portable-tools") + .await + .into_iter() + .find(|m| m.name == "portable-tools") + .expect("search finds the package"); + assert_eq!(hit.kind.as_deref(), Some("agent plugin")); + assert_eq!( + hit.version.as_deref(), + Some("2.1.0"), + "the version comes from the package's own manifest" + ); + assert_eq!( + hit.description.as_deref(), + Some("An externally authored package") + ); + assert!(!hit.dormant, "it declares a `dev.symposium` gate"); + + let dormant = symposium::search_command::find_matches(&ctx.sym, "dormant-portable") + .await + .into_iter() + .find(|m| m.name == "dormant-portable") + .expect("search finds the gateless package"); + assert!(dormant.dormant, "no gate, so it waits to be used"); + assert_eq!( + dormant.description.as_deref(), + Some("No gate, so it waits to be used"), + "dormancy is reported separately, so the description stays the author's" + ); + + let workspace_root = ctx.workspace_root.clone().unwrap(); + let deps = ctx.sym.workspace_deps(&workspace_root); + let entries = symposium::status_command::workspace_status(&ctx.sym, &deps).await?; + let entry = entries + .iter() + .find(|e| e.name == "portable-tools") + .expect("status lists the package"); + assert_eq!(entry.kind.as_deref(), Some("agent plugin")); + assert_eq!(entry.state, StatusState::Active); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `use` wakes a dormant package and installs it the same way any other plugin +/// is installed; `use --remove` takes it back out. +#[tokio::test] +async fn use_and_remove_a_dormant_agent_plugin_package() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-read0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().unwrap(); + let compiled = root.join(".symposium/plugins/dormant-portable"); + assert!(!compiled.exists(), "dormant, so nothing is installed"); + + ctx.symposium(&["use", "dormant-portable"]).await?; + assert!( + compiled.join("plugin.json").is_file(), + "`use` wakes it and the same install path runs" + ); + assert!(compiled.join("skills/dormant-guidance/SKILL.md").is_file()); + + ctx.symposium(&["use", "--remove", "dormant-portable"]) + .await?; + assert!( + !compiled.exists(), + "`remove` disables it and the next sync reaps the directory" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} diff --git a/tests/fixtures/agent-plugin-broken0/partly-broken/plugin.json b/tests/fixtures/agent-plugin-broken0/partly-broken/plugin.json new file mode 100644 index 00000000..262c6420 --- /dev/null +++ b/tests/fixtures/agent-plugin-broken0/partly-broken/plugin.json @@ -0,0 +1 @@ +{ "name": "partly-broken", "version": "0.1.0" } diff --git a/tests/fixtures/agent-plugin-broken0/partly-broken/skills/broken/SKILL.md b/tests/fixtures/agent-plugin-broken0/partly-broken/skills/broken/SKILL.md new file mode 100644 index 00000000..3a1bf7aa --- /dev/null +++ b/tests/fixtures/agent-plugin-broken0/partly-broken/skills/broken/SKILL.md @@ -0,0 +1 @@ +no frontmatter at all diff --git a/tests/fixtures/agent-plugin-broken0/partly-broken/skills/good/SKILL.md b/tests/fixtures/agent-plugin-broken0/partly-broken/skills/good/SKILL.md new file mode 100644 index 00000000..968e921e --- /dev/null +++ b/tests/fixtures/agent-plugin-broken0/partly-broken/skills/good/SKILL.md @@ -0,0 +1,5 @@ +--- +name: good +description: fine +--- +Body. diff --git a/tests/fixtures/agent-plugin-broken0/rejected/plugin.json b/tests/fixtures/agent-plugin-broken0/rejected/plugin.json new file mode 100644 index 00000000..9aed0a22 --- /dev/null +++ b/tests/fixtures/agent-plugin-broken0/rejected/plugin.json @@ -0,0 +1 @@ +{ "name": "Not_A_Valid_Name" } From ab9726b6bf9df77fe3915d7acaa2adc3b7aef703 Mon Sep 17 00:00:00 2001 From: fluzko Date: Fri, 21 Aug 2026 16:09:10 -0300 Subject: [PATCH 07/14] feat(sync): run the global half without a Rust workspace --- md/design/important-flows.md | 2 + md/design/module-structure.md | 2 + src/agent_plugin/mod.rs | 13 +-- src/agent_plugin/tests.rs | 11 ++- src/config.rs | 11 +++ src/sync.rs | 173 ++++++++++++++++++++++++---------- tests/init_sync.rs | 43 +++++++++ 7 files changed, 195 insertions(+), 60 deletions(-) diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 3c300c6c..62a3d4aa 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -33,6 +33,8 @@ The key code paths are in `discovery.rs`, `config.rs` (`PluginsConfig`, `UseEntr Every `cargo agents sync` compiles the plugins that apply into the directory unit agents consume, then hands each directory to the agents that can take it. The step runs after skills are resolved, so it never re-evaluates a gate. +Run outside a Rust workspace, only the global half happens: a globally-enabled plugin with a workspace-independent gate is compiled and delivered, while everything project-scoped is skipped. + 1. `agent_plugin::compile` groups the applicable skills by their contributing plugin's `canonical` id and builds one `CompiledPlugin` each: a manifest name (slugged into the format's grammar), an optional version (the manifest's, else a crate plugin's resolved version — a registry placeholder `*` is not a version), the plugin's description, and one skill entry per distinct origin. 2. Directory names are disambiguated across plugins, and skill directory names within each plugin, using the same origin-hash suffix rule that already governs skill installs. 3. `Scope::of` sends each compiled plugin to `/.symposium/plugins/` or `/installed/`. Global requires both a `use --global` entry naming the plugin *and* every gate in its chain (plugin, groups, contributed skills) to hold workspace-independently — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for why the second half is a correctness requirement and not a preference. A scope no configured agent can take is not compiled at all. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index ae716f9b..691017ea 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -28,6 +28,8 @@ Implements `cargo agents sync`. Scans workspace dependencies, finds applicable s One entry point, `sync(sym, deps, update)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. +**A workspace is optional.** Without one there is nothing project-scoped to install — no per-skill directories, no project staging root, no project hook registration — but globally-enabled plugins still apply, so the global half of the sync runs rather than the command refusing outright. `ProjectPaths` is the `Option` that carries the project root, its owned `.symposium/` directory, and its staging root together; every project-only step is guarded on it. `status` and a non-`--global` `use` still require a workspace, since each is about one. + `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`. ### `plugins.rs` — plugin registry diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index e8bc93c3..6b57d4f0 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -35,16 +35,13 @@ const MARKETPLACE_NAME: &str = "symposium"; /// A project root needs a name of its own because marketplace *registration* is /// user-level even for a project-scoped plugin (verified against Claude Code), so /// two projects both registering `symposium` would overwrite each other's path. -pub fn marketplace_name(scope: Scope, project_root: &Path) -> String { - match scope { - Scope::Global => MARKETPLACE_NAME.to_string(), - Scope::Project => { - let scoped = format!( - "{MARKETPLACE_NAME}-{}", - crate::pm::workspace_dir_name(project_root) - ); +pub fn marketplace_name(scope: Scope, project_root: Option<&Path>) -> String { + match (scope, project_root) { + (Scope::Project, Some(root)) => { + let scoped = format!("{MARKETPLACE_NAME}-{}", crate::pm::workspace_dir_name(root)); manifest::slug(&scoped).unwrap_or_else(|| MARKETPLACE_NAME.to_string()) } + _ => MARKETPLACE_NAME.to_string(), } } diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index caade6a6..6a203af1 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -507,11 +507,16 @@ fn the_marketplace_index_lists_each_plugin_and_is_removed_when_empty() { #[test] fn a_project_marketplace_is_named_per_workspace() { - let global = marketplace_name(Scope::Global, Path::new("/work/reporter")); + let global = marketplace_name(Scope::Global, Some(Path::new("/work/reporter"))); assert_eq!(global, "symposium"); + assert_eq!( + marketplace_name(Scope::Global, None), + "symposium", + "the global root needs no project to name it" + ); - let one = marketplace_name(Scope::Project, Path::new("/work/reporter")); - let two = marketplace_name(Scope::Project, Path::new("/elsewhere/reporter")); + let one = marketplace_name(Scope::Project, Some(Path::new("/work/reporter"))); + let two = marketplace_name(Scope::Project, Some(Path::new("/elsewhere/reporter"))); assert!(one.starts_with("symposium-reporter-"), "{one}"); assert_ne!( one, two, diff --git a/src/config.rs b/src/config.rs index 3927712b..fe137ed0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,17 @@ impl PluginsConfig { .collect() } + /// The names enabled by `use` entries that apply in every workspace. Stands + /// in for [`used_names_in`](Self::used_names_in) when there is no workspace + /// to scope against. + pub fn global_used_names(&self) -> Vec<&str> { + self.used + .iter() + .filter(|entry| matches!(entry, UseEntry::Global(_))) + .map(UseEntry::name) + .collect() + } + /// Is `name` enabled by a `use` entry that applies in every workspace? pub fn is_used_globally(&self, name: &str) -> bool { self.used.iter().any(|entry| match entry { diff --git a/src/sync.rs b/src/sync.rs index 3b2974e2..bb6d6269 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -290,6 +290,40 @@ async fn resolve_custom_predicate_entries( entries } +/// The project-scoped paths sync writes into, when there is a workspace at all. +struct ProjectPaths { + root: PathBuf, + /// The directory symposium owns outright, carrying the one `.gitignore`. + owned: PathBuf, + /// Staging root for project-scoped compiled plugins. + staging: PathBuf, +} + +impl ProjectPaths { + fn under(root: &Path) -> Self { + let owned = root.join(crate::agent_plugin::PROJECT_OWNED_DIR); + Self { + root: root.to_path_buf(), + staging: owned.join(crate::agent_plugin::PROJECT_STAGING_SUBDIR), + owned, + } + } +} + +/// The staging roots to consider, paired with the scope each one holds. The +/// project root drops out when there is no workspace. +fn staging_roots<'a>( + project: &'a Option, + global: &'a Path, +) -> Vec<(Scope, &'a Path)> { + let mut roots = Vec::new(); + if let Some(p) = project { + roots.push((Scope::Project, p.staging.as_path())); + } + roots.push((Scope::Global, global)); + roots +} + /// One skill selected for installation, with the plugin it came from. struct PendingSkill<'a> { name: String, @@ -303,12 +337,12 @@ struct PendingSkill<'a> { /// clean up stale installations. pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLevel) -> Result<()> { let out = &Output::quiet(); - let loaded = deps - .load() - .ok_or_else(|| anyhow::anyhow!("not in a Rust workspace"))?; - let project_root = loaded.root.clone(); - let workspace: Vec<_> = loaded.crates.clone(); - let loaded = loaded.clone(); + // A workspace is optional. Without one there is nothing project-scoped to + // install, but globally-enabled plugins still apply, so the global half of + // the sync runs regardless. + let loaded = deps.load().cloned(); + let project = loaded.as_ref().map(|l| ProjectPaths::under(&l.root)); + let workspace_deps_count = loaded.as_ref().map_or(0, |l| l.crates.len()); // The debounce keeps the per-event hook path cheap. A caller that asked for // an update — an explicit `sync`, or the `SessionStart` catch-up pass — wants // the comparison done, so a directory changed since the last sync is @@ -317,10 +351,13 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve UpdateLevel::None => Duration::from_secs(sym.config.sync_debounce_secs), _ => Duration::ZERO, }; - tracing::debug!(root = %project_root.display(), "resolved workspace root"); + match &project { + Some(p) => tracing::debug!(root = %p.root.display(), "resolved workspace root"), + None => tracing::debug!("no workspace; syncing globally-enabled plugins only"), + } // Load plugin registry (registry sources + workspace plugins) - let registry = plugins::load_registry_with_workspace(sym, Some(&loaded)).await; + let registry = plugins::load_registry_with_workspace(sym, loaded.as_deref()).await; for warning in ®istry.warnings { tracing::info!( @@ -332,7 +369,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve tracing::info!( report = %crate::report::ReportEvent::Info { - message: format!("scanning {} workspace dependencies", workspace.len()), + message: format!("scanning {workspace_deps_count} workspace dependencies"), }, ); @@ -344,20 +381,35 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // custom predicate results survive across sync runs; results are persisted // at the end of this evaluation pass. let dep_ids = crate::pm::workspace_dep_ids(sym, deps).await; - let used_names = sym.config.plugins.used_names_in(&project_root); - let predicate_cache_path = - crate::predicate_cache::PredicateCache::path_for_workspace(sym.cache_dir(), &project_root); + let used_names = match &project { + Some(p) => sym.config.plugins.used_names_in(&p.root), + None => sym.config.plugins.global_used_names(), + }; + // The predicate cache is keyed on a workspace, so there is nothing to cache + // against without one. + let predicate_cache_path = project.as_ref().map(|p| { + crate::predicate_cache::PredicateCache::path_for_workspace(sym.cache_dir(), &p.root) + }); let mut ctx = crate::predicate::PredicateContext::with_custom_predicates(&dep_ids, custom_entries) - .with_used_names(&used_names) - .with_disk_cache(&predicate_cache_path); + .with_used_names(&used_names); + if let Some(path) = &predicate_cache_path { + ctx = ctx.with_disk_cache(path); + } // The active plugin set: registry plugins plus the crate-sourced plugins // reached through `[[plugins]]` chained references and dependency // enablement. Every facet resolves over this one set, so a crate plugin's // skills and MCP servers install exactly like a registry plugin's. let pms = sym.package_managers(deps); - let active = plugins::active_plugins(sym, ®istry, &pms, Some(&project_root), &mut ctx).await; + let active = plugins::active_plugins( + sym, + ®istry, + &pms, + project.as_ref().map(|p| p.root.as_path()), + &mut ctx, + ) + .await; // Find all applicable skills. let applicable = skills::collect_skills(sym, &active, &mut ctx, update).await; @@ -409,32 +461,33 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve .any(|agent| agent.accepts_plugin_scope(plugin.scope)) }) .collect(); - let owned_dir = project_root.join(crate::agent_plugin::PROJECT_OWNED_DIR); - let project_staging = owned_dir.join(crate::agent_plugin::PROJECT_STAGING_SUBDIR); let global_staging = sym .config_dir() .join(crate::agent_plugin::GLOBAL_STAGING_DIR); let mut staged_project: BTreeSet = BTreeSet::new(); let mut staged_global: BTreeSet = BTreeSet::new(); - if compiled.iter().any(|p| p.scope == Scope::Project) - && let Err(e) = ignore_owned_dir(&owned_dir, &project_root) + if let Some(p) = &project + && compiled.iter().any(|c| c.scope == Scope::Project) + && let Err(e) = ignore_owned_dir(&p.owned, &p.root) { tracing::info!( report = %crate::report::ReportEvent::Warning { - message: format!("failed to prepare {}: {e}", display_path(&owned_dir)), + message: format!("failed to prepare {}: {e}", display_path(&p.owned)), }, ); } for plugin in &compiled { - let (root, boundary, staged) = match plugin.scope { - Scope::Project => ( - &project_staging, - project_root.as_path(), - &mut staged_project, - ), - Scope::Global => (&global_staging, sym.config_dir(), &mut staged_global), + let target = match (plugin.scope, &project) { + (Scope::Project, Some(p)) => Some((&p.staging, p.root.as_path(), &mut staged_project)), + // Nowhere to put a project-scoped plugin without a project. Its + // skills still reach the agents that read them individually. + (Scope::Project, None) => None, + (Scope::Global, _) => Some((&global_staging, sym.config_dir(), &mut staged_global)), + }; + let Some((root, boundary, staged)) = target else { + continue; }; match crate::agent_plugin::write(plugin, root, boundary, debounce) { Ok(dest) => { @@ -458,19 +511,21 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // Reaping the global root from a project sync is only sound because // `Scope::of` keeps the global set a function of user config alone. - crate::agent_plugin::reap(&project_staging, &staged_project); + if let Some(p) = &project { + crate::agent_plugin::reap(&p.staging, &staged_project); + } crate::agent_plugin::reap(&global_staging, &staged_global); - for (scope, root) in [ - (Scope::Project, &project_staging), - (Scope::Global, &global_staging), - ] { + for (scope, root) in staging_roots(&project, &global_staging) { let in_root: Vec<&crate::agent_plugin::CompiledPlugin> = compiled.iter().filter(|p| p.scope == scope).collect(); if in_root.is_empty() && !root.exists() { continue; } - let name = crate::agent_plugin::marketplace_name(scope, &project_root); + let name = crate::agent_plugin::marketplace_name( + scope, + project.as_ref().map(|p| p.root.as_path()), + ); if let Err(e) = crate::agent_plugin::write_marketplace(root, &name, &in_root) { tracing::info!( report = %crate::report::ReportEvent::Warning { @@ -487,9 +542,11 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve mcp_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); } } - if let Err(e) = ctx.persist_disk_cache(&predicate_cache_path) { + if let Some(path) = &predicate_cache_path + && let Err(e) = ctx.persist_disk_cache(path) + { tracing::warn!( - path = %predicate_cache_path.display(), + path = %path.display(), error = %e, "failed to persist predicate cache" ); @@ -509,7 +566,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let agent_names: Vec = sym.config.agents.iter().map(|a| a.name.clone()).collect(); tracing::info!( - workspace_deps = workspace.len(), + workspace_deps = workspace_deps_count, agents = agent_names.len(), skills = to_install.len(), "sync started" @@ -539,10 +596,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // which plugins that covers so their skills are not also installed // individually. let mut delivered: BTreeSet = BTreeSet::new(); - for (scope, root) in [ - (Scope::Project, &project_staging), - (Scope::Global, &global_staging), - ] { + for (scope, root) in staging_roots(&project, &global_staging) { let in_scope: Vec<&crate::agent_plugin::CompiledPlugin> = compiled .iter() .filter(|p| p.scope == scope && agent.accepts_plugin_scope(scope)) @@ -550,14 +604,22 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve if in_scope.is_empty() && !root.exists() { continue; } - let marketplace = crate::agent_plugin::marketplace_name(scope, &project_root); + let marketplace = crate::agent_plugin::marketplace_name( + scope, + project.as_ref().map(|p| p.root.as_path()), + ); let registration = crate::agents::Registration { marketplace: &marketplace, root, plugins: &in_scope, scope, }; - match agent.install_plugins(®istration, sym.home_dir(), &project_root, debounce) { + // Only a project-scoped registration needs the project path, and + // that scope is unreachable without one. + let enable_in = project + .as_ref() + .map_or(sym.home_dir(), |p| p.root.as_path()); + match agent.install_plugins(®istration, sym.home_dir(), enable_in, debounce) { Ok(copies) => { agent_copies .entry(agent) @@ -574,6 +636,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve copies .iter() .find(|c| c.ends_with(&plugin.dir_name)) + .map(PathBuf::as_path) .unwrap_or(root) ), }, @@ -588,9 +651,11 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } } - let hook_root = match sym.config.hook_scope { - crate::config::HookScope::Global => sym.home_dir().to_path_buf(), - crate::config::HookScope::Project => project_root.clone(), + let hook_root = match (sym.config.hook_scope, &project) { + (crate::config::HookScope::Project, Some(p)) => p.root.clone(), + // Project hook scope has nowhere to write without a project, so the + // user-level registration stands in. + _ => sym.home_dir().to_path_buf(), }; // Register hooks and MCP servers @@ -601,6 +666,13 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve .register_global_mcp_servers(&hook_root, &mcp_servers, out) .context("failed to register MCP servers")?; + // Individually-installed skills go under the project. Without one there + // is nowhere to put them, and the compiled directories above are the + // whole of what a no-workspace sync delivers. + let Some(project) = &project else { + continue; + }; + for pending in &to_install { let PendingSkill { name: skill_name, @@ -632,7 +704,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // (a workspace `.agents/skills/` skill, on an agent that reads // that same directory) — it is in place as user content, not // something to copy. - let plain_dir = agent.project_skill_dir(&project_root, skill_name); + let plain_dir = agent.project_skill_dir(&project.root, skill_name); let in_place = match (source_dir.canonicalize(), plain_dir.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false, @@ -655,7 +727,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } else { format!("{skill_name}-{}", origin_hash) }; - let dest_dir = agent.project_skill_dir(&project_root, &dir_name); + let dest_dir = agent.project_skill_dir(&project.root, &dir_name); // If the dest exists but is user-managed, skip it. if dest_dir.exists() && !has_symposium_marker(&dest_dir) { @@ -673,7 +745,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve match sync_managed_dir( source_dir, &dest_dir, - &project_root, + &project.root, debounce, Marking::MarkerAndGitignore, ) { @@ -709,7 +781,10 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // and remove subdirs containing the marker that we didn't just install. let mut scanned: BTreeSet = BTreeSet::new(); for &agent in Agent::all() { - let parent = skills_parent_dir(agent, &project_root); + let Some(project) = &project else { + break; + }; + let parent = skills_parent_dir(agent, &project.root); if !scanned.insert(parent.clone()) { continue; } diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 4b57ef7a..9790ba74 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2943,3 +2943,46 @@ async fn a_packages_declared_identity_reaches_the_compiled_manifest() { .await .unwrap(); } + +/// Outside a Rust workspace there is nothing project-scoped to install, but a +/// globally-enabled plugin still applies, so sync does the global half of its +/// work instead of refusing to run. +#[tokio::test] +async fn sync_outside_a_workspace_installs_the_global_plugins() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0"], + async |mut ctx| { + assert!( + ctx.workspace_root.is_none(), + "this fixture carries no Cargo.toml, which is the case under test" + ); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let global = ctx.sym.config_dir().join("installed/global-tools"); + assert!( + global.join("skills/global-guidance/SKILL.md").is_file(), + "a `use --global` plugin with a workspace-independent gate still installs" + ); + assert!( + ctx.sym + .config_dir() + .join("installed/.claude-plugin/marketplace.json") + .is_file(), + "and the global root is still indexed" + ); + assert!( + !ctx.sym + .config_dir() + .join("installed/project-tools") + .exists(), + "a dependency-gated plugin has no dependencies to match here" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} From 0e459882699898771a856b3d5fa6f9e7eb098b09 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 09:21:43 -0300 Subject: [PATCH 08/14] fix(agent-plugin): record installed plugins with copilot so it loads them --- md/design/module-structure.md | 4 +- src/agents/plugin_install.rs | 85 ++++++++++++++++++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 691017ea..25f4989f 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -96,10 +96,12 @@ Two mechanisms, and which one applies is a property of the agent. Each row below |---|---|---| | Claude Code | `extraKnownMarketplaces` in user settings, an entry in `~/.claude/plugins/known_marketplaces.json`, and `enabledPlugins` in the project's `.claude/settings.json` (project scope) or user settings (global) | **not copied** — resolved from the registered `installLocation` | | Codex CLI | `[marketplaces.]` and `[plugins."@"] enabled` in `config.toml` | copied to `plugins/cache////` | -| Copilot CLI | `extraKnownMarketplaces` and `enabledPlugins` in `~/.copilot/settings.json` | copied to `installed-plugins///` | +| Copilot CLI | `extraKnownMarketplaces` and `enabledPlugins` in `~/.copilot/settings.json`, then `copilot plugin install` for the record it keeps itself | copied to `installed-plugins///` | | Gemini CLI | none at all | copied to `~/.gemini/extensions//` | | Kiro, OpenCode, Goose | none | no plugin unit; skills keep arriving individually | +Copilot is the one agent where symposium drives the CLI rather than writing every file. It treats a plugin as installed only once it appears in its machine-managed `~/.copilot/config.json`, and that record carries a `source_sha` Copilot computes itself; guessing it would couple us to an internal we cannot verify. So the settings entries and the copy go in as usual, and then `copilot plugin install` is run for any plugin not already recorded — it needs no terminal, and the marketplace registration it resolves against is in place by then. This was found by asking the running agent: with the settings entries and the copy present but no such record, Copilot reported the skill as absent. + Claude Code needs both of its records: with `known_marketplaces.json` missing the plugin does not load, and Claude regenerates it from settings only in time for the *next* session. Its `installed_plugins.json` record and version-keyed cache copy are **not** required — deleting them leaves the plugin working. `accepts_plugin_scope` is where the project-scope asymmetry lives: only Claude Code can bound a plugin to one project. The other three store plugins per user with no way to scope them, so a project-scoped plugin reaches them through the per-skill path instead. A skill is installed individually only for agents that did *not* receive its plugin, so nothing arrives twice. diff --git a/src/agents/plugin_install.rs b/src/agents/plugin_install.rs index 6f99ced7..3f53727b 100644 --- a/src/agents/plugin_install.rs +++ b/src/agents/plugin_install.rs @@ -273,9 +273,90 @@ fn install_copilot(reg: &Registration, home: &Path, debounce: Duration) -> Resul .join(".copilot") .join("installed-plugins") .join(reg.marketplace); - copy_each(reg, home, debounce, |plugin| { + let written = copy_each(reg, home, debounce, |plugin| { installed.join(&plugin.manifest.name) - }) + })?; + + reconcile_copilot_records(reg, home); + Ok(written) +} + +/// Copilot only treats a plugin as installed once it appears in +/// `~/.copilot/config.json`, and that record carries a `source_sha` it computes +/// itself. Guessing that hash would couple us to an internal we cannot verify, +/// so this is the one agent where symposium drives the CLI instead of writing +/// the file: `copilot plugin install` needs no terminal, and by this point the +/// marketplace registration it resolves against is already in place. +/// +/// Verified the hard way — with the settings entries and the copy present but no +/// such record, Copilot reports the skill as absent. +fn reconcile_copilot_records(reg: &Registration, home: &Path) { + let recorded = copilot_recorded_plugins(home); + let keep = reg.qualified_names(); + + for stale in recorded.iter().filter(|r| ours(r) && !keep.contains(*r)) { + let name = stale.split_once('@').map_or(stale.as_str(), |(n, _)| n); + run_copilot(home, &["plugin", "uninstall", name]); + } + for plugin in reg.plugins { + let qualified = reg.qualified(plugin); + if !recorded.contains(&qualified) { + run_copilot(home, &["plugin", "install", &qualified]); + } + } +} + +/// The `@` keys Copilot currently records as installed. +/// +/// Its `config.json` is machine-managed and carries `//` comment lines, so it is +/// read leniently: an unreadable file just means nothing is recorded yet. +fn copilot_recorded_plugins(home: &Path) -> BTreeSet { + let path = home.join(".copilot").join("config.json"); + let Ok(raw) = std::fs::read_to_string(&path) else { + return BTreeSet::new(); + }; + let body: String = raw + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join( + " +", + ); + let Ok(value) = serde_json::from_str::(&body) else { + return BTreeSet::new(); + }; + value + .get("installedPlugins") + .and_then(Value::as_array) + .map(|entries| { + entries + .iter() + .filter_map(|entry| { + let name = entry.get("name")?.as_str()?; + let market = entry.get("marketplace")?.as_str()?; + Some(format!("{name}@{market}")) + }) + .collect() + }) + .unwrap_or_default() +} + +/// Best-effort: a missing or failing `copilot` leaves the config we wrote in +/// place, and the next sync tries again. +fn run_copilot(home: &Path, args: &[&str]) { + let result = std::process::Command::new("copilot") + .args(args) + .env("HOME", home) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status(); + match result { + Ok(status) if status.success() => {} + Ok(status) => tracing::debug!(?args, ?status, "copilot plugin command failed"), + Err(e) => tracing::debug!(?args, error = %e, "could not run copilot"), + } } /// Gemini discovers extensions by their presence in its directory, so the copy From 45fdbd94d29628326e7a45d995ed8ae0b0018503 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 10:36:33 -0300 Subject: [PATCH 09/14] test(agent-plugin): add the agent contract suite and close the tiered gaps --- md/design/module-structure.md | 10 +- md/design/running-tests.md | 34 +++ src/agent_plugin/tests.rs | 27 +++ src/agents/plugin_install/tests.rs | 94 ++++++++ src/cli.rs | 2 +- src/hook.rs | 9 +- src/plugins.rs | 27 ++- src/sync.rs | 105 ++++++--- src/use_command.rs | 4 +- symposium-testlib/src/lib.rs | 1 + tests/agent_plugin_contract.rs | 354 +++++++++++++++++++++++++++++ tests/init_sync.rs | 188 +++++++++++++++ 12 files changed, 816 insertions(+), 39 deletions(-) create mode 100644 tests/agent_plugin_contract.rs diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 25f4989f..98512c81 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,9 +24,11 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects — but only for a scope some configured agent can actually take, since otherwise the directory would sit unread — hands each to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps both the compiled directories and the agent-side copies it did not write. The debounce that keeps the per-event hook path cheap applies only when the caller asked for no update; an explicit `sync` or the `SessionStart` catch-up pass compares content so a directory changed since the last sync is restored rather than skipped. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects — but only for a scope some configured agent can actually take, since otherwise the directory would sit unread — hands each to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps both the compiled directories and the agent-side copies it did not write. The debounce that keeps the per-event hook path cheap is a decision the caller passes in (`Debounce::Recent` from the per-event hook path, `Debounce::Always` everywhere else), not something inferred from `UpdateLevel` — an explicit `cargo agents sync` defaults to `--update none`, so inferring it there meant editing a skill and re-running sync appeared to do nothing. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. -One entry point, `sync(sym, deps, update)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. +One entry point, `sync(sym, deps, update, debounce)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. + +Individually-installed skills go under the project when there is one and under the user's home when there is not, which is the only way a globally-enabled plugin's skills reach an agent that has no plugin unit to receive instead (`SkillHome`). **A workspace is optional.** Without one there is nothing project-scoped to install — no per-skill directories, no project staging root, no project hook registration — but globally-enabled plugins still apply, so the global half of the sync runs rather than the command refusing outright. `ProjectPaths` is the `Option` that carries the project root, its owned `.symposium/` directory, and its staging root together; every project-only step is guarded on it. `status` and a non-`--global` `use` still require a workspace, since each is about one. @@ -45,7 +47,9 @@ Validation here turns the raw TOML into: `load_crate_manifest(metadata, file, crate_name)` is the entry point for a crate-embedded plugin. It parses each source — the `[package.metadata.symposium]` table and a `SYMPOSIUM.toml` file, both in the ordinary plugin-manifest schema — independently and **leniently** (a malformed layer is logged and dropped), merges them (`RawPluginManifest::merge`: list fields append, scalar/keyed fields take the later layer, gates AND together), and runs the result through the same `validate_manifest` pipeline under a new `ManifestOrigin::Crate` variant: the `name` defaults to the crate, the dormancy rule does not apply (the reference that reached the crate is the gate), `[defaults]` is accepted, and the default `skills/` group is appended (but not the workspace-only `.agents/skills` group). A crate with neither source still yields that default group. `ParsedPlugin` carries a required `canonical: PackageId` — the resolved crate id for a crate-sourced plugin, or a placeholder id tagged with the source name (registry) / `"local"` (workspace) for plugins with no real package identity. It keys chained-plugin cycle/diamond detection on the normalized crate name (`skills.rs`); it does *not* affect skill identity, which is the `SKILL.md` path hash (see `skills.rs`). Every loader (`load_plugin_as`, `load_standalone_skill_plugin`, `workspace_plugin_for_dir`, and `CargoPm::build_from_fetched`) runs `resolve_group_sources` before returning, so each `[[skills]] source.path` group carries an **absolute** directory plus a display `source_label` — a `ParsedPlugin` needs no base/manifest dir. A `ParsedPlugin` carries no manifest or base path at all — its identity is its `canonical` id. `plugin show` renders a plugin's effective config keyed by that id (not a re-read manifest file); `plugin validate` reports each item by its id/name (a failed load's error message still carries the file it came from). -There is no separate "standalone skill" concept: a registry directory holding only a `SKILL.md` (no `SYMPOSIUM.toml`) is loaded by `load_standalone_skill_plugin` as a plugin with default values — named for the skill's own frontmatter `name` (falling back to the directory), carrying a single `source.path = "."` skill group that rediscovers that `SKILL.md`, and with the skill's frontmatter `depends-on`/`predicates` **hoisted to the plugin gate** so the ordinary dormancy rule applies (a bare skill that names no dependency is dormant until `use`d). This mirrors how a crate with no manifest still yields a plugin with the default `skills/` group. So `PluginRegistry` holds only `plugins`; the `plugin validate` CLI likewise reports a bare skill as its synthesized plugin, whose one child is the skill. Returns a `PluginRegistry` — a table of contents that doesn't load skill content. +There is no separate "standalone skill" concept: a registry directory holding only a `SKILL.md` (no `SYMPOSIUM.toml`) is loaded by `load_standalone_skill_plugin` as a plugin with default values — named for the skill's own frontmatter `name` (falling back to the directory), carrying a single `source.path = "."` skill group that rediscovers that `SKILL.md`, and with the skill's frontmatter `depends-on`/`predicates` **hoisted to the plugin gate** so the ordinary dormancy rule applies (a bare skill that names no dependency is dormant until `use`d). This mirrors how a crate with no manifest still yields a plugin with the default `skills/` group. So `PluginRegistry` holds only `plugins`; the `plugin validate` CLI likewise reports a bare skill as its synthesized plugin, whose one child is the skill. A registry entry's `canonical` id names its **subpath within the source**, not its declared name: two bare `SKILL.md` entries can carry the same frontmatter `name`, and two manifests can declare the same one, so keying on the name would make them a single plugin for grouping and dedup and the second would take the first's directory. This is also what `PathPm::load_plugin` already expects an id to mean. + +Returns a `PluginRegistry` — a table of contents that doesn't load skill content. A registry manifest that references no dependency anywhere — plugin, `[[skills]]`, `[[hooks]]`, `[[mcp_servers]]`, or `[[plugins]]` chain edge, via `depends-on`, a `depends-on(...)` predicate, or a custom predicate — is not an error: it validates and loads with `Plugin::requires_use = true`, i.e. *dormant*. `Plugin::applies` short-circuits to false for a dormant plugin unless `PredicateContext::is_used` says a `[plugins] use` entry names it, so every activation path (skills, hooks, MCP, subcommands, help) agrees. `depends-on = ["*"]` remains the explicit always-active spelling, and `plugin validate` reports dormancy as a warning. So a recommendations-registry entry — an ordinary flat plugin — stays out of dormancy by declaring its own `depends-on` (the crates it advises, or `["*"]`). The positional origins never go dormant, because where they were found supplies the gate. diff --git a/md/design/running-tests.md b/md/design/running-tests.md index 58ddbffb..b3ff9d7a 100644 --- a/md/design/running-tests.md +++ b/md/design/running-tests.md @@ -49,6 +49,40 @@ cargo test --test init_sync # just the init/sync tests cargo test --test dispatch # just the CLI dispatch tests ``` +## Agent contract tests + +`tests/agent_plugin_contract.rs` answers one question the rest of the suite cannot: does the agent +*actually load and use* what symposium installed? Asserting on the bytes we write is not enough — a +Copilot install once passed a unit test that reproduced its own CLI's output exactly, and Copilot +still ignored the plugin, because the missing piece was a record Copilot keeps for itself. + +These are **opt-in**, because they write into your real agent configuration: + +```bash +SYMPOSIUM_AGENT_CONTRACT=1 cargo test --test agent_plugin_contract -- --test-threads=1 +``` + +Real configuration is the only place a real agent reads. Redirecting `HOME` would isolate it but also +cut the agent off from its credentials, which live under that same directory. So every file the tests +touch is snapshotted first and restored on the way out, including on panic, and every directory they +copy into is removed. + +Each agent runs the same three checks — install, update, remove — driven through that agent's own CLI: + +| Check | Assertion | +|---|---| +| install | the agent invokes the skill and returns its token | +| update | after the source changes, the agent returns the *new* token | +| remove | once the plugin stops applying, the agent no longer has the skill | + +The probe plugin is deliberately dormant, activated only by a `use` entry, so that removing that entry +genuinely deactivates it. A `depends-on = ["*"]` probe would stay active regardless and the removal +check would prove nothing. + +An agent whose CLI is missing or cannot authenticate is **skipped with the reason printed**, never +failed: a missing login is not a symposium bug. A green run therefore does not by itself mean every +agent was covered — read the skips. + ## Debugging test failures Add `--nocapture` to see test output (agent messages, hook traces): diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index 6a203af1..0b0558a5 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -526,3 +526,30 @@ fn a_project_marketplace_is_named_per_workspace() { assert!(manifest::is_valid_name(name), "{name}"); } } + +#[test] +fn a_plugin_whose_name_cannot_be_slugged_is_skipped() { + let unnameable = registry_plugin("___", wildcard()); + let skills = vec![skill_of( + &unnameable, + "guidance", + "/reg/x/skills/g/SKILL.md", + )]; + assert!( + compile(&[unnameable], &skills, &no_config()).is_empty(), + "a package with no usable name cannot be installed anywhere, so it is dropped" + ); +} + +#[test] +fn one_unnameable_plugin_does_not_stop_the_others() { + let bad = registry_plugin("!!!", wildcard()); + let good = registry_plugin("pdf-tools", wildcard()); + let skills = vec![ + skill_of(&bad, "lost", "/reg/bad/skills/lost/SKILL.md"), + skill_of(&good, "extract", "/reg/good/skills/extract/SKILL.md"), + ]; + let compiled = compile(&[bad, good], &skills, &no_config()); + let names: Vec<&str> = compiled.iter().map(|p| p.dir_name.as_str()).collect(); + assert_eq!(names, vec!["pdf-tools"]); +} diff --git a/src/agents/plugin_install/tests.rs b/src/agents/plugin_install/tests.rs index 43db636a..3482aacf 100644 --- a/src/agents/plugin_install/tests.rs +++ b/src/agents/plugin_install/tests.rs @@ -372,3 +372,97 @@ fn every_copy_carries_the_marker_so_it_can_be_reaped() { "Claude copies nothing, so there is nothing of ours to reap" ); } + +// ── resilience ─────────────────────────────────────────────────────── + +#[test] +fn a_corrupt_agent_config_is_an_error_rather_than_a_panic() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let settings = home.join(".claude/settings.json"); + std::fs::create_dir_all(settings.parent().unwrap()).expect("create"); + std::fs::write(&settings, "{ this is not json").expect("seed"); + + let one = plugin("pdf-tools", "pdf-tools", "1.2.0"); + let result = Agent::Claude.install_plugins( + ®istration( + &tmp.path().join("staging"), + "symposium", + &[&one], + Scope::Global, + ), + &home, + tmp.path(), + Duration::ZERO, + ); + assert!( + result.is_err(), + "an unreadable config is reported to the caller, which turns it into a warning" + ); + assert_eq!( + std::fs::read_to_string(&settings).expect("read"), + "{ this is not json", + "and the file is left exactly as the user had it" + ); +} + +#[test] +fn copilots_own_record_is_read_leniently() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + assert!( + copilot_recorded_plugins(&home).is_empty(), + "no config yet means nothing is recorded" + ); + + let config = home.join(".copilot/config.json"); + std::fs::create_dir_all(config.parent().unwrap()).expect("create"); + + std::fs::write(&config, "{ not json at all").expect("seed"); + assert!( + copilot_recorded_plugins(&home).is_empty(), + "an unparseable file means nothing is recorded, not a failure" + ); + + // Copilot writes this file itself, with `//` comment lines. + std::fs::write( + &config, + "// This file is managed automatically.\n{\n \"installedPlugins\": [\n \ + {\"name\": \"pdf-tools\", \"marketplace\": \"symposium\"},\n \ + {\"name\": \"other\", \"marketplace\": \"elsewhere\"}\n ]\n}\n", + ) + .expect("seed"); + let recorded = copilot_recorded_plugins(&home); + assert!(recorded.contains("pdf-tools@symposium")); + assert!( + recorded.contains("other@elsewhere"), + "entries we do not own are still read, so they are not treated as missing" + ); +} + +#[test] +fn a_missing_copilot_binary_does_not_fail_the_install() { + let tmp = tempfile::tempdir().expect("tmp"); + let home = tmp.path().join("home"); + let root = tmp.path().join("staging"); + let one = staged_plugin(&root, "pdf-tools"); + + // `run_copilot` spawns by name, so an absent binary surfaces as a spawn + // error. Proven directly rather than by manipulating PATH, which is + // process-global and would race other tests. + run_copilot(&home, &["definitely-not-a-subcommand"]); + + let written = Agent::Copilot + .install_plugins( + ®istration(&root, "symposium", &[&one], Scope::Global), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("the config and copy still land even if the CLI cannot be driven"); + assert_eq!(written.len(), 1); + assert!( + home.join(".copilot/settings.json").is_file(), + "settings are ours to write, and do not depend on the binary" + ); +} diff --git a/src/cli.rs b/src/cli.rs index 4868a998..12cf8b68 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -247,7 +247,7 @@ pub async fn run( // hook-triggered auto-sync path calls `sync::sync` directly and // never reaches here at all. discovery::prompt_for_consent(sym, &deps, out).await?; - sync::sync(sym, &deps, update).await + sync::sync(sym, &deps, update, sync::Debounce::Always).await } Commands::Search { query } => search_command::search(sym, &query).await, diff --git a/src/hook.rs b/src/hook.rs index 35c9d4d8..6c114944 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -390,7 +390,14 @@ async fn run_auto_sync(sym: &Symposium, deps: &Arc, session_start }; tracing::debug!("auto-sync running"); - if let Err(e) = crate::sync::sync(sym, deps, update).await { + // The catch-up pass at session start looks at everything; a per-event sync + // stays cheap. + let debounce = if session_start { + crate::sync::Debounce::Always + } else { + crate::sync::Debounce::Recent + }; + if let Err(e) = crate::sync::sync(sym, deps, update, debounce).await { tracing::warn!(error = %e, "auto-sync during hook failed (continuing)"); return; } diff --git a/src/plugins.rs b/src/plugins.rs index 297f6ef3..1af6822d 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -1341,13 +1341,32 @@ fn load_agent_plugin_entry( ) -> Result { let mut plugin = crate::agent_plugin::read::load(dir, false)?; resolve_group_sources(&mut plugin, dir, source_dir); + let canonical = entry_id(source_name, source_dir, dir, &plugin.name); Ok(ParsedPlugin { - canonical: PackageId::new(source_name, &plugin.name, ANY_VERSION), + canonical, plugin, workspace_member: false, }) } +/// The canonical id for a registry entry: the entry's subpath within its source. +/// +/// The subpath, not the plugin's name, because a name is not unique — two bare +/// `SKILL.md` entries can declare the same frontmatter `name`, and two manifests +/// can declare the same `name`. Sharing an id would make them one plugin as far +/// as grouping and dedup are concerned, so the second would overwrite the first. +/// This is also what [`PathPm::load_plugin`](crate::pm::PathPm) already expects +/// an id to mean. +fn entry_id(source_name: &str, source_dir: &Path, entry_dir: &Path, fallback: &str) -> PackageId { + let subpath = entry_dir + .strip_prefix(source_dir) + .ok() + .map(crate::pm::layout::subpath_key) + .filter(|key| !key.is_empty()) + .unwrap_or_else(|| fallback.to_string()); + PackageId::new(source_name, subpath, ANY_VERSION) +} + /// Resolve each `source.path` skill group to an absolute directory and a /// display label, given the plugin's own base directory (what the relative /// path is joined onto) and the attribution root the label is shown relative @@ -1436,8 +1455,9 @@ fn load_standalone_skill_plugin( }; let base = skill_md.parent().unwrap_or(source_dir); resolve_group_sources(&mut plugin, base, source_dir); + let canonical = entry_id(source_name, source_dir, base, &name); Ok(ParsedPlugin { - canonical: PackageId::new(source_name, &name, ANY_VERSION), + canonical, plugin, workspace_member: false, }) @@ -1965,8 +1985,9 @@ fn load_plugin_as( let mut plugin = validate_manifest(manifest, origin) .with_context(|| format!("validating `{}`", manifest_path.display()))?; resolve_group_sources(&mut plugin, base, source_dir); + let canonical = entry_id(source_name, source_dir, base, &plugin.name); Ok(ParsedPlugin { - canonical: PackageId::new(source_name, &plugin.name, ANY_VERSION), + canonical, plugin, // Registry sources are never workspace members; the workspace-plugin // loader is the only place that stamps true. diff --git a/src/sync.rs b/src/sync.rs index bb6d6269..aa7d9fb5 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -46,14 +46,43 @@ pub(crate) fn create_managed_dir_all(path: &Path, boundary: &Path) -> Result<()> Ok(()) } -/// Skills parent directory for an agent (e.g. `.claude/skills/` or -/// `.agents/skills/`), derived from `Agent::project_skill_dir`. -fn skills_parent_dir(agent: Agent, project_root: &Path) -> PathBuf { - agent - .project_skill_dir(project_root, "_") - .parent() - .expect("skill dir must have parent") - .to_path_buf() +/// Where individually-installed skills go for one sync: under the project when +/// there is one, and otherwise under the user's home, which is the only place a +/// globally-enabled plugin's skills can land for an agent that has no plugin +/// unit to receive instead. +#[derive(Clone, Copy)] +enum SkillHome<'a> { + Project(&'a Path), + Global(&'a Path), +} + +impl<'a> SkillHome<'a> { + /// The directory this agent should hold `skill_name` in. `None` when the + /// agent has no such location at all — Copilot has no global skills path. + fn dir_for(&self, agent: Agent, skill_name: &str) -> Option { + match self { + SkillHome::Project(root) => Some(agent.project_skill_dir(root, skill_name)), + SkillHome::Global(home) => agent.global_skill_dir(home, skill_name), + } + } + + /// The boundary `create_managed_dir_all` may not walk above. + fn boundary(&self) -> &'a Path { + match self { + SkillHome::Project(root) => root, + SkillHome::Global(home) => home, + } + } + + /// The shared parent those directories sit in, for stale cleanup. + fn parent_for(&self, agent: Agent) -> Option { + Some( + self.dir_for(agent, "_")? + .parent() + .expect("skill dir must have parent") + .to_path_buf(), + ) + } } /// Whether a managed directory also needs its own `.gitignore`. @@ -290,6 +319,18 @@ async fn resolve_custom_predicate_entries( entries } +/// Whether a sync may skip a directory it synced very recently. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Debounce { + /// Honor `sync-debounce-secs`. For the per-event hook path, which runs on + /// every tool call and has to stay near free. + Recent, + /// Compare content regardless of how recently we last looked. For anything a + /// person triggered, and for the `SessionStart` catch-up pass — otherwise + /// editing a skill and re-running `sync` appears to do nothing. + Always, +} + /// The project-scoped paths sync writes into, when there is a workspace at all. struct ProjectPaths { root: PathBuf, @@ -335,7 +376,12 @@ struct PendingSkill<'a> { /// Run the full sync: discover applicable skills, install into agent dirs, /// clean up stale installations. -pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLevel) -> Result<()> { +pub async fn sync( + sym: &Symposium, + deps: &Arc, + update: UpdateLevel, + debounce: Debounce, +) -> Result<()> { let out = &Output::quiet(); // A workspace is optional. Without one there is nothing project-scoped to // install, but globally-enabled plugins still apply, so the global half of @@ -343,13 +389,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve let loaded = deps.load().cloned(); let project = loaded.as_ref().map(|l| ProjectPaths::under(&l.root)); let workspace_deps_count = loaded.as_ref().map_or(0, |l| l.crates.len()); - // The debounce keeps the per-event hook path cheap. A caller that asked for - // an update — an explicit `sync`, or the `SessionStart` catch-up pass — wants - // the comparison done, so a directory changed since the last sync is - // restored rather than skipped for the debounce window. - let debounce = match update { - UpdateLevel::None => Duration::from_secs(sym.config.sync_debounce_secs), - _ => Duration::ZERO, + let debounce = match debounce { + Debounce::Recent => Duration::from_secs(sym.config.sync_debounce_secs), + Debounce::Always => Duration::ZERO, }; match &project { Some(p) => tracing::debug!(root = %p.root.display(), "resolved workspace root"), @@ -581,6 +623,15 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve return Ok(()); } + // Individually-installed skills go under the project when there is one. A + // no-workspace sync still has to deliver a globally-enabled plugin's skills + // to agents that cannot take the compiled directory, so they land under the + // user's home instead. + let skill_home = match &project { + Some(p) => SkillHome::Project(&p.root), + None => SkillHome::Global(sym.home_dir()), + }; + // Track every skill directory we (re)install during this sync. Anything // we find later that has the marker file but isn't in this set is stale. let mut installed_dirs: BTreeSet = BTreeSet::new(); @@ -666,13 +717,6 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve .register_global_mcp_servers(&hook_root, &mcp_servers, out) .context("failed to register MCP servers")?; - // Individually-installed skills go under the project. Without one there - // is nowhere to put them, and the compiled directories above are the - // whole of what a no-workspace sync delivers. - let Some(project) = &project else { - continue; - }; - for pending in &to_install { let PendingSkill { name: skill_name, @@ -704,7 +748,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // (a workspace `.agents/skills/` skill, on an agent that reads // that same directory) — it is in place as user content, not // something to copy. - let plain_dir = agent.project_skill_dir(&project.root, skill_name); + let Some(plain_dir) = skill_home.dir_for(agent, skill_name) else { + continue; + }; let in_place = match (source_dir.canonicalize(), plain_dir.canonicalize()) { (Ok(a), Ok(b)) => a == b, _ => false, @@ -727,7 +773,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve } else { format!("{skill_name}-{}", origin_hash) }; - let dest_dir = agent.project_skill_dir(&project.root, &dir_name); + let Some(dest_dir) = skill_home.dir_for(agent, &dir_name) else { + continue; + }; // If the dest exists but is user-managed, skip it. if dest_dir.exists() && !has_symposium_marker(&dest_dir) { @@ -745,7 +793,7 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve match sync_managed_dir( source_dir, &dest_dir, - &project.root, + skill_home.boundary(), debounce, Marking::MarkerAndGitignore, ) { @@ -781,10 +829,9 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve // and remove subdirs containing the marker that we didn't just install. let mut scanned: BTreeSet = BTreeSet::new(); for &agent in Agent::all() { - let Some(project) = &project else { - break; + let Some(parent) = skill_home.parent_for(agent) else { + continue; }; - let parent = skills_parent_dir(agent, &project.root); if !scanned.insert(parent.clone()) { continue; } diff --git a/src/use_command.rs b/src/use_command.rs index 1e66522f..57ff9ba6 100644 --- a/src/use_command.rs +++ b/src/use_command.rs @@ -92,7 +92,7 @@ pub async fn use_plugin( // Install now rather than waiting for the next sync. if workspace_root.is_some() { - crate::sync::sync(sym, &deps, update).await?; + crate::sync::sync(sym, &deps, update, crate::sync::Debounce::Always).await?; } Ok(()) } @@ -143,7 +143,7 @@ pub async fn remove_plugin( ); if workspace_root.is_some() { - crate::sync::sync(sym, &deps, update).await?; + crate::sync::sync(sym, &deps, update, crate::sync::Debounce::Always).await?; } Ok(()) } diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 4d46f513..afb70701 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -278,6 +278,7 @@ impl TestContext { &self.sym, &self.sym.workspace_deps(&cwd), symposium::UpdateLevel::None, + symposium::sync::Debounce::Always, ) .await?; diff --git a/tests/agent_plugin_contract.rs b/tests/agent_plugin_contract.rs new file mode 100644 index 00000000..689f8f83 --- /dev/null +++ b/tests/agent_plugin_contract.rs @@ -0,0 +1,354 @@ +//! Agent contract tests: does the agent *actually load and use* what symposium +//! installed? +//! +//! This is the only level that can catch a plausible file in a plausible place +//! that the agent ignores — the failure mode that shipped a broken Copilot +//! install past a green unit test asserting the exact bytes Copilot's own CLI +//! writes. What was missing there was a fact about Copilot, not about us. +//! +//! **Opt in with `SYMPOSIUM_AGENT_CONTRACT=1`.** These tests write into the +//! developer's real agent configuration, because that is the only place a real +//! agent reads: redirecting `HOME` isolates the config but also cuts the agent +//! off from its credentials, which are stored under it. Every file touched is +//! snapshotted first and restored on the way out, including on panic. +//! +//! Each agent is skipped, not failed, when its CLI is absent or cannot +//! authenticate. A skip prints why, so a green run never silently means +//! "verified nothing". + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The skill reports this, and only a skill that actually ran can produce it. +const TOKEN_V1: &str = "CONTRACT-TOKEN-A1B2"; +/// What it reports after the source is edited, proving an update propagated. +const TOKEN_V2: &str = "CONTRACT-TOKEN-C3D4"; + +const ASK: &str = "Do you have a skill named contract-check? If so, invoke it and \ + report only the token it gives you. Otherwise answer NONE."; + +fn enabled() -> bool { + std::env::var("SYMPOSIUM_AGENT_CONTRACT").is_ok_and(|v| !v.is_empty() && v != "0") +} + +fn home() -> PathBuf { + PathBuf::from(std::env::var("HOME").expect("HOME")) +} + +/// How to drive one agent, and what of its configuration we may disturb. +struct AgentUnderTest { + /// Symposium's name for it, as `init --add-agent` takes it. + name: &'static str, + /// The binary to run. + bin: &'static str, + /// Arguments that make it answer one prompt and exit. + prompt_args: &'static [&'static str], + /// Configuration files symposium writes for this agent. + config_files: Vec, + /// Directories symposium copies into for this agent. + content_dirs: Vec, +} + +fn agents() -> Vec { + let h = home(); + vec![ + AgentUnderTest { + name: "claude", + bin: "claude", + prompt_args: &["-p"], + config_files: vec![ + h.join(".claude/settings.json"), + h.join(".claude/plugins/known_marketplaces.json"), + ], + content_dirs: vec![], + }, + AgentUnderTest { + name: "codex", + bin: "codex", + prompt_args: &["exec", "--skip-git-repo-check"], + config_files: vec![h.join(".codex/config.toml")], + content_dirs: vec![h.join(".codex/plugins/cache/symposium")], + }, + AgentUnderTest { + name: "copilot", + bin: "copilot", + prompt_args: &["-p"], + config_files: vec![ + h.join(".copilot/settings.json"), + h.join(".copilot/config.json"), + ], + content_dirs: vec![h.join(".copilot/installed-plugins/symposium")], + }, + AgentUnderTest { + name: "gemini", + bin: "gemini", + prompt_args: &["-p"], + config_files: vec![], + content_dirs: vec![h.join(".gemini/extensions/contract-probe")], + }, + ] +} + +/// Snapshots every path it is given and puts them back when dropped, so a +/// failing assertion cannot leave the developer's agent configuration altered. +struct ConfigGuard { + files: Vec<(PathBuf, Option>)>, + dirs: Vec, +} + +impl ConfigGuard { + fn snapshot(agent: &AgentUnderTest) -> Self { + let files = agent + .config_files + .iter() + .map(|path| (path.clone(), std::fs::read(path).ok())) + .collect(); + Self { + files, + dirs: agent.content_dirs.clone(), + } + } +} + +impl Drop for ConfigGuard { + fn drop(&mut self) { + for dir in &self.dirs { + let _ = std::fs::remove_dir_all(dir); + } + for (path, original) in &self.files { + match original { + Some(bytes) => { + let _ = std::fs::write(path, bytes); + } + // It did not exist before this test, so it must not now. + None => { + let _ = std::fs::remove_file(path); + } + } + } + } +} + +/// A fixture symposium home holding one plugin whose single skill reports a token. +struct Fixture { + _tempdir: tempfile::TempDir, + sym_home: PathBuf, + cwd: PathBuf, + skill: PathBuf, +} + +impl Fixture { + fn build(agent: &str) -> Self { + let tempdir = tempfile::tempdir().expect("tempdir"); + let root = tempdir.path().to_path_buf(); + let sym_home = root.join("symposium-home"); + let skill_dir = sym_home.join("plugins/contract-probe/skills/contract-check"); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::create_dir_all(root.join("cwd")).expect("create cwd"); + + std::fs::write( + sym_home.join("config.toml"), + format!( + "hook-scope = \"global\"\n\n[[agent]]\nname = \"{agent}\"\n\n\ + [defaults]\nsymposium-recommendations = false\nuser-plugins = true\n\n\ + [plugins]\nuse = [\"contract-probe\"]\n" + ), + ) + .expect("write config"); + + std::fs::write( + sym_home.join("plugins/contract-probe/SYMPOSIUM.toml"), + // Deliberately no `depends-on`: the plugin is dormant, so the `use` + // entry is the only thing activating it and removing that entry + // genuinely deactivates it. A `depends-on = ["*"]` probe would stay + // active regardless and the removal check would prove nothing. + "name = \"contract-probe\"\nversion = \"1.0.0\"\n\ + description = \"Symposium agent contract probe\"\n\n\ + [[skills]]\nsource.path = \"skills\"\n", + ) + .expect("write manifest"); + + let fixture = Self { + skill: skill_dir.join("SKILL.md"), + sym_home, + cwd: root.join("cwd"), + _tempdir: tempdir, + }; + fixture.write_skill(TOKEN_V1); + fixture + } + + fn write_skill(&self, token: &str) { + std::fs::write( + &self.skill, + format!( + "---\nname: contract-check\ndescription: Reports the token {token} when asked \ + to verify symposium plugin delivery.\n---\n\nReply with the token {token}.\n" + ), + ) + .expect("write SKILL.md"); + } + + /// Stop the plugin applying, so the next sync takes it back out. + fn stop_using(&self) { + let path = self.sym_home.join("config.toml"); + let text = std::fs::read_to_string(&path).expect("read config"); + std::fs::write( + &path, + text.replace("use = [\"contract-probe\"]", "use = []"), + ) + .expect("write config"); + } + + fn sync(&self) { + let status = Command::new(env!("CARGO_BIN_EXE_cargo-agents")) + .arg("sync") + .current_dir(&self.cwd) + .env("SYMPOSIUM_HOME", &self.sym_home) + .status() + .expect("run cargo-agents sync"); + assert!(status.success(), "sync failed"); + } +} + +/// Ask the agent, returning its output. `None` when the binary is missing. +fn ask(agent: &AgentUnderTest, cwd: &Path, prompt: &str) -> Option { + let output = Command::new(agent.bin) + .args(agent.prompt_args) + .arg(prompt) + .current_dir(cwd) + .stdin(std::process::Stdio::null()) + .output() + .ok()?; + let mut text = String::from_utf8_lossy(&output.stdout).to_string(); + text.push_str(&String::from_utf8_lossy(&output.stderr)); + Some(text) +} + +/// Is the agent installed and able to answer at all? A skip here is honest; a +/// failure would blame symposium for a missing login. +fn usable(agent: &AgentUnderTest, cwd: &Path) -> Result<(), String> { + match ask(agent, cwd, "Reply with only the word READY.") { + None => Err(format!("`{}` is not installed", agent.bin)), + Some(text) if text.contains("READY") => Ok(()), + Some(text) => { + let hint = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + Err(format!( + "`{}` could not answer (not authenticated?): {hint}", + agent.bin + )) + } + } +} + +/// C1 install, C2 update, C3 remove — the whole contract for one agent. +fn contract(agent: &AgentUnderTest) { + let fixture = Fixture::build(agent.name); + + if let Err(why) = usable(agent, &fixture.cwd) { + eprintln!("SKIP {}: {why}", agent.name); + return; + } + + let _guard = ConfigGuard::snapshot(agent); + + // C1 — installed, and the agent runs it. + fixture.sync(); + let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); + assert!( + answer.contains(TOKEN_V1), + "{}: expected {TOKEN_V1} after install, got:\n{answer}", + agent.name + ); + + // C2 — the source changes and the agent sees the new content. + fixture.write_skill(TOKEN_V2); + fixture.sync(); + let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); + assert!( + answer.contains(TOKEN_V2) && !answer.contains(TOKEN_V1), + "{}: expected {TOKEN_V2} after an edit, got:\n{answer}", + agent.name + ); + + // C3 — it stops applying and the agent no longer has it. + fixture.stop_using(); + fixture.sync(); + let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); + assert!( + !answer.contains(TOKEN_V1) && !answer.contains(TOKEN_V2), + "{}: the skill should be gone, got:\n{answer}", + agent.name + ); +} + +fn run_for(name: &str) { + if !enabled() { + eprintln!("SKIP {name}: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); + return; + } + let agent = agents() + .into_iter() + .find(|a| a.name == name) + .expect("known agent"); + contract(&agent); +} + +#[test] +fn claude_code_honors_the_install_contract() { + run_for("claude"); +} + +#[test] +fn codex_honors_the_install_contract() { + run_for("codex"); +} + +#[test] +fn copilot_honors_the_install_contract() { + run_for("copilot"); +} + +#[test] +fn gemini_honors_the_install_contract() { + run_for("gemini"); +} + +/// An agent with no plugin unit still receives the skill on its own, and that +/// path has to keep working now that the plugin-capable agents have left it. +/// +/// Outside a workspace those skills have nowhere project-scoped to go, so they +/// land in the agent's user-level skills directory — the only place a globally +/// enabled plugin can reach an agent that cannot take a compiled directory. +#[test] +fn an_agent_without_a_plugin_unit_still_receives_the_skill() { + if !enabled() { + eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); + return; + } + + let installed = home().join(".agents/skills/contract-check"); + let opencode = AgentUnderTest { + name: "opencode", + bin: "opencode", + prompt_args: &[], + config_files: Vec::new(), + content_dirs: vec![installed.clone()], + }; + let _guard = ConfigGuard::snapshot(&opencode); + + let fixture = Fixture::build(opencode.name); + fixture.sync(); + assert!( + installed.join("SKILL.md").is_file(), + "OpenCode has no plugin unit, so the skill has to arrive on its own at {}", + installed.display() + ); + + fixture.stop_using(); + fixture.sync(); + assert!( + !installed.exists(), + "and it has to be reaped when the plugin stops applying" + ); +} diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 9790ba74..148d7570 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2986,3 +2986,191 @@ async fn sync_outside_a_workspace_installs_the_global_plugins() { .await .unwrap(); } + +// ── Delivery across a mixed agent set, and its lifecycle ───────────── + +/// A plugin reaches each agent exactly once, by whichever mechanism that agent +/// has: Claude Code takes the compiled directory, and an agent with no plugin +/// unit still receives the skill on its own. Neither gets both. +#[tokio::test] +async fn a_plugin_reaches_each_agent_once_by_its_own_mechanism() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude", "--add-agent", "opencode"]) + .await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + assert!( + root.join(".symposium/plugins/project-tools/skills/project-guidance/SKILL.md") + .is_file(), + "the compiled directory is what Claude Code is given" + ); + assert!( + !root.join(".claude/skills/project-guidance").exists(), + "so Claude must not also receive the skill on its own" + ); + assert!( + root.join(".agents/skills/project-guidance/SKILL.md") + .is_file(), + "OpenCode has no plugin unit, so it still receives the skill individually" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Dropping an agent from the config reaps the copies it was given, the same way +/// dropping a plugin does. +#[tokio::test] +async fn removing_an_agent_from_the_config_reaps_its_plugin_copies() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude", "--add-agent", "codex"]) + .await?; + ctx.symposium(&["sync"]).await?; + + // Codex loads only from its own tree, so it was given a copy. + let copy = ctx + .sym + .config_dir() + .join(".codex/plugins/cache/symposium/global-tools/0.0.0"); + assert!( + copy.join("skills/global-guidance/SKILL.md").is_file(), + "codex should have received a copy, found: {:?}", + std::fs::read_dir(ctx.sym.config_dir().join(".codex/plugins/cache/symposium")) + .ok() + .map(|d| d.flatten().map(|e| e.path()).collect::>()) + ); + + ctx.symposium(&["init", "--remove-agent", "codex"]).await?; + ctx.symposium(&["sync"]).await?; + + assert!( + !copy.exists(), + "an agent dropped from the config keeps nothing of ours" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Editing a skill upstream reaches the copy an agent already holds, not just the +/// staging root. +#[tokio::test] +async fn editing_a_skill_updates_the_copy_an_agent_holds() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "codex"]).await?; + ctx.symposium(&["sync"]).await?; + + let delivered = ctx.sym.config_dir().join( + ".codex/plugins/cache/symposium/global-tools/0.0.0/skills/global-guidance/SKILL.md", + ); + assert!(delivered.is_file()); + assert!(!std::fs::read_to_string(&delivered)?.contains("SECOND EDITION")); + + let source = ctx + .sym + .config_dir() + .join("plugins/global-tools/global-guidance/SKILL.md"); + let edited = std::fs::read_to_string(&source)?.replace("Body.", "SECOND EDITION"); + std::fs::write(&source, edited)?; + + ctx.symposium(&["sync"]).await?; + assert!( + std::fs::read_to_string(&delivered)?.contains("SECOND EDITION"), + "the agent's own copy has to follow the source, not just the staging root" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Two registry entries may declare the same name. They are still two plugins: +/// an entry's identity is where it sits, not what it calls itself, or the second +/// would take the first's directory and its skills. +#[tokio::test] +async fn two_entries_with_one_declared_name_compile_separately() { + with_fixture( + TestMode::SimulationOnly, + &["distinct-standalone-paths0", "workspace0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let compiled: Vec = std::fs::read_dir(root.join(".symposium/plugins"))? + .flatten() + .map(|e| e.path()) + .filter(|p| p.join("plugin.json").is_file()) + .collect(); + assert_eq!( + compiled.len(), + 2, + "one directory per entry, not per name; got {compiled:?}" + ); + for dir in &compiled { + assert!( + dir.join("skills/my-skill/SKILL.md").is_file(), + "{} lost its skill", + dir.display() + ); + } + + let bodies: Vec = compiled + .iter() + .map(|d| std::fs::read_to_string(d.join("skills/my-skill/SKILL.md")).unwrap()) + .collect(); + assert!(bodies.iter().any(|b| b.contains("Foo body"))); + assert!(bodies.iter().any(|b| b.contains("Bar body"))); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A skill's companion files travel with it into the compiled plugin, not just +/// its `SKILL.md`. +#[tokio::test] +async fn companion_files_travel_into_the_compiled_plugin() { + with_fixture( + TestMode::SimulationOnly, + &["agent-plugin-scopes0", "workspace0"], + async |mut ctx| { + let companion = ctx + .sym + .config_dir() + .join("plugins/project-tools/project-guidance/REFERENCE.md"); + std::fs::write(&companion, "companion content\n")?; + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let root = ctx.workspace_root.clone().expect("workspace root"); + let delivered = root.join(".symposium/plugins/project-tools/skills/project-guidance"); + assert!(delivered.join("SKILL.md").is_file()); + assert_eq!( + std::fs::read_to_string(delivered.join("REFERENCE.md"))?, + "companion content\n", + "the whole skill directory is copied, not only its SKILL.md" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} From db96bc97ada5da602a38e3ca0f74f5a490137fdf Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 10:48:11 -0300 Subject: [PATCH 10/14] test(agent-plugin): cover project scope and the per-skill fallback with real agents --- md/design/running-tests.md | 6 ++ tests/agent_plugin_contract.rs | 136 ++++++++++++++++++++++++++++++--- 2 files changed, 132 insertions(+), 10 deletions(-) diff --git a/md/design/running-tests.md b/md/design/running-tests.md index b3ff9d7a..d4f10849 100644 --- a/md/design/running-tests.md +++ b/md/design/running-tests.md @@ -67,6 +67,12 @@ cut the agent off from its credentials, which live under that same directory. So touch is snapshotted first and restored on the way out, including on panic, and every directory they copy into is removed. +Both scopes are covered, because they take different paths: a global plugin is enabled in the user's +own settings and compiled under the symposium home, while a project-scoped one is enabled in the +*project's* settings, compiled under the project, and registered under a marketplace named for that +workspace. Claude Code is the only agent that can bound a plugin to a project; for the rest, the suite +checks that a project-scoped plugin still reaches them as individual skills. + Each agent runs the same three checks — install, update, remove — driven through that agent's own CLI: | Check | Assertion | diff --git a/tests/agent_plugin_contract.rs b/tests/agent_plugin_contract.rs index 689f8f83..450ec6c9 100644 --- a/tests/agent_plugin_contract.rs +++ b/tests/agent_plugin_contract.rs @@ -129,6 +129,18 @@ impl Drop for ConfigGuard { } } +/// Which scope the probe plugin should be enabled at. +/// +/// The two take genuinely different paths: a global plugin is enabled in the +/// user's own settings and compiled under the symposium home, while a +/// project-scoped one is enabled in the *project's* settings, compiled under the +/// project, and registered under a marketplace named for that workspace. +#[derive(Clone, Copy, PartialEq)] +enum ProbeScope { + Global, + Project, +} + /// A fixture symposium home holding one plugin whose single skill reports a token. struct Fixture { _tempdir: tempfile::TempDir, @@ -139,6 +151,10 @@ struct Fixture { impl Fixture { fn build(agent: &str) -> Self { + Self::build_scoped(agent, ProbeScope::Global) + } + + fn build_scoped(agent: &str, scope: ProbeScope) -> Self { let tempdir = tempfile::tempdir().expect("tempdir"); let root = tempdir.path().to_path_buf(); let sym_home = root.join("symposium-home"); @@ -146,12 +162,36 @@ impl Fixture { std::fs::create_dir_all(&skill_dir).expect("create skill dir"); std::fs::create_dir_all(root.join("cwd")).expect("create cwd"); + // A project-scoped run needs a Rust workspace to be scoped *to*, and a + // `use` entry naming it; a global run has neither. + let cwd = match scope { + ProbeScope::Global => root.join("cwd"), + ProbeScope::Project => { + let project = root.join("cwd"); + std::fs::create_dir_all(project.join("src")).expect("create project"); + std::fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"contract-project\"\nversion = \"0.1.0\"\n\ + edition = \"2021\"\n\n[dependencies]\n", + ) + .expect("write Cargo.toml"); + std::fs::write(project.join("src/lib.rs"), "").expect("write lib.rs"); + project + } + }; + let used = match scope { + ProbeScope::Global => "use = [\"contract-probe\"]".to_string(), + ProbeScope::Project => format!( + "use = [{{ name = \"contract-probe\", workspace = \"{}\" }}]", + cwd.display().to_string().replace('\\', "/") + ), + }; std::fs::write( sym_home.join("config.toml"), format!( - "hook-scope = \"global\"\n\n[[agent]]\nname = \"{agent}\"\n\n\ + "hook-scope = \"project\"\n\n[[agent]]\nname = \"{agent}\"\n\n\ [defaults]\nsymposium-recommendations = false\nuser-plugins = true\n\n\ - [plugins]\nuse = [\"contract-probe\"]\n" + [plugins]\n{used}\n" ), ) .expect("write config"); @@ -171,7 +211,7 @@ impl Fixture { let fixture = Self { skill: skill_dir.join("SKILL.md"), sym_home, - cwd: root.join("cwd"), + cwd, _tempdir: tempdir, }; fixture.write_skill(TOKEN_V1); @@ -189,15 +229,23 @@ impl Fixture { .expect("write SKILL.md"); } - /// Stop the plugin applying, so the next sync takes it back out. + /// Stop the plugin applying, so the next sync takes it back out. The probe is + /// dormant, so dropping its `use` entry is what deactivates it. fn stop_using(&self) { let path = self.sym_home.join("config.toml"); let text = std::fs::read_to_string(&path).expect("read config"); - std::fs::write( - &path, - text.replace("use = [\"contract-probe\"]", "use = []"), - ) - .expect("write config"); + let cleared: String = text + .lines() + .map(|line| { + if line.trim_start().starts_with("use = [") { + "use = []".to_string() + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + std::fs::write(&path, format!("{cleared}\n")).expect("write config"); } fn sync(&self) { @@ -243,7 +291,11 @@ fn usable(agent: &AgentUnderTest, cwd: &Path) -> Result<(), String> { /// C1 install, C2 update, C3 remove — the whole contract for one agent. fn contract(agent: &AgentUnderTest) { - let fixture = Fixture::build(agent.name); + contract_at(agent, ProbeScope::Global) +} + +fn contract_at(agent: &AgentUnderTest, scope: ProbeScope) { + let fixture = Fixture::build_scoped(agent.name, scope); if let Err(why) = usable(agent, &fixture.cwd) { eprintln!("SKIP {}: {why}", agent.name); @@ -299,6 +351,23 @@ fn claude_code_honors_the_install_contract() { run_for("claude"); } +/// Claude Code is the only agent that can bound a plugin to one project, and +/// that path differs from the global one: the enablement goes into the project's +/// own settings, and the marketplace is named for the workspace so two projects +/// cannot overwrite each other's registration. +#[test] +fn claude_code_honors_the_contract_at_project_scope() { + if !enabled() { + eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); + return; + } + let agent = agents() + .into_iter() + .find(|a| a.name == "claude") + .expect("known agent"); + contract_at(&agent, ProbeScope::Project); +} + #[test] fn codex_honors_the_install_contract() { run_for("codex"); @@ -314,6 +383,53 @@ fn gemini_honors_the_install_contract() { run_for("gemini"); } +/// An agent that cannot bound a plugin to one project still receives that +/// plugin's skills, individually, under the project. +/// +/// This is the claim that the per-skill path stays primary for project-scoped +/// plugins on every agent but Claude Code. Asserting the file lands is our half; +/// asking the agent is theirs. +#[test] +fn a_project_scoped_plugin_reaches_codex_as_a_plain_skill() { + if !enabled() { + eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); + return; + } + let agent = agents() + .into_iter() + .find(|a| a.name == "codex") + .expect("known agent"); + + let fixture = Fixture::build_scoped(agent.name, ProbeScope::Project); + if let Err(why) = usable(&agent, &fixture.cwd) { + eprintln!("SKIP {}: {why}", agent.name); + return; + } + let _guard = ConfigGuard::snapshot(&agent); + + fixture.sync(); + let installed = fixture.cwd.join(".agents/skills/contract-check/SKILL.md"); + assert!( + installed.is_file(), + "codex cannot scope a plugin to a project, so the skill has to arrive on its own at {}", + installed.display() + ); + assert!( + !fixture + .cwd + .join(".symposium/plugins/contract-probe") + .exists() + || fixture.cwd.join(".symposium/plugins").exists(), + "the compiled directory is only built for agents that can take it" + ); + + let answer = ask(&agent, &fixture.cwd, ASK).expect("agent ran"); + assert!( + answer.contains(TOKEN_V1), + "codex should read the project's own skills directory, got:\n{answer}" + ); +} + /// An agent with no plugin unit still receives the skill on its own, and that /// path has to keep working now that the plugin-capable agents have left it. /// From 0bf8e154453f4f715b722bbad7f6f35d35e453d6 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 12:51:13 -0300 Subject: [PATCH 11/14] test(agent-plugin): drop the CLI-driving suite, keep tests that exercise the code --- md/design/running-tests.md | 40 --- src/agents/plugin_install.rs | 10 + src/agents/plugin_install/tests.rs | 19 +- tests/agent_plugin_contract.rs | 470 ----------------------------- 4 files changed, 18 insertions(+), 521 deletions(-) delete mode 100644 tests/agent_plugin_contract.rs diff --git a/md/design/running-tests.md b/md/design/running-tests.md index d4f10849..58ddbffb 100644 --- a/md/design/running-tests.md +++ b/md/design/running-tests.md @@ -49,46 +49,6 @@ cargo test --test init_sync # just the init/sync tests cargo test --test dispatch # just the CLI dispatch tests ``` -## Agent contract tests - -`tests/agent_plugin_contract.rs` answers one question the rest of the suite cannot: does the agent -*actually load and use* what symposium installed? Asserting on the bytes we write is not enough — a -Copilot install once passed a unit test that reproduced its own CLI's output exactly, and Copilot -still ignored the plugin, because the missing piece was a record Copilot keeps for itself. - -These are **opt-in**, because they write into your real agent configuration: - -```bash -SYMPOSIUM_AGENT_CONTRACT=1 cargo test --test agent_plugin_contract -- --test-threads=1 -``` - -Real configuration is the only place a real agent reads. Redirecting `HOME` would isolate it but also -cut the agent off from its credentials, which live under that same directory. So every file the tests -touch is snapshotted first and restored on the way out, including on panic, and every directory they -copy into is removed. - -Both scopes are covered, because they take different paths: a global plugin is enabled in the user's -own settings and compiled under the symposium home, while a project-scoped one is enabled in the -*project's* settings, compiled under the project, and registered under a marketplace named for that -workspace. Claude Code is the only agent that can bound a plugin to a project; for the rest, the suite -checks that a project-scoped plugin still reaches them as individual skills. - -Each agent runs the same three checks — install, update, remove — driven through that agent's own CLI: - -| Check | Assertion | -|---|---| -| install | the agent invokes the skill and returns its token | -| update | after the source changes, the agent returns the *new* token | -| remove | once the plugin stops applying, the agent no longer has the skill | - -The probe plugin is deliberately dormant, activated only by a `use` entry, so that removing that entry -genuinely deactivates it. A `depends-on = ["*"]` probe would stay active regardless and the removal -check would prove nothing. - -An agent whose CLI is missing or cannot authenticate is **skipped with the reason printed**, never -failed: a missing login is not a symposium bug. A green run therefore does not by itself mean every -agent was covered — read the skips. - ## Debugging test failures Add `--nocapture` to see test output (agent messages, hook traces): diff --git a/src/agents/plugin_install.rs b/src/agents/plugin_install.rs index 3f53727b..6a80a37f 100644 --- a/src/agents/plugin_install.rs +++ b/src/agents/plugin_install.rs @@ -344,6 +344,16 @@ fn copilot_recorded_plugins(home: &Path) -> BTreeSet { /// Best-effort: a missing or failing `copilot` leaves the config we wrote in /// place, and the next sync tries again. +/// +/// Not run under `cargo test`. Spawning the developer's own agent CLI from a +/// unit test would make the suite depend on which binaries happen to be +/// installed, and on their being fast and non-interactive. Everything around +/// this call is tested; that Copilot then loads the plugin was established by +/// asking the running agent. +#[cfg(test)] +fn run_copilot(_home: &Path, _args: &[&str]) {} + +#[cfg(not(test))] fn run_copilot(home: &Path, args: &[&str]) { let result = std::process::Command::new("copilot") .args(args) diff --git a/src/agents/plugin_install/tests.rs b/src/agents/plugin_install/tests.rs index 3482aacf..9d2ba85a 100644 --- a/src/agents/plugin_install/tests.rs +++ b/src/agents/plugin_install/tests.rs @@ -441,17 +441,15 @@ fn copilots_own_record_is_read_leniently() { } #[test] -fn a_missing_copilot_binary_does_not_fail_the_install() { +fn the_config_and_copy_land_without_driving_the_copilot_cli() { let tmp = tempfile::tempdir().expect("tmp"); let home = tmp.path().join("home"); let root = tmp.path().join("staging"); let one = staged_plugin(&root, "pdf-tools"); - // `run_copilot` spawns by name, so an absent binary surfaces as a spawn - // error. Proven directly rather than by manipulating PATH, which is - // process-global and would race other tests. - run_copilot(&home, &["definitely-not-a-subcommand"]); - + // `run_copilot` is a no-op in tests, so this is the whole of what symposium + // writes for itself: whether Copilot then records the plugin is Copilot's + // half, and driving its CLI from a unit test is what we do not do. let written = Agent::Copilot .install_plugins( ®istration(&root, "symposium", &[&one], Scope::Global), @@ -459,10 +457,9 @@ fn a_missing_copilot_binary_does_not_fail_the_install() { tmp.path(), Duration::ZERO, ) - .expect("the config and copy still land even if the CLI cannot be driven"); + .expect("install"); + assert_eq!(written.len(), 1); - assert!( - home.join(".copilot/settings.json").is_file(), - "settings are ours to write, and do not depend on the binary" - ); + assert!(home.join(".copilot/settings.json").is_file()); + assert!(written[0].join("skills/probe/SKILL.md").is_file()); } diff --git a/tests/agent_plugin_contract.rs b/tests/agent_plugin_contract.rs deleted file mode 100644 index 450ec6c9..00000000 --- a/tests/agent_plugin_contract.rs +++ /dev/null @@ -1,470 +0,0 @@ -//! Agent contract tests: does the agent *actually load and use* what symposium -//! installed? -//! -//! This is the only level that can catch a plausible file in a plausible place -//! that the agent ignores — the failure mode that shipped a broken Copilot -//! install past a green unit test asserting the exact bytes Copilot's own CLI -//! writes. What was missing there was a fact about Copilot, not about us. -//! -//! **Opt in with `SYMPOSIUM_AGENT_CONTRACT=1`.** These tests write into the -//! developer's real agent configuration, because that is the only place a real -//! agent reads: redirecting `HOME` isolates the config but also cuts the agent -//! off from its credentials, which are stored under it. Every file touched is -//! snapshotted first and restored on the way out, including on panic. -//! -//! Each agent is skipped, not failed, when its CLI is absent or cannot -//! authenticate. A skip prints why, so a green run never silently means -//! "verified nothing". - -use std::path::{Path, PathBuf}; -use std::process::Command; - -/// The skill reports this, and only a skill that actually ran can produce it. -const TOKEN_V1: &str = "CONTRACT-TOKEN-A1B2"; -/// What it reports after the source is edited, proving an update propagated. -const TOKEN_V2: &str = "CONTRACT-TOKEN-C3D4"; - -const ASK: &str = "Do you have a skill named contract-check? If so, invoke it and \ - report only the token it gives you. Otherwise answer NONE."; - -fn enabled() -> bool { - std::env::var("SYMPOSIUM_AGENT_CONTRACT").is_ok_and(|v| !v.is_empty() && v != "0") -} - -fn home() -> PathBuf { - PathBuf::from(std::env::var("HOME").expect("HOME")) -} - -/// How to drive one agent, and what of its configuration we may disturb. -struct AgentUnderTest { - /// Symposium's name for it, as `init --add-agent` takes it. - name: &'static str, - /// The binary to run. - bin: &'static str, - /// Arguments that make it answer one prompt and exit. - prompt_args: &'static [&'static str], - /// Configuration files symposium writes for this agent. - config_files: Vec, - /// Directories symposium copies into for this agent. - content_dirs: Vec, -} - -fn agents() -> Vec { - let h = home(); - vec![ - AgentUnderTest { - name: "claude", - bin: "claude", - prompt_args: &["-p"], - config_files: vec![ - h.join(".claude/settings.json"), - h.join(".claude/plugins/known_marketplaces.json"), - ], - content_dirs: vec![], - }, - AgentUnderTest { - name: "codex", - bin: "codex", - prompt_args: &["exec", "--skip-git-repo-check"], - config_files: vec![h.join(".codex/config.toml")], - content_dirs: vec![h.join(".codex/plugins/cache/symposium")], - }, - AgentUnderTest { - name: "copilot", - bin: "copilot", - prompt_args: &["-p"], - config_files: vec![ - h.join(".copilot/settings.json"), - h.join(".copilot/config.json"), - ], - content_dirs: vec![h.join(".copilot/installed-plugins/symposium")], - }, - AgentUnderTest { - name: "gemini", - bin: "gemini", - prompt_args: &["-p"], - config_files: vec![], - content_dirs: vec![h.join(".gemini/extensions/contract-probe")], - }, - ] -} - -/// Snapshots every path it is given and puts them back when dropped, so a -/// failing assertion cannot leave the developer's agent configuration altered. -struct ConfigGuard { - files: Vec<(PathBuf, Option>)>, - dirs: Vec, -} - -impl ConfigGuard { - fn snapshot(agent: &AgentUnderTest) -> Self { - let files = agent - .config_files - .iter() - .map(|path| (path.clone(), std::fs::read(path).ok())) - .collect(); - Self { - files, - dirs: agent.content_dirs.clone(), - } - } -} - -impl Drop for ConfigGuard { - fn drop(&mut self) { - for dir in &self.dirs { - let _ = std::fs::remove_dir_all(dir); - } - for (path, original) in &self.files { - match original { - Some(bytes) => { - let _ = std::fs::write(path, bytes); - } - // It did not exist before this test, so it must not now. - None => { - let _ = std::fs::remove_file(path); - } - } - } - } -} - -/// Which scope the probe plugin should be enabled at. -/// -/// The two take genuinely different paths: a global plugin is enabled in the -/// user's own settings and compiled under the symposium home, while a -/// project-scoped one is enabled in the *project's* settings, compiled under the -/// project, and registered under a marketplace named for that workspace. -#[derive(Clone, Copy, PartialEq)] -enum ProbeScope { - Global, - Project, -} - -/// A fixture symposium home holding one plugin whose single skill reports a token. -struct Fixture { - _tempdir: tempfile::TempDir, - sym_home: PathBuf, - cwd: PathBuf, - skill: PathBuf, -} - -impl Fixture { - fn build(agent: &str) -> Self { - Self::build_scoped(agent, ProbeScope::Global) - } - - fn build_scoped(agent: &str, scope: ProbeScope) -> Self { - let tempdir = tempfile::tempdir().expect("tempdir"); - let root = tempdir.path().to_path_buf(); - let sym_home = root.join("symposium-home"); - let skill_dir = sym_home.join("plugins/contract-probe/skills/contract-check"); - std::fs::create_dir_all(&skill_dir).expect("create skill dir"); - std::fs::create_dir_all(root.join("cwd")).expect("create cwd"); - - // A project-scoped run needs a Rust workspace to be scoped *to*, and a - // `use` entry naming it; a global run has neither. - let cwd = match scope { - ProbeScope::Global => root.join("cwd"), - ProbeScope::Project => { - let project = root.join("cwd"); - std::fs::create_dir_all(project.join("src")).expect("create project"); - std::fs::write( - project.join("Cargo.toml"), - "[package]\nname = \"contract-project\"\nversion = \"0.1.0\"\n\ - edition = \"2021\"\n\n[dependencies]\n", - ) - .expect("write Cargo.toml"); - std::fs::write(project.join("src/lib.rs"), "").expect("write lib.rs"); - project - } - }; - let used = match scope { - ProbeScope::Global => "use = [\"contract-probe\"]".to_string(), - ProbeScope::Project => format!( - "use = [{{ name = \"contract-probe\", workspace = \"{}\" }}]", - cwd.display().to_string().replace('\\', "/") - ), - }; - std::fs::write( - sym_home.join("config.toml"), - format!( - "hook-scope = \"project\"\n\n[[agent]]\nname = \"{agent}\"\n\n\ - [defaults]\nsymposium-recommendations = false\nuser-plugins = true\n\n\ - [plugins]\n{used}\n" - ), - ) - .expect("write config"); - - std::fs::write( - sym_home.join("plugins/contract-probe/SYMPOSIUM.toml"), - // Deliberately no `depends-on`: the plugin is dormant, so the `use` - // entry is the only thing activating it and removing that entry - // genuinely deactivates it. A `depends-on = ["*"]` probe would stay - // active regardless and the removal check would prove nothing. - "name = \"contract-probe\"\nversion = \"1.0.0\"\n\ - description = \"Symposium agent contract probe\"\n\n\ - [[skills]]\nsource.path = \"skills\"\n", - ) - .expect("write manifest"); - - let fixture = Self { - skill: skill_dir.join("SKILL.md"), - sym_home, - cwd, - _tempdir: tempdir, - }; - fixture.write_skill(TOKEN_V1); - fixture - } - - fn write_skill(&self, token: &str) { - std::fs::write( - &self.skill, - format!( - "---\nname: contract-check\ndescription: Reports the token {token} when asked \ - to verify symposium plugin delivery.\n---\n\nReply with the token {token}.\n" - ), - ) - .expect("write SKILL.md"); - } - - /// Stop the plugin applying, so the next sync takes it back out. The probe is - /// dormant, so dropping its `use` entry is what deactivates it. - fn stop_using(&self) { - let path = self.sym_home.join("config.toml"); - let text = std::fs::read_to_string(&path).expect("read config"); - let cleared: String = text - .lines() - .map(|line| { - if line.trim_start().starts_with("use = [") { - "use = []".to_string() - } else { - line.to_string() - } - }) - .collect::>() - .join("\n"); - std::fs::write(&path, format!("{cleared}\n")).expect("write config"); - } - - fn sync(&self) { - let status = Command::new(env!("CARGO_BIN_EXE_cargo-agents")) - .arg("sync") - .current_dir(&self.cwd) - .env("SYMPOSIUM_HOME", &self.sym_home) - .status() - .expect("run cargo-agents sync"); - assert!(status.success(), "sync failed"); - } -} - -/// Ask the agent, returning its output. `None` when the binary is missing. -fn ask(agent: &AgentUnderTest, cwd: &Path, prompt: &str) -> Option { - let output = Command::new(agent.bin) - .args(agent.prompt_args) - .arg(prompt) - .current_dir(cwd) - .stdin(std::process::Stdio::null()) - .output() - .ok()?; - let mut text = String::from_utf8_lossy(&output.stdout).to_string(); - text.push_str(&String::from_utf8_lossy(&output.stderr)); - Some(text) -} - -/// Is the agent installed and able to answer at all? A skip here is honest; a -/// failure would blame symposium for a missing login. -fn usable(agent: &AgentUnderTest, cwd: &Path) -> Result<(), String> { - match ask(agent, cwd, "Reply with only the word READY.") { - None => Err(format!("`{}` is not installed", agent.bin)), - Some(text) if text.contains("READY") => Ok(()), - Some(text) => { - let hint = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); - Err(format!( - "`{}` could not answer (not authenticated?): {hint}", - agent.bin - )) - } - } -} - -/// C1 install, C2 update, C3 remove — the whole contract for one agent. -fn contract(agent: &AgentUnderTest) { - contract_at(agent, ProbeScope::Global) -} - -fn contract_at(agent: &AgentUnderTest, scope: ProbeScope) { - let fixture = Fixture::build_scoped(agent.name, scope); - - if let Err(why) = usable(agent, &fixture.cwd) { - eprintln!("SKIP {}: {why}", agent.name); - return; - } - - let _guard = ConfigGuard::snapshot(agent); - - // C1 — installed, and the agent runs it. - fixture.sync(); - let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); - assert!( - answer.contains(TOKEN_V1), - "{}: expected {TOKEN_V1} after install, got:\n{answer}", - agent.name - ); - - // C2 — the source changes and the agent sees the new content. - fixture.write_skill(TOKEN_V2); - fixture.sync(); - let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); - assert!( - answer.contains(TOKEN_V2) && !answer.contains(TOKEN_V1), - "{}: expected {TOKEN_V2} after an edit, got:\n{answer}", - agent.name - ); - - // C3 — it stops applying and the agent no longer has it. - fixture.stop_using(); - fixture.sync(); - let answer = ask(agent, &fixture.cwd, ASK).expect("agent ran"); - assert!( - !answer.contains(TOKEN_V1) && !answer.contains(TOKEN_V2), - "{}: the skill should be gone, got:\n{answer}", - agent.name - ); -} - -fn run_for(name: &str) { - if !enabled() { - eprintln!("SKIP {name}: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); - return; - } - let agent = agents() - .into_iter() - .find(|a| a.name == name) - .expect("known agent"); - contract(&agent); -} - -#[test] -fn claude_code_honors_the_install_contract() { - run_for("claude"); -} - -/// Claude Code is the only agent that can bound a plugin to one project, and -/// that path differs from the global one: the enablement goes into the project's -/// own settings, and the marketplace is named for the workspace so two projects -/// cannot overwrite each other's registration. -#[test] -fn claude_code_honors_the_contract_at_project_scope() { - if !enabled() { - eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); - return; - } - let agent = agents() - .into_iter() - .find(|a| a.name == "claude") - .expect("known agent"); - contract_at(&agent, ProbeScope::Project); -} - -#[test] -fn codex_honors_the_install_contract() { - run_for("codex"); -} - -#[test] -fn copilot_honors_the_install_contract() { - run_for("copilot"); -} - -#[test] -fn gemini_honors_the_install_contract() { - run_for("gemini"); -} - -/// An agent that cannot bound a plugin to one project still receives that -/// plugin's skills, individually, under the project. -/// -/// This is the claim that the per-skill path stays primary for project-scoped -/// plugins on every agent but Claude Code. Asserting the file lands is our half; -/// asking the agent is theirs. -#[test] -fn a_project_scoped_plugin_reaches_codex_as_a_plain_skill() { - if !enabled() { - eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); - return; - } - let agent = agents() - .into_iter() - .find(|a| a.name == "codex") - .expect("known agent"); - - let fixture = Fixture::build_scoped(agent.name, ProbeScope::Project); - if let Err(why) = usable(&agent, &fixture.cwd) { - eprintln!("SKIP {}: {why}", agent.name); - return; - } - let _guard = ConfigGuard::snapshot(&agent); - - fixture.sync(); - let installed = fixture.cwd.join(".agents/skills/contract-check/SKILL.md"); - assert!( - installed.is_file(), - "codex cannot scope a plugin to a project, so the skill has to arrive on its own at {}", - installed.display() - ); - assert!( - !fixture - .cwd - .join(".symposium/plugins/contract-probe") - .exists() - || fixture.cwd.join(".symposium/plugins").exists(), - "the compiled directory is only built for agents that can take it" - ); - - let answer = ask(&agent, &fixture.cwd, ASK).expect("agent ran"); - assert!( - answer.contains(TOKEN_V1), - "codex should read the project's own skills directory, got:\n{answer}" - ); -} - -/// An agent with no plugin unit still receives the skill on its own, and that -/// path has to keep working now that the plugin-capable agents have left it. -/// -/// Outside a workspace those skills have nowhere project-scoped to go, so they -/// land in the agent's user-level skills directory — the only place a globally -/// enabled plugin can reach an agent that cannot take a compiled directory. -#[test] -fn an_agent_without_a_plugin_unit_still_receives_the_skill() { - if !enabled() { - eprintln!("SKIP: set SYMPOSIUM_AGENT_CONTRACT=1 to run agent contract tests"); - return; - } - - let installed = home().join(".agents/skills/contract-check"); - let opencode = AgentUnderTest { - name: "opencode", - bin: "opencode", - prompt_args: &[], - config_files: Vec::new(), - content_dirs: vec![installed.clone()], - }; - let _guard = ConfigGuard::snapshot(&opencode); - - let fixture = Fixture::build(opencode.name); - fixture.sync(); - assert!( - installed.join("SKILL.md").is_file(), - "OpenCode has no plugin unit, so the skill has to arrive on its own at {}", - installed.display() - ); - - fixture.stop_using(); - fixture.sync(); - assert!( - !installed.exists(), - "and it has to be reaped when the plugin stops applying" - ); -} From bdb987bfcf78489362579ceb07cb7b19665884a6 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 14:51:39 -0300 Subject: [PATCH 12/14] docs: trim the agent-plugin docs and fix a stale claim about driving agent CLIs --- md/design/agents.md | 2 +- md/design/important-flows.md | 30 +++++++++----------- md/design/module-structure.md | 52 ++++++++++++++++------------------- src/agent_plugin/mod.rs | 27 ++++++------------ src/agents/plugin_install.rs | 19 ++++++------- 5 files changed, 55 insertions(+), 75 deletions(-) diff --git a/md/design/agents.md b/md/design/agents.md index 817c3707..3b48ab93 100644 --- a/md/design/agents.md +++ b/md/design/agents.md @@ -29,7 +29,7 @@ Where these files go depends on whether the agent is configured at the user leve ### Plugin directories -An agent with a plugin unit receives a compiled directory instead of loose skill files. Only Claude Code can scope one to a project; for the others a project-scoped plugin falls back to the per-skill paths below, and OpenCode, Goose, and Kiro use those paths for everything. +An agent with a plugin unit receives a compiled directory instead of loose skill files. Only Claude Code can scope one to a project; for the others a project-scoped plugin falls back to the per-skill paths below. | Agent | How it is given the directory | Project scope | |-------|-------------------------------|---------------| diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 62a3d4aa..13f7e979 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -31,30 +31,26 @@ The key code paths are in `discovery.rs`, `config.rs` (`PluginsConfig`, `UseEntr ## Compilation and delivery of agent plugin directories -Every `cargo agents sync` compiles the plugins that apply into the directory unit agents consume, then hands each directory to the agents that can take it. The step runs after skills are resolved, so it never re-evaluates a gate. +Every `cargo agents sync` compiles the plugins that apply into the unit agents consume, then hands each directory to the agents that can take it. It runs after skills are resolved, so it never re-evaluates a gate. Outside a Rust workspace only the global half happens. -Run outside a Rust workspace, only the global half happens: a globally-enabled plugin with a workspace-independent gate is compiled and delivered, while everything project-scoped is skipped. +1. `agent_plugin::compile` groups applicable skills by their plugin's `canonical` id and builds one `CompiledPlugin` each: a slugged name, a version, the description, and one entry per distinct skill origin. Directory names and skill names are disambiguated with the same origin-hash suffix rule that governs skill installs. +2. `Scope::of` sends each to `/.symposium/plugins/` or `/installed/` — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for what global requires and why. A scope no configured agent can take is not compiled. +3. `agent_plugin::write` stages into a tempdir and syncs it in through `sync::sync_managed_dir`, so an unchanged plugin is not recopied. `write_marketplace` writes `.claude-plugin/marketplace.json` at each staging root, and removes it when the root empties. +4. For each configured agent and each scope it accepts, `Agent::install_plugins` writes that agent's configuration and, where the agent loads only from its own tree, copies the directory there. Plugins an agent received are recorded, so their skills are skipped in the per-skill loop and nothing arrives twice. +5. `agent_plugin::reap_to_depth` removes marked directories this sync did not write, under both staging roots and every known agent's plugin tree. Reaping the global root from a project sync is sound only because step 2 keeps the global set a function of user config alone. -1. `agent_plugin::compile` groups the applicable skills by their contributing plugin's `canonical` id and builds one `CompiledPlugin` each: a manifest name (slugged into the format's grammar), an optional version (the manifest's, else a crate plugin's resolved version — a registry placeholder `*` is not a version), the plugin's description, and one skill entry per distinct origin. -2. Directory names are disambiguated across plugins, and skill directory names within each plugin, using the same origin-hash suffix rule that already governs skill installs. -3. `Scope::of` sends each compiled plugin to `/.symposium/plugins/` or `/installed/`. Global requires both a `use --global` entry naming the plugin *and* every gate in its chain (plugin, groups, contributed skills) to hold workspace-independently — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for why the second half is a correctness requirement and not a preference. A scope no configured agent can take is not compiled at all. -4. `agent_plugin::write` stages the content in a temporary directory and syncs it in through `sync::sync_managed_dir`, so an unchanged plugin is not recopied. The directory gets the `.symposium` marker; the project tree's single `.gitignore` is written at `.symposium/` rather than into each plugin. -5. `write_marketplace` writes `.claude-plugin/marketplace.json` at each staging root, the one index path Claude Code, Codex, and Copilot all read, and removes it when a root holds no plugins. -6. For each configured agent and each scope the agent accepts, `Agent::install_plugins` writes that agent's configuration and, where the agent loads only from its own tree, copies the directory there. The plugins an agent received are recorded, and their skills are then skipped in the per-skill loop — so a skill is installed individually only for an agent that could not take its plugin, and nothing arrives twice. -7. `agent_plugin::reap_to_depth` removes marked directories this sync did not write, under both staging roots and under every known agent's plugin tree (so an agent dropped from the config is cleaned up too). Reaping the global root from a project sync is sound only because step 3 keeps the global set a function of user config alone. - -The key code paths are in `agent_plugin/mod.rs` (`compile`, `Scope::of`, `write`, `write_marketplace`, `reap_to_depth`), `agent_plugin/manifest.rs` (`slug`, `is_valid_name`, the three manifest shapes), `agents/plugin_install.rs` (`accepts_plugin_scope`, `install_plugins`, `plugin_reap_roots`), `predicate.rs` (`is_workspace_independent`), and `sync.rs`. +The key code paths are in `agent_plugin/mod.rs`, `agent_plugin/manifest.rs`, `agents/plugin_install.rs`, `predicate.rs` (`is_workspace_independent`), and `sync.rs`. ## Reading an externally authored package -A directory holding a `plugin.json` loads as an ordinary symposium plugin, so everything downstream — compilation, delivery, `status` — treats it like any other. +A directory holding a `plugin.json` loads as an ordinary symposium plugin, so compilation, delivery and `status` treat it like any other. -1. `pm::layout::classify` returns `EntryKind::AgentPlugin` for a directory carrying the manifest. Precedence runs `SYMPOSIUM.toml`, `plugin.json`, `SKILL.md`, so a directory with both TOML and JSON loads as a symposium plugin. A claimed directory is not descended into, so nesting a package inside a package is not a way to ship two; a source root that is itself a package is an error. -2. `agent_plugin::read::load` parses the manifest, reports unknown top-level fields and an unsupported `mcp.json`, reads the gate from `extensions["dev.symposium"]`, and returns a `Plugin` with one `skills/` group limited to immediate children. -3. The three positions call it: `plugins::load_entry` for a registry entry (dormancy applies), `workspace_plugin_for_dir` for a member, and `CargoPm::build_from_fetched` for a dependency (both gated by position, so no `use` entry is needed). `embedded_plugin_kind` counts a `plugin.json` as plugin content, so a dependency carrying one is offered for consent. -4. Containment is per unit: a bad manifest rejects that package alone, an unknown field is reported and ignored, a broken skill is skipped while the rest load, and a skill resolving outside the package is refused. +1. `pm::layout::classify` returns `EntryKind::AgentPlugin`. Precedence runs `SYMPOSIUM.toml`, `plugin.json`, `SKILL.md`. A claimed directory is not descended into, so a package cannot nest another; a source root that is itself a package is an error. +2. `agent_plugin::read::load` parses the manifest, reports unknown fields and an unsupported `mcp.json`, reads the gate from `extensions["dev.symposium"]`, and returns a `Plugin` with one `skills/` group limited to immediate children. +3. The three positions call it: `plugins::load_entry` (registry, dormancy applies), `workspace_plugin_for_dir` (member), and `CargoPm::build_from_fetched` (dependency) — the latter two gated by position. `embedded_plugin_kind` counts a `plugin.json`, so a dependency carrying one is offered for consent. +4. Containment is per unit: a bad manifest rejects that package alone, an unknown field is ignored, a broken skill is skipped, and a skill resolving outside the package is refused. -The key code paths are in `agent_plugin/read.rs`, `agent_plugin/manifest.rs` (`IncomingManifest`), `pm/layout.rs` (`classify`, `AGENT_PLUGIN_FILE`), `plugins.rs` (`load_entry`, `workspace_plugin_for_dir`, `apply_sibling_identity`, `dormant_without_gate`), and `skills.rs` (`discover_skills`, `SkillDepth`). +The key code paths are in `agent_plugin/read.rs`, `pm/layout.rs`, `plugins.rs` (`load_entry`, `workspace_plugin_for_dir`, `apply_sibling_identity`, `dormant_without_gate`), and `skills.rs` (`discover_skills`, `SkillDepth`). ## Help rendering diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 98512c81..da0e82ef 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,13 +24,11 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory) under the staging root its scope selects — but only for a scope some configured agent can actually take, since otherwise the directory would sit unread — hands each to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps both the compiled directories and the agent-side copies it did not write. The debounce that keeps the per-event hook path cheap is a decision the caller passes in (`Debounce::Recent` from the per-event hook path, `Debounce::Always` everywhere else), not something inferred from `UpdateLevel` — an explicit `cargo agents sync` defaults to `--update none`, so inferring it there meant editing a skill and re-running sync appeared to do nothing. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory), hands it to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps what it did not write — compiled directories and agent-side copies alike. A scope no configured agent can take is not compiled. `Debounce` is passed in rather than inferred from `UpdateLevel`: only the per-event hook path debounces, since an explicit `sync` defaults to `--update none` and would otherwise ignore a just-edited skill. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. One entry point, `sync(sym, deps, update, debounce)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. -Individually-installed skills go under the project when there is one and under the user's home when there is not, which is the only way a globally-enabled plugin's skills reach an agent that has no plugin unit to receive instead (`SkillHome`). - -**A workspace is optional.** Without one there is nothing project-scoped to install — no per-skill directories, no project staging root, no project hook registration — but globally-enabled plugins still apply, so the global half of the sync runs rather than the command refusing outright. `ProjectPaths` is the `Option` that carries the project root, its owned `.symposium/` directory, and its staging root together; every project-only step is guarded on it. `status` and a non-`--global` `use` still require a workspace, since each is about one. +**A workspace is optional.** Without one there is nothing project-scoped to install, but globally-enabled plugins still apply, so the global half runs rather than the command refusing. `ProjectPaths` carries the project root, its `.symposium/` directory and its staging root together, and every project-only step is guarded on it. `SkillHome` sends individually-installed skills under the project when there is one and under the user's home when there is not — the only way a global plugin's skills reach an agent with no plugin unit. `status` and a non-`--global` `use` still require a workspace. `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`. @@ -57,60 +55,56 @@ Workspace-scoped callers use `load_registry_with_workspace`, which additionally ### `agent_plugin/` — compiling an agent plugin directory -Turns an already-gated plugin into the unit agents themselves consume: a manifest beside a `skills/` directory, per the [Agent Plugins](https://agent-plugins.org/) format. Because every predicate has been evaluated by the time compilation runs, the emitted directory holds only what applies — an agent never receives a gate and never resolves one. +Turns an already-gated plugin into the unit agents consume: a manifest beside a `skills/` directory, per the [Agent Plugins](https://agent-plugins.org/) format. Predicates are all evaluated by then, so the directory holds only what applies and an agent never sees a gate. + +One directory serves every agent, because their formats differ only in which manifest they read: Claude Code reads `.claude-plugin/plugin.json` and ignores a root `plugin.json`, Agent Plugins agents do the reverse, Gemini reads only `gemini-extension.json`. All three are written side by side, plus `.claude-plugin/marketplace.json` at the staging root — the one index Claude Code, Codex and Copilot all accept. The manifest always carries a `version` (`UNVERSIONED` = `0.0.0` when unknown), because Codex keys its cache directory on it and would otherwise pick `1.0.0` itself. -`manifest.rs` models the three manifests one compiled directory carries and owns the format's **name grammar** (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars). That grammar is narrower than a symposium plugin name, which may be a crate name with underscores or a free-form manifest string, so `slug` normalizes one into the other. Two distinct names can slug alike (`foo_bar` and `foo-bar`), which is why directory disambiguation keys on the *slug*, not the original name. +`manifest.rs` owns the format's name grammar (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars), which is narrower than a symposium plugin name, so `slug` normalizes one into the other. Two names can slug alike (`foo_bar`, `foo-bar`), so directory disambiguation keys on the *slug*. -`compile(active, skills, plugins)` groups the applicable skills by their contributing plugin's `canonical` id — the name is only a display label, since two registries can supply the same one. A plugin with no applicable skills compiles to nothing (version one carries only the skills component, so the directory would be empty). Skills sharing a name *within* one plugin take an origin-hash suffix; across plugins they do not collide, because agents namespace a plugin's skills under the plugin (`pdf-tools:extract-tables`). When more than one plugin claims a directory name, every claimant takes the suffixed form, so a name stays stable as unrelated plugins come and go. +`compile` groups applicable skills by their plugin's `canonical` id, not its name — two registries can supply the same name. A plugin with no applicable skills compiles to nothing. Skills sharing a name within one plugin take an origin-hash suffix; across plugins they cannot collide, since agents namespace them (`pdf-tools:extract-tables`). When several plugins claim a directory name, every claimant takes the suffixed form, so a name stays stable as unrelated plugins come and go. One bundle referenced by several plugins is emitted once, by the first claimant — emitting per plugin would load identical guidance N times. -`Scope::of` decides which of two staging roots a plugin belongs in: +`Scope::of` picks the staging root: | Scope | Root | | |---|---|---| -| `Project` | `/.symposium/plugins/` | symposium owns the whole `.symposium/` tree, so one `.gitignore` with `*` sits at its root rather than one per directory | -| `Global` | `/installed/` | deliberately **not** `plugins/`, which is the builtin `user-plugins` registry — compiling there would make symposium ingest its own output as registry plugins on the next load | - -Global is the narrow case, and needs two independent things to hold. First the user must have asked for it, with a `use --global` entry naming the plugin: scope follows the enablement, so a project's sync never writes a plugin into the user's home that was not enabled there, not even one gated `depends-on(*)`. Second, nothing about the plugin may vary by workspace — it is not a workspace member, not crate-sourced, and its own gate, every declared skill group's gate, and every contributed skill's gate all hold workspace-independently. That second half is a safety property rather than a preference: a user-level directory is visible from every workspace while cleanup reaps whatever it did not install this run, so a global set that varied by workspace would have two projects undoing each other on every session start. The group and skill clauses matter as much as the plugin's own — a plugin gated `depends-on(*)` whose group is gated `depends-on(serde)` would compile to different content in different projects, which is the same churn by another route. - -One skill bundle referenced by several active plugins is emitted **once**, by the first plugin to claim it. A plugin directory is its own namespace, so emitting per plugin would not collide, but it would load identical guidance once per referencing plugin. - -One directory serves every agent, because their formats differ only in which manifest they read. Claude Code ignores a root `plugin.json` (falling back to the directory name for identity) and Agent Plugins agents ignore `.claude-plugin/`, so carrying both costs nothing; Gemini reads only its own file. All three are written side by side, and `write_marketplace` adds `.claude-plugin/marketplace.json` at the staging root — the one index path Claude Code, Codex, and Copilot all accept. Each was verified by loading a directory in the running agent, not by reading documentation. +| `Project` | `/.symposium/plugins/` | symposium owns the whole `.symposium/` tree, so one `.gitignore` sits at its root | +| `Global` | `/installed/` | deliberately **not** `plugins/`, the builtin `user-plugins` registry — compiling there would make symposium ingest its own output | -The manifest always carries a `version`, even though the format allows omitting one, because Codex keys its plugin cache directory on the version and picks `1.0.0` itself for a version-less plugin. Emitting one means the cache path is the value symposium wrote. `UNVERSIONED` (`0.0.0`) stands in when a plugin declares none. +Global needs two things: a `use --global` entry naming the plugin, and nothing about it varying by workspace (not a workspace member, not crate-sourced, and its own gate, every declared group's gate and every contributed skill's gate all workspace-independent). The second half is correctness, not preference: a user-level directory is visible everywhere while cleanup reaps what it did not install, so a global set that varied by workspace would have two projects undoing each other every session. -`write` assembles the directory in a temporary directory and hands it to `sync::sync_managed_dir`, so the install is change-aware and debounced exactly like a skill directory: recompiling identical content leaves the destination untouched. `reap_to_depth` removes marked directories the current sync did not write, keyed on the `.symposium` marker so a directory the user placed there is left alone; the depth lets one function serve both a staging root and an agent's own tree, where Codex nests copies as `//`. +`write` assembles the directory in a tempdir and hands it to `sync::sync_managed_dir`, so recompiling identical content leaves the destination untouched. `reap_to_depth` removes marked directories this sync did not write; the depth lets one function serve both a staging root and an agent's own tree, where Codex nests copies as `//`. ### `agent_plugin/read.rs` — reading an externally authored package -A directory holding a `plugin.json` is a third kind of plugin entry beside one holding a `SYMPOSIUM.toml` and one holding a bare `SKILL.md`, recognized in the same three positions with each position keeping its meaning: a registry entry is curated but ungated, a workspace member is gated by membership, a dependency is an untrusted offer subject to consent. +A directory holding a `plugin.json` is a third entry kind beside `SYMPOSIUM.toml` and a bare `SKILL.md`, recognized in the same three positions, each keeping its meaning: a registry entry is curated but ungated, a workspace member is gated by membership, a dependency is an untrusted offer subject to consent. -`IncomingManifest` is the read counterpart of the [`Manifest`](#agent_plugin--compiling-an-agent-plugin-directory) symposium writes, and the two are deliberately separate: an incoming package may carry fields we never emit. Unknown top-level keys are captured through a flattened map and reported rather than rejected, since the format asks a client to tolerate what it does not recognize. A name that breaks the format's grammar does reject the package, because a package with an unusable identity cannot be installed anywhere. +`IncomingManifest` is separate from the `Manifest` symposium writes, since an incoming package may carry fields we never emit. Unknown top-level keys are reported and ignored, as the format asks; a name breaking its grammar rejects the package, which could not be installed anywhere. -The format cannot express *when* a package applies, so gating comes from `extensions["dev.symposium"]` (`depends-on` and `predicates`, in the same syntax a `SYMPOSIUM.toml` gate uses — the existing deserializers read them straight from JSON). Other namespaces are ignored without being inspected, as the format requires. A malformed `dev.symposium` object *is* an error: it was written for symposium, so ignoring it would activate the package more widely than its author asked. A package declaring no gate is dormant under the ordinary rule, unless its position already gates it. +The format cannot say *when* a package applies, so the gate comes from `extensions["dev.symposium"]` (`depends-on` and `predicates`, read straight from JSON by the existing deserializers). Other namespaces are ignored uninspected. A malformed `dev.symposium` *is* an error — it was written for us, so ignoring it would activate the package more widely than its author asked. No gate means dormant, unless the position already gates it. -Skills map without adaptation except in one respect: the format fixes `skills/` at one level, so the group carries `SkillDepth::ImmediateChildren` while every symposium-declared group keeps the recursive walk. Discovery also refuses a skill whose real path leaves the directory it was found in — a symlink out would otherwise be read here and silently dropped at install time, since the copy ignores symlinks, producing an empty skill rather than a reported one. +Skills map unchanged except for depth: the format fixes `skills/` at one level, so the group carries `SkillDepth::ImmediateChildren`. Discovery also refuses a skill whose real path leaves its directory — a symlink out would be read here and then silently dropped by the copy, giving an empty skill instead of a reported one. -A directory carrying both manifests loads as a symposium plugin, `SYMPOSIUM.toml` being the richer one, but `sibling_identity` fills the name, version, and description the TOML omits so an author need not repeat what they already declared portably. A broken companion is reported and ignored rather than rejecting the plugin the TOML defines. +A directory carrying both manifests loads as a symposium plugin, but `sibling_identity` fills the name, version and description the TOML omits. A broken companion is reported and ignored rather than rejecting the plugin the TOML defines. ### `agents/plugin_install.rs` — handing a directory to an agent -Two mechanisms, and which one applies is a property of the agent. Each row below was established by installing a directory and asking the running agent what it could see, then deleting parts of the installation to find what was actually required: +Two mechanisms, and which applies is a property of the agent. Every row was established by installing a directory, asking the running agent what it could see, then deleting parts to find what was actually required: | Agent | Configuration symposium writes | Content | |---|---|---| | Claude Code | `extraKnownMarketplaces` in user settings, an entry in `~/.claude/plugins/known_marketplaces.json`, and `enabledPlugins` in the project's `.claude/settings.json` (project scope) or user settings (global) | **not copied** — resolved from the registered `installLocation` | | Codex CLI | `[marketplaces.]` and `[plugins."@"] enabled` in `config.toml` | copied to `plugins/cache////` | -| Copilot CLI | `extraKnownMarketplaces` and `enabledPlugins` in `~/.copilot/settings.json`, then `copilot plugin install` for the record it keeps itself | copied to `installed-plugins///` | +| Copilot CLI | `extraKnownMarketplaces` and `enabledPlugins` in `~/.copilot/settings.json`, then `copilot plugin install` | copied to `installed-plugins///` | | Gemini CLI | none at all | copied to `~/.gemini/extensions//` | | Kiro, OpenCode, Goose | none | no plugin unit; skills keep arriving individually | -Copilot is the one agent where symposium drives the CLI rather than writing every file. It treats a plugin as installed only once it appears in its machine-managed `~/.copilot/config.json`, and that record carries a `source_sha` Copilot computes itself; guessing it would couple us to an internal we cannot verify. So the settings entries and the copy go in as usual, and then `copilot plugin install` is run for any plugin not already recorded — it needs no terminal, and the marketplace registration it resolves against is in place by then. This was found by asking the running agent: with the settings entries and the copy present but no such record, Copilot reported the skill as absent. +Claude Code needs *both* its records — without `known_marketplaces.json` the plugin does not load, and Claude regenerates it from settings only in time for the next session — but not its `installed_plugins.json` entry or cache copy. -Claude Code needs both of its records: with `known_marketplaces.json` missing the plugin does not load, and Claude regenerates it from settings only in time for the *next* session. Its `installed_plugins.json` record and version-keyed cache copy are **not** required — deleting them leaves the plugin working. +Copilot is the one agent whose CLI symposium drives rather than writing every file: it counts a plugin as installed only once it appears in its machine-managed `config.json`, in a record carrying a `source_sha` it computes itself. `run_copilot` is a no-op under `cfg(test)`, so no test spawns it. -`accepts_plugin_scope` is where the project-scope asymmetry lives: only Claude Code can bound a plugin to one project. The other three store plugins per user with no way to scope them, so a project-scoped plugin reaches them through the per-skill path instead. A skill is installed individually only for agents that did *not* receive its plugin, so nothing arrives twice. +`accepts_plugin_scope` holds the asymmetry: only Claude Code can bound a plugin to one project, so for the others a project-scoped plugin arrives through the per-skill path. A skill is installed individually only for agents that did *not* receive its plugin, so nothing arrives twice. -Registration is user-level even for a project-scoped plugin, which is why `marketplace_name` gives each project root a name of its own (`symposium--`); two projects registering `symposium` would otherwise overwrite each other's path. Entries are reconciled rather than appended: an entry for a plugin that no longer applies is dropped, while an entry whose marketplace symposium does not own is never touched. Copies carry the ownership marker, so `plugin_reap_roots` plus `reap_to_depth` clean up an agent's own tree the same way a staging root is cleaned. +Registration is user-level even for a project-scoped plugin, so `marketplace_name` gives each project its own (`symposium--`) rather than letting two projects overwrite one entry. Entries are reconciled: one of ours that no longer applies is dropped, one from a marketplace we do not own is never touched. Copies carry the ownership marker, so `plugin_reap_roots` plus `reap_to_depth` clean an agent's tree like a staging root. ### `installation.rs` — sources and acquisition diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index 6b57d4f0..f172a760 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -59,25 +59,16 @@ pub enum Scope { } impl Scope { - /// Where a plugin's compiled directory belongs. + /// Where a plugin's compiled directory belongs. Global needs both a + /// `use --global` entry naming it and every gate in its chain — the plugin's, + /// each declared group's, each contributed skill's — to hold + /// workspace-independently; anything else is project-scoped. /// - /// Global needs two independent things to hold, and defaults to project when - /// either fails. - /// - /// First, the user has to have asked for it: a `use --global` entry naming the - /// plugin. Scope follows the enablement that selected a plugin, so a project's - /// sync never writes a plugin into the user's home directory that was not - /// enabled there — not even one gated `depends-on(*)`. - /// - /// Second, nothing about the plugin may vary by workspace. A user-level - /// directory is visible from every workspace while cleanup reaps whatever it - /// did not install this run, so a global set that varied by workspace would - /// have two projects undoing each other on every session start. Content - /// counts as much as activation here: a plugin gated `depends-on(*)` whose - /// skill group is gated `depends-on(serde)` would compile to different - /// content per project, which is the same churn by another route. So every - /// gate in the chain must hold workspace-independently — the plugin's, each - /// declared group's, and each contributed skill's. + /// The second half is correctness, not preference: a user-level directory is + /// visible everywhere while cleanup reaps what it did not install this run, + /// so a global set that varied by workspace would have two projects undoing + /// each other every session. Content counts as much as activation, hence the + /// group and skill gates. pub fn of( parsed: &ParsedPlugin, contributed: &[&SkillWithGroupContext], diff --git a/src/agents/plugin_install.rs b/src/agents/plugin_install.rs index 6a80a37f..1b7f36bc 100644 --- a/src/agents/plugin_install.rs +++ b/src/agents/plugin_install.rs @@ -1,18 +1,17 @@ //! Handing a compiled plugin directory to an agent. //! -//! Two mechanisms exist, and which applies is a property of the agent, verified -//! by installing a directory and asking the running agent what it can see: +//! Two mechanisms, and which applies is a property of the agent, established by +//! installing a directory and asking the running agent what it can see: //! //! - **Registered** — the agent is pointed at the staging root and reads it in -//! place. Only Claude Code does this, and it is also the only agent that can -//! express a project-scoped plugin. -//! - **Copied** — the agent loads only from its own directory, so the content is -//! copied there. Codex CLI, Copilot CLI, and Gemini CLI all require this; -//! deleting the copy makes the skill disappear. +//! place. Only Claude Code, which is also the only agent that can express a +//! project-scoped plugin. +//! - **Copied** — the agent loads only from its own tree. Codex, Copilot and +//! Gemini all require this; deleting the copy makes the skill disappear. //! -//! Symposium writes each agent's configuration itself rather than driving the -//! agent's own install command, which is what it already does for hooks and MCP -//! entries and the only option on the auto-sync path, where there is no terminal. +//! Symposium writes each agent's configuration itself, as it already does for +//! hooks and MCP entries, since the auto-sync path has no terminal to prompt at. +//! Copilot is the one exception, and [`reconcile_copilot_records`] says why. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; From 49d916c40c3b46ee575d1ecef9a239086edd96f4 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 16:11:31 -0300 Subject: [PATCH 13/14] fix(agent-plugin): correct plugin identity and skill dedup --- src/agent_plugin/manifest.rs | 19 ++++++++++++++ src/agent_plugin/mod.rs | 17 +++++++++++-- src/agent_plugin/tests.rs | 49 ++++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/agent_plugin/manifest.rs b/src/agent_plugin/manifest.rs index f0ef686b..edd04657 100644 --- a/src/agent_plugin/manifest.rs +++ b/src/agent_plugin/manifest.rs @@ -232,6 +232,25 @@ pub fn slug(name: &str) -> Option { (!capped.is_empty()).then_some(capped) } +/// Append a disambiguating suffix to an already-slugged name, keeping the +/// result inside the grammar's length limit. +/// +/// The suffix goes on the *manifest* name as well as the directory, because a +/// plugin's agent-facing identity (its enablement key, and the cache path Codex +/// and Copilot derive) is the manifest name. +pub fn suffixed(slugged: &str, hash: &str) -> String { + let room = MAX_NAME_LEN.saturating_sub(hash.len() + 1); + let base = if slugged.len() > room { + trim_to_alnum(&slugged[..room]) + } else { + slugged.to_string() + }; + if base.is_empty() { + return hash.to_string(); + } + format!("{base}-{hash}") +} + fn trim_to_alnum(s: &str) -> String { s.trim_matches(|c: char| !(c.is_ascii_lowercase() || c.is_ascii_digit())) .to_string() diff --git a/src/agent_plugin/mod.rs b/src/agent_plugin/mod.rs index f172a760..453a34c4 100644 --- a/src/agent_plugin/mod.rs +++ b/src/agent_plugin/mod.rs @@ -154,6 +154,13 @@ pub fn compile( continue; }; + // Every skill this plugin declares may already have been claimed by an + // earlier one, so emptiness is only known after dedup. + let skills = compile_skills(&mine, &mut claimed); + if skills.is_empty() { + continue; + } + compiled.push(( crate::skills::hash_origin_key(&parsed.canonical.to_string()), CompiledPlugin { @@ -165,7 +172,7 @@ pub fn compile( parsed.plugin.description.clone(), ), scope: Scope::of(parsed, &mine, plugins), - skills: compile_skills(&mine, &mut claimed), + skills, }, )); } @@ -177,6 +184,10 @@ pub fn compile( /// one plugin claims a slug, every claimant takes the suffixed form. Suffixing /// all of them rather than all-but-one keeps a name stable when an unrelated /// plugin appears or disappears. +/// +/// The manifest name is suffixed alongside the directory: agents key a plugin +/// by its manifest name, so leaving that colliding would give two plugins one +/// enablement entry and one cache path. fn disambiguate(compiled: Vec<(String, CompiledPlugin)>) -> Vec { let mut claims: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new(); for (_, plugin) in &compiled { @@ -192,7 +203,9 @@ fn disambiguate(compiled: Vec<(String, CompiledPlugin)>) -> Vec .into_iter() .map(|(hash, mut plugin)| { if contested.contains(&plugin.dir_name) { - plugin.dir_name = format!("{}-{hash}", plugin.dir_name); + let name = manifest::suffixed(&plugin.dir_name, &hash); + plugin.dir_name = name.clone(); + plugin.manifest.name = name; } plugin }) diff --git a/src/agent_plugin/tests.rs b/src/agent_plugin/tests.rs index 0b0558a5..75c14412 100644 --- a/src/agent_plugin/tests.rs +++ b/src/agent_plugin/tests.rs @@ -238,10 +238,55 @@ fn names_that_slug_alike_are_both_suffixed() { assert!(manifest::is_valid_name(&plugin.dir_name)); } assert_ne!(compiled[0].dir_name, compiled[1].dir_name); + for plugin in &compiled { + assert_eq!( + plugin.manifest.name, plugin.dir_name, + "agents key a plugin by its manifest name, so it is suffixed too" + ); + assert!(manifest::is_valid_name(&plugin.manifest.name)); + } + assert_ne!(compiled[0].manifest.name, compiled[1].manifest.name); +} + +#[test] +fn a_suffixed_name_stays_within_the_length_limit() { + let long = "x".repeat(64); + let a = registry_plugin(&long, wildcard()); + let b = registry_plugin(&format!("{long}!"), wildcard()); + let skills = vec![ + skill_of(&a, "a", "/reg/one/skills/a/SKILL.md"), + skill_of(&b, "b", "/reg/two/skills/b/SKILL.md"), + ]; + + let compiled = compile(&[a, b], &skills, &no_config()); + assert_eq!(compiled.len(), 2); + for plugin in &compiled { + assert!( + manifest::is_valid_name(&plugin.manifest.name), + "{} is not a valid manifest name", + plugin.manifest.name + ); + } + assert_ne!(compiled[0].manifest.name, compiled[1].manifest.name); +} + +#[test] +fn a_plugin_whose_skills_were_all_claimed_compiles_to_nothing() { + let first = registry_plugin("first", wildcard()); + let second = registry_plugin("second", wildcard()); + let shared = "/reg/shared/skills/guide/SKILL.md"; + let skills = vec![ + skill_of(&first, "guide", shared), + skill_of(&second, "guide", shared), + ]; + + let compiled = compile(&[first, second], &skills, &no_config()); assert_eq!( - compiled[0].manifest.name, "pdf-tools", - "the manifest keeps the undisambiguated name; only the directory moves" + compiled.len(), + 1, + "the second plugin has nothing left after dedup, so it must not be emitted" ); + assert_eq!(compiled[0].manifest.name, "first"); } #[test] From 76b124a4059b80b896291ce08db12e46f607a124 Mon Sep 17 00:00:00 2001 From: fluzko Date: Mon, 24 Aug 2026 16:11:40 -0300 Subject: [PATCH 14/14] fix(sync): never reap when a registry source is unreadable --- md/design/important-flows.md | 2 +- md/design/module-structure.md | 6 +++--- src/help_render.rs | 1 + src/plugins.rs | 10 +++++++++ src/pm/git.rs | 4 ++++ src/pm/mod.rs | 10 +++++++++ src/pm/path.rs | 15 +++++++++++++ src/skills.rs | 6 ++++++ src/subcommand_dispatch.rs | 1 + src/sync.rs | 40 +++++++++++++++++++++-------------- 10 files changed, 75 insertions(+), 20 deletions(-) diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 13f7e979..c84caca2 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -37,7 +37,7 @@ Every `cargo agents sync` compiles the plugins that apply into the unit agents c 2. `Scope::of` sends each to `/.symposium/plugins/` or `/installed/` — see [key modules](./module-structure.md#agent_plugin--compiling-an-agent-plugin-directory) for what global requires and why. A scope no configured agent can take is not compiled. 3. `agent_plugin::write` stages into a tempdir and syncs it in through `sync::sync_managed_dir`, so an unchanged plugin is not recopied. `write_marketplace` writes `.claude-plugin/marketplace.json` at each staging root, and removes it when the root empties. 4. For each configured agent and each scope it accepts, `Agent::install_plugins` writes that agent's configuration and, where the agent loads only from its own tree, copies the directory there. Plugins an agent received are recorded, so their skills are skipped in the per-skill loop and nothing arrives twice. -5. `agent_plugin::reap_to_depth` removes marked directories this sync did not write, under both staging roots and every known agent's plugin tree. Reaping the global root from a project sync is sound only because step 2 keeps the global set a function of user config alone. +5. `agent_plugin::reap_to_depth` removes marked directories this sync did not write, under both staging roots and every known agent's plugin tree. Reaping the global root from a project sync is sound only because step 2 keeps the global set a function of user config alone. Steps 4 and 5 are skipped entirely when a trusted source was unreadable, so a transient registry failure cannot be read as an uninstall. The key code paths are in `agent_plugin/mod.rs`, `agent_plugin/manifest.rs`, `agents/plugin_install.rs`, `predicate.rs` (`is_workspace_independent`), and `sync.rs`. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index da0e82ef..ee2e796a 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -24,7 +24,7 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`-- ### `sync.rs` — synchronization command -Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory), hands it to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps what it did not write — compiled directories and agent-side copies alike. A scope no configured agent can take is not compiled. `Debounce` is passed in rather than inferred from `UpdateLevel`: only the per-event hook path debounces, since an explicit `sync` defaults to `--update none` and would otherwise ignore a just-edited skill. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. +Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_managed_dir(source_dir, dest_dir, project_root, debounce)`, used by every path that installs a directory symposium owns. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Every plugin-removal path -- staging reap, agent-copy reap, and the reconcile that drops marketplace and enablement entries -- is skipped when `PluginRegistry::sources_readable` is false, since an unmounted registry would otherwise uninstall its plugins from every agent. A single skipped *entry* is not this: it loses one plugin, which genuinely should then be removed. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). After skills are resolved, sync compiles each applicable plugin into an [agent plugin directory](#agent_plugin--compiling-an-agent-plugin-directory), hands it to the agents that accept it ([delivery](#agentsplugin_installrs--handing-a-directory-to-an-agent)), and reaps what it did not write — compiled directories and agent-side copies alike. A scope no configured agent can take is not compiled. `Debounce` is passed in rather than inferred from `UpdateLevel`: only the per-event hook path debounces, since an explicit `sync` defaults to `--update none` and would otherwise ignore a just-edited skill. Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs. One entry point, `sync(sym, deps, update, debounce)`: callers pass an already-built `WorkspaceDeps` so the CLI and the hook pipeline share one cached workspace resolution. @@ -61,7 +61,7 @@ One directory serves every agent, because their formats differ only in which man `manifest.rs` owns the format's name grammar (`^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$`, 1-64 chars), which is narrower than a symposium plugin name, so `slug` normalizes one into the other. Two names can slug alike (`foo_bar`, `foo-bar`), so directory disambiguation keys on the *slug*. -`compile` groups applicable skills by their plugin's `canonical` id, not its name — two registries can supply the same name. A plugin with no applicable skills compiles to nothing. Skills sharing a name within one plugin take an origin-hash suffix; across plugins they cannot collide, since agents namespace them (`pdf-tools:extract-tables`). When several plugins claim a directory name, every claimant takes the suffixed form, so a name stays stable as unrelated plugins come and go. One bundle referenced by several plugins is emitted once, by the first claimant — emitting per plugin would load identical guidance N times. +`compile` groups applicable skills by their plugin's `canonical` id, not its name — two registries can supply the same name. A plugin with no applicable skills compiles to nothing, which includes one whose every skill an earlier plugin already claimed -- only knowable after dedup, so the emptiness check runs after it. Skills sharing a name within one plugin take an origin-hash suffix; across plugins they cannot collide, since agents namespace them (`pdf-tools:extract-tables`). When several plugins claim a directory name, every claimant takes the suffixed form -- on the manifest name as well as the directory, since a plugin's agent-facing identity (its enablement key, and the cache path Codex and Copilot derive) is the manifest name. Suffixing all claimants rather than all-but-one keeps a name stable as unrelated plugins come and go. One bundle referenced by several plugins is emitted once, by the first claimant — emitting per plugin would load identical guidance N times. `Scope::of` picks the staging root: @@ -122,7 +122,7 @@ Validates skill group source constraints during manifest validation: a group mus The in-process seam from the [registry-centric plugin distribution RFD](../rfds/registry-centric-plugins/README.md). A `PackageId` is the canonical `(pm, name, version)` tuple; `version` may still be a requirement (a semver range, or `*` for "no requirement"), and `fetch` canonicalizes it — a `FetchedPackage` carries the exact resolved id plus the content directory. A `PluginInfo` (id plus optional description) is the lightweight result of `search`. -The `PackageManager` trait is the RFD's operation set. Plugin loading has two forms — `active_plugins(deps)` (the plugins a PM activates for the workspace deps) and `load_plugin(id)` (the plugin(s) a specific id maps to) — both returning fully path-resolved `ParsedPlugin`s and best-effort (failures logged, not surfaced); plus `list_deps`, `search`, `fetch`, `refresh` (pull a registry's content — a no-op default for local/dependency sources), and `registry_source` (the git-vs-path descriptor, for `plugin list`). A PM value is an *instance*, not just an ecosystem: a **transport** can `fetch`/`load_plugin` any id of its ecosystem because the id carries the source, while a **registry instance** fronts one configured source and enumerates its packages via `active_plugins`. A registry instance's `name()` is the *configured registry name* (`user-plugins`, `symposium-recommendations`, …), which is also the `pm` component of every id it mints and the name its plugins are attributed to. A PM is *self-contained*: it holds whatever it needs to resolve its own ecosystem, so operations take no ambient context — mirroring the out-of-process shape, where a PM spawned for a workspace answers from its own state. `CargoPm` holds an `Arc` and drives it (lazy, cached); `PathPm` holds its directory. `PmRegistry` is **one flat set** of instances — `fetch` / `load_plugin` dispatch by `PackageId::pm`; `list_deps` / `search` / `load_plugin` union across all. Each `PmInstance` carries `trusted`: registries and the workspace are trust roots, the cargo transport (over dependencies) is not — the one distinction consumers branch on (registry loading takes only trusted instances; `discover` takes only the untrusted cargo transport). `Symposium::package_managers(deps)` builds the set — the cargo instance (`trusted = false`) plus one registry instance per configured registry (`trusted = true`: a `GitPm` for a git entry, a `PathPm` for a path entry); `detached_managers()` uses a detached resolver for workspace-independent work. `workspace_dep_ids(sym, deps)` unions `list_deps` and degrades to empty on failure. `CargoPm` (`pm/cargo/mod.rs`): `fetch` delegates to `crate_sources::RustCrateFetch` (path override, workspace pin, registry); `list_deps` reads `self.workspace.crates()` as cargo ids; `active_plugins(deps)` builds a `ParsedPlugin` (via the shared `build_from_fetched`) for each dependency whose source embeds plugin content (a `SYMPOSIUM.toml`, `[package.metadata.symposium]`, or the default `skills/`), fetched cache-only into the already-extracted source (no probe) — these are dependency-embedded, so the caller applies consent; `load_plugin(id)` builds the named crate whatever it embeds (any fetchable crate yields at least a default `skills/` plugin); `search` queries crates.io (`crates_io_api`, capped at `SEARCH_PAGE_SIZE`) so `use`/`search` can name a crate the workspace doesn't depend on. `CargoPm` also owns crate-to-plugin resolution: +The `PackageManager` trait is the RFD's operation set. `source_readable` is the one operation that exists for removal rather than loading: a source that cannot be listed yields no plugins, exactly like an empty one, and sync must not read that absence as "these plugins no longer apply". An absent directory is readable -- that is an empty registry, not a failure -- so a fresh install still reaps. Plugin loading has two forms — `active_plugins(deps)` (the plugins a PM activates for the workspace deps) and `load_plugin(id)` (the plugin(s) a specific id maps to) — both returning fully path-resolved `ParsedPlugin`s and best-effort (failures logged, not surfaced); plus `list_deps`, `search`, `fetch`, `refresh` (pull a registry's content — a no-op default for local/dependency sources), and `registry_source` (the git-vs-path descriptor, for `plugin list`). A PM value is an *instance*, not just an ecosystem: a **transport** can `fetch`/`load_plugin` any id of its ecosystem because the id carries the source, while a **registry instance** fronts one configured source and enumerates its packages via `active_plugins`. A registry instance's `name()` is the *configured registry name* (`user-plugins`, `symposium-recommendations`, …), which is also the `pm` component of every id it mints and the name its plugins are attributed to. A PM is *self-contained*: it holds whatever it needs to resolve its own ecosystem, so operations take no ambient context — mirroring the out-of-process shape, where a PM spawned for a workspace answers from its own state. `CargoPm` holds an `Arc` and drives it (lazy, cached); `PathPm` holds its directory. `PmRegistry` is **one flat set** of instances — `fetch` / `load_plugin` dispatch by `PackageId::pm`; `list_deps` / `search` / `load_plugin` union across all. Each `PmInstance` carries `trusted`: registries and the workspace are trust roots, the cargo transport (over dependencies) is not — the one distinction consumers branch on (registry loading takes only trusted instances; `discover` takes only the untrusted cargo transport). `Symposium::package_managers(deps)` builds the set — the cargo instance (`trusted = false`) plus one registry instance per configured registry (`trusted = true`: a `GitPm` for a git entry, a `PathPm` for a path entry); `detached_managers()` uses a detached resolver for workspace-independent work. `workspace_dep_ids(sym, deps)` unions `list_deps` and degrades to empty on failure. `CargoPm` (`pm/cargo/mod.rs`): `fetch` delegates to `crate_sources::RustCrateFetch` (path override, workspace pin, registry); `list_deps` reads `self.workspace.crates()` as cargo ids; `active_plugins(deps)` builds a `ParsedPlugin` (via the shared `build_from_fetched`) for each dependency whose source embeds plugin content (a `SYMPOSIUM.toml`, `[package.metadata.symposium]`, or the default `skills/`), fetched cache-only into the already-extracted source (no probe) — these are dependency-embedded, so the caller applies consent; `load_plugin(id)` builds the named crate whatever it embeds (any fetchable crate yields at least a default `skills/` plugin); `search` queries crates.io (`crates_io_api`, capped at `SEARCH_PAGE_SIZE`) so `use`/`search` can name a crate the workspace doesn't depend on. `CargoPm` also owns crate-to-plugin resolution: - `build_from_fetched(fetched) -> Option` builds a first-class `ParsedPlugin` from its manifest sources — `[package.metadata.symposium]` in `Cargo.toml` and a `SYMPOSIUM.toml` at the source root — layered over the crate defaults by `plugins::load_crate_manifest` (merge order: defaults → Cargo.toml → SYMPOSIUM.toml; see [important flows](./important-flows.md#crate-sourced-skill-resolution)). The plugin is stamped with the resolved crate id as its `canonical` identity. A crate with **no** manifest sources still yields a plugin whose only content is the default `skills/` group — so `load_plugin` returns `Some` for any fetchable crate; `None` means the fetch failed or the merged manifest was invalid (both logged). Callers stay ignorant of crates: `skills.rs` hands over a dependency name and gets back a parsed plugin. Consumers: chained-reference expansion in `skills.rs` calls `load_plugin`; `crate_command.rs` builds ids with `CargoPm::id_for` and fetches through `PmRegistry`; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) gets its `PredicateContext` deps from `workspace_dep_ids`. Sync helpers that used to take `&[WorkspaceCrate]` and resolve deps themselves (`help_render::render`, `subcommand_dispatch::find_subcommand`) now take an already-resolved `&[PackageId]`, so only the async entry points touch the PM layer. diff --git a/src/help_render.rs b/src/help_render.rs index deafe4db..bf76fe52 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -245,6 +245,7 @@ mod tests { PluginRegistry { plugins, warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), } } diff --git a/src/plugins.rs b/src/plugins.rs index 1af6822d..d489a590 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -979,6 +979,10 @@ pub struct PluginRegistry { pub plugins: Vec, /// Non-fatal load warnings for entries that were skipped. pub warnings: Vec, + /// Whether every trusted source could be read. False means the plugin list + /// is incomplete for a reason that says nothing about what still applies, + /// so callers must not treat an absence here as a removal. + pub sources_readable: bool, /// Global custom predicate registry. Built from all plugins' `custom_predicates`. pub custom_predicates: CustomPredicateRegistry, } @@ -1500,7 +1504,12 @@ async fn load_registry_impl( // Dependency-embedded crate plugins are not trust roots — they reach the // active set through discovery / consent and the driver's `load_plugin`, // never here. Each registry instance logs its own load failures. + let mut sources_readable = true; for inst in pms.instances().filter(|i| i.trusted) { + if !inst.pm.source_readable().await { + sources_readable = false; + tracing::warn!(registry = %inst.name, "registry source unreadable"); + } plugins.extend(inst.pm.active_plugins(&[]).await); } @@ -1518,6 +1527,7 @@ async fn load_registry_impl( PluginRegistry { plugins, warnings, + sources_readable, custom_predicates, } } diff --git a/src/pm/git.rs b/src/pm/git.rs index e3612ee0..826b3d36 100644 --- a/src/pm/git.rs +++ b/src/pm/git.rs @@ -97,6 +97,10 @@ impl PackageManager for GitPm { url: self.git_url.clone(), }) } + + async fn source_readable(&self) -> bool { + self.inner.source_readable().await + } } #[cfg(test)] diff --git a/src/pm/mod.rs b/src/pm/mod.rs index a7426815..159e9d4a 100644 --- a/src/pm/mod.rs +++ b/src/pm/mod.rs @@ -173,6 +173,16 @@ pub trait PackageManager { fn registry_source(&self) -> Option { None } + + /// Whether this instance's source can be read right now. + /// + /// A source that cannot be listed yields no plugins, exactly like one that + /// is genuinely empty. Callers that *remove* things need the difference: + /// an unreadable registry must not read as "these plugins no longer apply". + /// An absent source is readable — that is an empty registry, not a failure. + async fn source_readable(&self) -> bool { + true + } } /// Where a registry instance's content comes from — the git-vs-path diff --git a/src/pm/path.rs b/src/pm/path.rs index b0fd4ab7..84e362f9 100644 --- a/src/pm/path.rs +++ b/src/pm/path.rs @@ -121,10 +121,25 @@ impl PackageManager for PathPm { dir: self.dir.clone(), }) } + + async fn source_readable(&self) -> bool { + !self.dir.exists() || std::fs::read_dir(&self.dir).is_ok() + } } #[cfg(test)] mod tests { + /// An absent directory is an empty registry, not an unreadable one. Treating + /// it as a failure would stop a fresh install from ever reaping anything. + #[tokio::test] + async fn an_absent_directory_is_readable() { + let dir = tempfile::tempdir().unwrap(); + let pm = PathPm::new("test", dir.path().join("nope")); + assert!(pm.source_readable().await); + let pm = PathPm::new("test", dir.path().to_path_buf()); + assert!(pm.source_readable().await); + } + use super::*; #[tokio::test] diff --git a/src/skills.rs b/src/skills.rs index 25a96061..1b347798 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -1141,6 +1141,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1193,6 +1194,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1263,6 +1265,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1339,6 +1342,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1416,6 +1420,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1538,6 +1543,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 9ce37b3d..9cd765f1 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -225,6 +225,7 @@ mod tests { PluginRegistry { plugins, warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), } } diff --git a/src/sync.rs b/src/sync.rs index aa7d9fb5..673fdf9d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -409,6 +409,13 @@ pub async fn sync( ); } + // Removing anything means knowing the complete set of what should exist, + // and an unreadable source means we do not. An unmounted registry path + // would otherwise read as "these plugins no longer apply" and uninstall + // them from every agent. A single skipped *entry* is not this: it loses one + // plugin, which genuinely should then be removed. + let degraded = !registry.sources_readable; + tracing::info!( report = %crate::report::ReportEvent::Info { message: format!("scanning {workspace_deps_count} workspace dependencies"), @@ -482,9 +489,6 @@ pub async fn sync( } } - // Compile each applicable plugin into an agent plugin directory. Nothing - // reads these yet — the per-agent delivery lands with the emitters — but the - // directories are owned and reaped from here on. // Only compile for a scope some configured agent can actually take. With // none, the directory would sit unread and the skills still arrive through // the per-skill path. @@ -553,15 +557,17 @@ pub async fn sync( // Reaping the global root from a project sync is only sound because // `Scope::of` keeps the global set a function of user config alone. - if let Some(p) = &project { - crate::agent_plugin::reap(&p.staging, &staged_project); + if !degraded { + if let Some(p) = &project { + crate::agent_plugin::reap(&p.staging, &staged_project); + } + crate::agent_plugin::reap(&global_staging, &staged_global); } - crate::agent_plugin::reap(&global_staging, &staged_global); for (scope, root) in staging_roots(&project, &global_staging) { let in_root: Vec<&crate::agent_plugin::CompiledPlugin> = compiled.iter().filter(|p| p.scope == scope).collect(); - if in_root.is_empty() && !root.exists() { + if in_root.is_empty() && (degraded || !root.exists()) { continue; } let name = crate::agent_plugin::marketplace_name( @@ -652,7 +658,7 @@ pub async fn sync( .iter() .filter(|p| p.scope == scope && agent.accepts_plugin_scope(scope)) .collect(); - if in_scope.is_empty() && !root.exists() { + if in_scope.is_empty() && (degraded || !root.exists()) { continue; } let marketplace = crate::agent_plugin::marketplace_name( @@ -867,14 +873,16 @@ pub async fn sync( // Reap plugin copies we no longer own, across every known agent so an agent // dropped from the config is cleaned up too. - for &agent in Agent::all() { - let written = agent_copies.get(&agent).cloned().unwrap_or_default(); - for root in agent.plugin_reap_roots(sym.home_dir()) { - crate::agent_plugin::reap_to_depth( - &root, - crate::agent_plugin::AGENT_COPY_DEPTH, - &written, - ); + if !degraded { + for &agent in Agent::all() { + let written = agent_copies.get(&agent).cloned().unwrap_or_default(); + for root in agent.plugin_reap_roots(sym.home_dir()) { + crate::agent_plugin::reap_to_depth( + &root, + crate::agent_plugin::AGENT_COPY_DEPTH, + &written, + ); + } } }