diff --git a/md/design/agents.md b/md/design/agents.md index 95a92486..3b48ab93 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. + +| 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 11eff832..c84caca2 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -29,6 +29,29 @@ 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 and delivery of agent plugin directories + +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. + +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. 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`. + +## Reading an externally authored package + +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`. 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`, `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 `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 d2590a0b..ee2e796a 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_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. 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. -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, debounce)`: 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, 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`. @@ -37,17 +39,73 @@ 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. `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. 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 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` 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, 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: + +| Scope | Root | | +|---|---|---| +| `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 | + +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 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 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 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 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 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, 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 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` | 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* 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. + +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` 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, 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 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. @@ -64,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. @@ -86,6 +144,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 @@ -100,7 +160,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. @@ -124,7 +184,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/manifest.rs b/src/agent_plugin/manifest.rs new file mode 100644 index 00000000..edd04657 --- /dev/null +++ b/src/agent_plugin/manifest.rs @@ -0,0 +1,359 @@ +//! 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::{Deserialize, 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, + /// 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: String, 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 + } +} + +/// 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. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct GeminiExtension { + pub name: String, + /// Required here, unlike in the Agent Plugins manifest. + pub version: String, +} + +impl GeminiExtension { + pub fn new(name: String, version: String) -> Self { + Self { name, version } + } + + 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 { + 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) +} + +/// 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() +} + +#[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 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"], "1.2.0"); + 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(), "0.0.0".into(), None).to_json(); + assert!(bare.contains(SCHEMA_URL)); + assert!(!bare.contains("description")); + + 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"); + 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..453a34c4 --- /dev/null +++ b/src/agent_plugin/mod.rs @@ -0,0 +1,406 @@ +//! 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; +pub mod read; + +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::{GeminiExtension, Manifest, Marketplace, MarketplaceEntry}; + +/// 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"; + +/// 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: 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(), + } +} + +/// 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 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. + /// + /// 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], + plugins: &PluginsConfig, + ) -> Scope { + let workspace_bound = parsed.workspace_member + || parsed.canonical.pm == CARGO_PM + || !parsed.plugin.predicates.is_workspace_independent() + || parsed + .plugin + .skills + .iter() + .any(|group| !group.predicates.is_workspace_independent()) + || contributed + .iter() + .any(|entry| !entry.skill.predicates.is_workspace_independent()); + if workspace_bound || !plugins.is_used_globally(&parsed.plugin.name) { + 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 { + /// 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, + 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(); + // 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 + .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; + }; + + // 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 { + source_id: parsed.canonical.clone(), + dir_name: name.clone(), + manifest: Manifest::new( + name, + version_of(parsed), + parsed.plugin.description.clone(), + ), + scope: Scope::of(parsed, &mine, plugins), + skills, + }, + )); + } + + 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. +/// +/// 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 { + *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) { + let name = manifest::suffixed(&plugin.dir_name, &hash); + plugin.dir_name = name.clone(); + plugin.manifest.name = name; + } + plugin + }) + .collect() +} + +/// 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 claimed.insert(skill.origin_hash.clone()) { + *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() +} + +/// 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. +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. +/// +/// 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")?; + write_manifests(staged.path(), &compiled.manifest)?; + + 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) +} + +/// 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 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() { + 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) { + 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) + ), + }, + ), + } + } +} + +/// 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 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..3d5509df --- /dev/null +++ b/src/agent_plugin/read.rs @@ -0,0 +1,123 @@ +//! 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, PluginKind, 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, + kind: PluginKind::AgentPlugin, + 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 new file mode 100644 index 00000000..75c14412 --- /dev/null +++ b/src/agent_plugin/tests.rs @@ -0,0 +1,600 @@ +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 ──────────────────────────────────────────────────────────── + +fn used_globally(name: &str) -> PluginsConfig { + PluginsConfig { + used: vec![UseEntry::Global(name.to_string())], + ..Default::default() + } +} + +#[test] +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::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] +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, &[], &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, &[], &used_globally("widget")), + Scope::Project, + "a crate plugin is reached through this workspace's dependency graph" + ); +} + +#[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 globally = used_globally("pdf-tools"); + let mut grouped = registry_plugin("pdf-tools", wildcard()); + grouped.plugin.skills = vec![SkillGroup { + predicates: on_serde(), + source: PluginSource::Path(PathBuf::from("skills")), + ..Default::default() + }]; + 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], &globally), + 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 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()); + 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); + 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.len(), + 1, + "the second plugin has nothing left after dedup, so it must not be emitted" + ); + assert_eq!(compiled[0].manifest.name, "first"); +} + +#[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), "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), "0.3.1"); + + let placeholder = registry_plugin("pdf-tools", wildcard()); + assert_eq!( + version_of(&placeholder), + UNVERSIONED, + "the `*` placeholder is not a version, and Codex keys its cache on one" + ); +} + +// ── 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 { + source_id: PackageId::new("test", "src", ANY_VERSION), + dir_name: "pdf-tools".into(), + manifest: Manifest::new("pdf-tools".into(), "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); + + 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(), + "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 { + source_id: PackageId::new("test", "src", ANY_VERSION), + dir_name: "pdf-tools".into(), + manifest: Manifest::new("pdf-tools".into(), UNVERSIONED.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("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"); +} + +#[test] +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(), UNVERSIONED.into(), 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"); + + 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] +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 { + source_id: PackageId::new("test", "src", ANY_VERSION), + dir_name: "pdf-tools-ab12cd34".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(), UNVERSIONED.into(), 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, 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, 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, + "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}"); + } +} + +#[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/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..1b7f36bc --- /dev/null +++ b/src/agents/plugin_install.rs @@ -0,0 +1,419 @@ +//! Handing a compiled plugin directory to an agent. +//! +//! 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, 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, 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}; +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); + 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. +/// +/// 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) + .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 +/// 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..9d2ba85a --- /dev/null +++ b/src/agents/plugin_install/tests.rs @@ -0,0 +1,465 @@ +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" + ); +} + +// ── 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 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` 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), + &home, + tmp.path(), + Duration::ZERO, + ) + .expect("install"); + + assert_eq!(written.len(), 1); + assert!(home.join(".copilot/settings.json").is_file()); + assert!(written[0].join("skills/probe/SKILL.md").is_file()); +} 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/config.rs b/src/config.rs index 6e743b63..fe137ed0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -160,6 +160,25 @@ 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 { + 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/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/help_render.rs b/src/help_render.rs index 823b26ed..bf76fe52 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), @@ -251,6 +245,7 @@ mod tests { PluginRegistry { plugins, warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), } } diff --git a/src/hook.rs b/src/hook.rs index 29cba839..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; } @@ -942,7 +949,6 @@ fn dispatched_hooks_for_payload( #[cfg(test)] mod tests { - use std::collections::BTreeMap; use crate::pm::{ANY_VERSION, PackageId}; @@ -1157,12 +1163,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/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/plugins.rs b/src/plugins.rs index efea5ea2..d489a590 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,51 @@ 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 { + /// 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 +233,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 +263,7 @@ impl RawSkillGroup { source, source_label: None, workspace_member: false, + depth: SkillDepth::default(), }) } } @@ -448,9 +497,15 @@ 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 = "is_symposium_kind")] + pub kind: PluginKind, + #[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 @@ -924,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, } @@ -1009,6 +1068,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 +1121,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; } @@ -1242,9 +1309,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, @@ -1256,6 +1324,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())), @@ -1263,6 +1335,42 @@ 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); + let canonical = entry_id(source_name, source_dir, dir, &plugin.name); + Ok(ParsedPlugin { + 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 @@ -1289,6 +1397,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 @@ -1313,11 +1434,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. @@ -1327,6 +1444,9 @@ fn load_standalone_skill_plugin( let mut plugin = Plugin { name: name.clone(), + kind: PluginKind::Symposium, + version: None, + description: None, predicates, installations: Vec::new(), hooks: Vec::new(), @@ -1339,8 +1459,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, }) @@ -1383,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); } @@ -1401,6 +1527,7 @@ async fn load_registry_impl( PluginRegistry { plugins, warnings, + sources_readable, custom_predicates, } } @@ -1584,9 +1711,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 @@ -1596,6 +1734,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()) @@ -1642,6 +1781,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())); @@ -1675,6 +1821,7 @@ pub struct ValidationResult { #[derive(Debug)] pub enum ValidationKind { Plugin, + AgentPlugin, Skill, } @@ -1682,6 +1829,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"), } } @@ -1702,6 +1850,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(); @@ -1714,6 +1866,7 @@ pub fn validate_source_dir(dir: &Path) -> Result> { &skills_dir, group.workspace_member, &group.predicates, + group.depth, ); let group_label = group .source_label @@ -1759,7 +1912,7 @@ pub fn validate_source_dir(dir: &Path) -> Result> { }); results.push(ValidationResult { id, - kind: ValidationKind::Plugin, + kind, result, warning, children, @@ -1836,13 +1989,15 @@ 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); + 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. @@ -1850,6 +2005,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)?) } @@ -2078,6 +2251,9 @@ fn validate_manifest( Ok(Plugin { name, + kind: PluginKind::Symposium, + version: manifest.version.take(), + description: manifest.description.take(), predicates, installations, hooks, @@ -2230,7 +2406,6 @@ fn build_custom_predicate_registry( mod tests { use super::*; use indoc::indoc; - use std::collections::BTreeMap; use crate::predicate::PredicateSet; @@ -2543,6 +2718,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 +3384,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 +3392,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 +3400,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 +3408,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 +3598,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 +5039,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/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/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/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/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/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 ceead0c0..ffdd0280 100644 --- a/src/report.rs +++ b/src/report.rs @@ -66,9 +66,26 @@ pub enum ReportEvent { reason: Option, }, + /// A plugin was compiled into an agent plugin directory. + PluginCompiled { + plugin: String, + scope: String, + skills: usize, + 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, + plugin: String, agent: String, dest: String, }, @@ -134,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`. @@ -147,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, @@ -228,8 +254,29 @@ impl ReportEvent { format!(" skill {skill} ({plugin}): skipped ({r})") } } - Self::SkillInstalled { skill, agent, dest } => { - format!("✅ installed skill {skill} for {agent} → {dest}") + Self::PluginCompiled { + plugin, + scope, + skills, + dest, + } => { + format!("📦 compiled {plugin} ({scope}, {skills} skills) → {dest}") + } + Self::PluginDelivered { + plugin, + agent, + scope, + dest, + } => { + format!("🔌 delivered {plugin} ({scope}) to {agent} → {dest}") + } + Self::SkillInstalled { + skill, + plugin, + agent, + dest, + } => { + format!("✅ installed skill {skill} from {plugin} for {agent} → {dest}") } Self::SkillRemoved { path } => { format!("➖ removed {path}") @@ -303,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 } => { @@ -332,6 +387,7 @@ impl ReportEvent { Self::PluginStatus { name, version, + plugin_kind, root, state, } => { @@ -345,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/skills.rs b/src/skills.rs index f07500a4..1b347798 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); @@ -99,6 +99,11 @@ 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, + /// 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. @@ -159,13 +164,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); } } } @@ -292,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(), @@ -353,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 @@ -552,10 +596,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 +621,12 @@ fn collect_skill_applicable_to( reason: None, }, ); - results.push(SkillWithGroupContext { skill, origin_hash }); + results.push(SkillWithGroupContext { + skill, + origin_hash, + plugin: plugin_name.to_string(), + plugin_id: parsed.canonical.clone(), + }); } /// Raw frontmatter fields extracted from a SKILL.md file. @@ -1076,19 +1126,12 @@ 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, + ..Default::default() }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1098,6 +1141,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1135,19 +1179,12 @@ 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, + ..Default::default() }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1157,6 +1194,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1212,19 +1250,12 @@ 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, + ..Default::default() }], - mcp_servers: vec![], - installations: Vec::new(), - subcommands: BTreeMap::new(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1234,6 +1265,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1294,19 +1326,13 @@ 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, + ..Default::default() }], - mcp_servers: vec![], - installations: Vec::new(), subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1316,6 +1342,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1372,7 +1399,6 @@ mod tests { Predicate::Shell("true".into()), ], }, - hooks: vec![], skills: vec![SkillGroup { predicates: PredicateSet { predicates: vec![ @@ -1381,15 +1407,10 @@ mod tests { ], }, source: PluginSource::Path(skill_dir.to_path_buf()), - source_label: None, - workspace_member: false, + ..Default::default() }], - mcp_servers: vec![], - installations: Vec::new(), subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { @@ -1399,6 +1420,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1504,21 +1526,15 @@ 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 "." // resolves to the skill's own directory. source: PluginSource::Path(skill_dir.clone()), - source_label: None, - workspace_member: false, + ..Default::default() }], - mcp_servers: vec![], subcommands: Default::default(), - custom_predicates: vec![], - chained: vec![], - requires_use: false, + ..Default::default() }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { @@ -1527,6 +1543,7 @@ mod tests { workspace_member: false, }], warnings: vec![], + sources_readable: true, custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; @@ -1574,7 +1591,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(); @@ -1603,7 +1625,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(); @@ -1666,7 +1693,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 @@ -1682,7 +1714,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/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/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 046dc9f1..9cd765f1 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, } @@ -231,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 71d534c8..673fdf9d 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}; @@ -45,42 +46,90 @@ 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), } -/// Mark a directory as symposium-generated: drop the `.symposium` marker -/// and a `.gitignore` containing `*` so the directory is recognized on -/// future syncs and kept out of version control. +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`. +#[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`. 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, 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?; @@ -144,10 +193,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,11 +205,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_skill_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); @@ -169,9 +218,9 @@ fn sync_skill_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_generated_skill_directory(dest_dir)?; + mark_managed_dir(dest_dir, marking)?; return Ok(true); } @@ -196,9 +245,9 @@ fn sync_skill_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_generated_skill_directory(dest_dir)?; + mark_managed_dir(dest_dir, marking)?; Ok(true) } @@ -270,21 +319,87 @@ 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, + /// 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, + origin_hash: String, + plugin: String, + plugin_id: crate::pm::PackageId, + 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<()> { +pub async fn sync( + sym: &Symposium, + deps: &Arc, + update: UpdateLevel, + debounce: Debounce, +) -> 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(); - let debounce = Duration::from_secs(sym.config.sync_debounce_secs); - tracing::debug!(root = %project_root.display(), "resolved workspace root"); + // 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()); + 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"), + 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!( @@ -294,9 +409,16 @@ pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLeve ); } + // 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 dependencies", workspace.len()), + message: format!("scanning {workspace_deps_count} workspace dependencies"), }, ); @@ -308,20 +430,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; @@ -334,7 +471,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 +479,107 @@ 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(), + plugin_id: entry.plugin_id.clone(), + source: &entry.skill.path, + }); + } + } + + // 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 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 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(&p.owned)), + }, + ); + } + + for plugin in &compiled { + 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) => { + 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. + if !degraded { + 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 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() && (degraded || !root.exists()) { + continue; + } + 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 { + message: format!("failed to index {}: {e}", display_path(root)), + }, + ); } } @@ -353,9 +590,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" ); @@ -375,7 +614,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" @@ -390,16 +629,90 @@ 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(); + // 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)?; - 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(), + // 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 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)) + .collect(); + if in_scope.is_empty() && (degraded || !root.exists()) { + continue; + } + 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, + }; + // 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) + .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)) + .map(PathBuf::as_path) + .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, &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 @@ -410,10 +723,24 @@ 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, + 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() { Some(p) => p, None => { out.warn(format!( @@ -427,7 +754,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, @@ -450,7 +779,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) { @@ -465,12 +796,19 @@ 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, + skill_home.boundary(), + debounce, + Marking::MarkerAndGitignore, + ) { 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), }, @@ -497,7 +835,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 parent = skills_parent_dir(agent, &project_root); + let Some(parent) = skill_home.parent_for(agent) else { + continue; + }; if !scanned.insert(parent.clone()) { continue; } @@ -531,6 +871,21 @@ 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. + 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, + ); + } + } + } + // 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/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/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..5ececacb 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" ); @@ -429,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?; @@ -498,10 +511,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 +534,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!( @@ -588,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" } 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/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 c418ec56..148d7570 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; @@ -11,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. @@ -150,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(()) @@ -187,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. @@ -548,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(()) }, ) @@ -591,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(()) }, ) @@ -631,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(()) }, ) @@ -665,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(()) }, ) @@ -701,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(()) }, ) @@ -890,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. @@ -1024,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 @@ -1060,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, @@ -1073,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. @@ -1188,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")?; @@ -1214,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(), @@ -1337,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(()) }, ) @@ -1432,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"))?; @@ -1464,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. @@ -1560,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"); @@ -2172,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(()) }, ) @@ -2230,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(()) }, ) @@ -2326,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, @@ -2461,6 +2522,213 @@ 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, + &["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/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"], "project-tools"); + assert_eq!( + manifest["$schema"], + "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" + ); + assert!( + project.join("skills/project-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/global-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"], "global-tools"); + assert_eq!(index["plugins"][0]["source"], "./global-tools"); + + 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/global-tools").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![("global-tools", "global"), ("project-tools", "project")] + ); + + 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, + &["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/project-tools"); + assert!(compiled.is_dir()); + assert!( + 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/project-tools/SYMPOSIUM.toml"); + std::fs::write( + &manifest, + "name = \"project-tools\"\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, + &["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/global-tools"); + assert!(global.is_dir(), "first sync installs the global plugin"); + assert!( + 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" + ); + + 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/global-tools").exists().not(), + "a global plugin is not also compiled into each project" + ); + assert!( + other.join(".symposium/plugins/project-tools").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. @@ -2475,23 +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]["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 @@ -2534,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(()) @@ -2591,3 +2862,315 @@ 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(); +} + +/// 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(); +} + +// ── 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(); +}