diff --git a/Cargo.lock b/Cargo.lock index 667fbfe6..af2134d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2428,6 +2428,7 @@ version = "0.4.0" dependencies = [ "anyhow", "assert_matches", + "async-trait", "bytes", "cargo_metadata", "chrono", @@ -2483,14 +2484,11 @@ name = "symposium-sdk" version = "0.1.0" dependencies = [ "anyhow", - "cargo_metadata", "clap", - "dirs", "regex", "semver", "serde", "serde_json", - "sha2", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index 61091afd..8e080599 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ pkg-fmt = "zip" [dependencies] clap = { version = "4", features = ["derive"] } anyhow = "1" +async-trait = "0.1" cargo_metadata = "0.18" chrono = "0.4" crates_io_api = { version = "0.12", default-features = false, features = ["rustls"] } diff --git a/README.md b/README.md index ded84edd..2e165282 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Configuration is a single user-wide file at `~/.symposium/config.toml`, created | `hook-scope` | `"global"` | Install hooks in the home directory (`global`) or the project (`project`). | | `auto-update` | `"on"` | `off`, `warn` (notify when a newer version exists), or `on` (install and re-exec). | -`[[agent]]` entries list your agents, `[[plugin-source]]` adds git or local plugin sources, and `[defaults]` toggles the two built-in sources. User data lives under `~/.symposium/` (overridable via `SYMPOSIUM_HOME` or the XDG variables). See the [configuration reference](https://symposium.dev/reference/configuration.html). +`[[agent]]` entries list your agents, `[[registry]]` adds git or local plugin sources, and `[defaults]` toggles the two built-in registries. User data lives under `~/.symposium/` (overridable via `SYMPOSIUM_HOME` or the XDG variables). See the [configuration reference](https://symposium.dev/reference/configuration.html). ## Supported agents diff --git a/md/SUMMARY.md b/md/SUMMARY.md index 79e113ae..f0fbbaeb 100644 --- a/md/SUMMARY.md +++ b/md/SUMMARY.md @@ -34,6 +34,9 @@ - [The `cargo agents` command](./reference/cargo-agents.md) - [`cargo agents init`](./reference/cargo-agents-init.md) - [`cargo agents sync`](./reference/cargo-agents-sync.md) + - [`cargo agents search`](./reference/cargo-agents-search.md) + - [`cargo agents use`](./reference/cargo-agents-use.md) + - [`cargo agents status`](./reference/cargo-agents-status.md) - [`cargo agents self-update`](./reference/cargo-agents-self-update.md) - [`cargo agents plugin`](./reference/cargo-agents-plugin.md) - [`cargo agents crate-info`](./reference/cargo-agents-crate-info.md) diff --git a/md/custom-plugin-source.md b/md/custom-plugin-source.md index 93cc0e10..83056bbb 100644 --- a/md/custom-plugin-source.md +++ b/md/custom-plugin-source.md @@ -15,15 +15,15 @@ Custom plugin sources are useful for: ## Adding your own custom sources -You can also define a custom plugin source in a git repository or at another path on your system. +You can also define a custom plugin source in a git repository or at another path on your system. Each one is a `[[registry]]` entry (`[[plugin-source]]` is the retired spelling of the same table, still accepted). ### Git repository -Add a remote Git repository as a plugin source: +Add a remote Git repository as a registry: ```toml # In ~/.symposium/config.toml -[[plugin-source]] +[[registry]] name = "my-company" git = "https://github.com/mycompany/symposium-plugins" auto-update = true @@ -33,10 +33,10 @@ We recommend creating a CI tool that runs [`cargo agents plugin validate`](./ref ### Local directory -Add a local directory as a plugin source: +Add a local directory as a registry: ```toml -[[plugin-source]] +[[registry]] name = "local-dev" path = "./my-plugins" auto-update = false diff --git a/md/design/hook-flow.md b/md/design/hook-flow.md index c8db614b..32784771 100644 --- a/md/design/hook-flow.md +++ b/md/design/hook-flow.md @@ -6,7 +6,7 @@ Entry point invoked by the agent's hook system on session events. 1. **Auto-sync** (if enabled) — when `auto-sync = true` in the user config, runs [`cargo agents sync`](./sync-agent-flow.md) to ensure skills are current. The workspace root is resolved from the payload's `cwd` field; if the payload does not include a working directory, the process's current working directory is used as a fallback. Runs quietly and non-fatally — failures are logged but don't block hook dispatch. - **`SessionStart` is the refresh point.** Because it fires once per agent session, it does the expensive work that other events skip: it bypasses the `Cargo.lock` freshness gate (so skills re-sync even when the workspace's dependencies are unchanged) and passes `UpdateLevel::Check` so git plugin sources and `source.git` skill groups are re-fetched if their upstream moved. Every other event keeps the cheap, `Cargo.lock`-gated path with `UpdateLevel::None` (debounced) to avoid per-event network and `cargo metadata` cost. The plugin-source refresh on `SessionStart` (`ensure_plugin_sources` with `Check`, decided in the binary entry point from the event) still honors each source's `auto-update` toggle. `SessionStart` also runs `prewarm_hook_sources`, which *refreshes already-installed* hook binaries/scripts (the `cargo`/`github` sources backing plugin hooks) — refresh-only, so it never eagerly installs a tool a hook may never use; first install still happens lazily at dispatch. + **`SessionStart` is the refresh point.** Because it fires once per agent session, it does the expensive work that other events skip: it bypasses the `Cargo.lock` freshness gate (so skills re-sync even when the workspace's dependencies are unchanged) and passes `UpdateLevel::Check` so git registries and `source.git` skill groups are re-fetched if their upstream moved. Every other event keeps the cheap, `Cargo.lock`-gated path with `UpdateLevel::None` (debounced) to avoid per-event network and `cargo metadata` cost. The registry refresh on `SessionStart` (`ensure_registries` with `Check`, decided in the binary entry point from the event) still honors each registry's `auto-update` toggle. `SessionStart` also runs `prewarm_hook_sources`, which *refreshes already-installed* hook binaries/scripts (the `cargo`/`github` sources backing plugin hooks) — refresh-only, so it never eagerly installs a tool a hook may never use; first install still happens lazily at dispatch. 2. **Built-in dispatch** — symposium's own handling, before plugin hooks. Currently only `SessionStart` produces output; `PreToolUse`, `PostToolUse`, and `UserPromptSubmit` are no-ops. On `SessionStart` two fragments are computed independently and, when present, joined into one `additionalContext`: - **Discovery hint** — when the active workspace exposes plugin-vended subcommands (the same workspace-filtered set listed by [`cargo agents --help`](./subcommands.md#help-text-grouping)), a line suggesting the agent run `cargo agents --help` to find them. Computed independently of the update-check throttle, so it fires whenever there is something to discover. diff --git a/md/design/important-flows.md b/md/design/important-flows.md index 9c90b229..11eff832 100644 --- a/md/design/important-flows.md +++ b/md/design/important-flows.md @@ -4,17 +4,30 @@ This section describes the logic of each `cargo agents` command. ## Crate-sourced skill resolution -A plugin loads a crate as a plugin by naming that crate in a `[[plugins]]` chained reference (`source.cargo = "..."`). When the owning plugin is active and the edge's predicates hold, sync resolves the crate. A single path handles every crate — a crate is always a first-class plugin, whether it describes itself with a `SYMPOSIUM.toml`, with `[package.metadata.symposium]`, with both, or with neither: +A plugin loads a crate as a plugin by naming that crate in a `[[plugins]]` chained reference (`source.cargo = "..."`); the user can also load one directly by enabling the dependency it lives in (see [enablement](#dependency-enablement) below). When the owning plugin is active and the edge's predicates hold, the crate is resolved into the **active plugin set** — the shared list every facet (skills, MCP servers, hooks, subcommands) resolves over, so a crate-sourced plugin's extensions dispatch exactly like a registry plugin's. A single path handles every crate — a crate is always a first-class plugin, whether it describes itself with a `SYMPOSIUM.toml`, with `[package.metadata.symposium]`, with both, or with neither: -1. `skills_applicable_to` runs `expand_chained_plugins` over the active plugin's `plugin.chained` edges; each edge whose predicates hold (evaluated against the *owning* plugin's provenance) names a crate directly. -2. For that crate, `expand_chained_plugins` calls `CargoPm::load_plugin(name, workspace)`: - - `CargoPm::fetch` resolves the source via `RustCrateFetch` (path overrides for local path deps, then the cargo registry cache, then crates.io). The fetched id carries the exact resolved version. +1. `skills::active_plugins` seeds a worklist from the trust-root plugins the registry loaded: each active plugin's `plugin.chained` edges whose predicates hold (evaluated against the *owning* plugin's provenance) contribute a `source.cargo` crate id. +2. For each id the fixed-point calls `pms.load_plugin(id)` on the **package-manager set** `active_plugins` was handed (built once by `package_managers(deps)`). The id's `pm` routes it to the cargo transport, which: + - `CargoPm::fetch` resolves the source via `RustCrateFetch` (path overrides for local path deps, then the cargo registry cache, then crates.io) with `UpdateLevel::None` — cache-only, so this is safe on the per-event hook path. The fetched id carries the exact resolved version. - `plugins::load_crate_manifest` builds the plugin definition by layering three sources (merge order: crate defaults → `[package.metadata.symposium]` from `Cargo.toml` → `SYMPOSIUM.toml` file). Both manifest sources use the ordinary plugin-manifest schema and are parsed **leniently** (a malformed layer is logged and dropped). Validation runs under `ManifestOrigin::Crate` (name defaults to the crate, `depends-on` is waived, `[defaults]` accepted, default `skills/` group appended unless `[defaults] skills = false`). The result is a `ParsedPlugin` whose `canonical` id is the resolved crate. A crate with no manifest sources still yields one whose only content is that default `skills/` group. -3. Back in `expand_chained_plugins`, the crate plugin's own plugin-level predicates are honored (`applies`, which stamps its provenance — never a workspace member), its skill groups run through the ordinary `load_skills_for_group` pipeline — honoring named groups, group predicates, and `source.path`/`source.git`, with each discovered skill's origin hashed from its on-disk `SKILL.md` path — and **its own `[[plugins]]` edges are expanded in turn**. This is how a `[package.metadata.symposium]` redirect (now a `[[plugins]] source.cargo` chained reference to the target crate) is followed. A per-top-level-plugin `visited` set keyed on the normalized crate name collapses diamonds (a crate reached two ways loads once) and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed but not yet dispatched (a `warn_undispatched_crate_features` notice fires when present). +3. `record_active` honors the crate plugin's own plugin-level predicates (`applies`, which stamps its provenance — never a workspace member), appends it to the active set, and **enqueues its own `[[plugins]]` edges**. This is how a `[package.metadata.symposium]` redirect (now a `[[plugins]] source.cargo` chained reference to the target crate) is followed. A `visited` set keyed on `(pm, normalized name)` — global across the whole `active_plugins` call — collapses diamonds (a crate reached through two plugins loads once, so its hooks don't double-fire and its subcommands don't read as a false conflict) and breaks cycles; the finite crate universe bounds termination. +4. Facet extraction then walks the active set. `collect_skills` runs each plugin's skill groups through the ordinary `load_skills_for_group` pipeline — honoring named groups, group predicates, and `source.path`/`source.git`, with each discovered skill's origin hashed from its on-disk `SKILL.md` path (this is where git skill sources are fetched, hence the `update` level). MCP-server filtering (`sync`), hook dispatch (`hook::dispatch_plugin_hooks`), and subcommand lookup (`subcommand_dispatch`) each iterate the same set. A crate plugin's **custom predicate definitions** are the one facet still not wired in — they resolve only from configured registries, and `warn_undispatched_crate_features` notes when a crate declares one. A skill's install identity is the hash of its on-disk `SKILL.md` path, so a crate reached two ways dedupes to one install. The edge's version requirement is recorded but not yet enforced — the crate resolves against the workspace (pin / path override). -The key code paths are in `pm/cargo.rs` (`CargoPm::load_plugin`), `plugins.rs` (`load_crate_manifest`, `RawPluginManifest::merge`, `ManifestOrigin::Crate`, `ParsedPlugin::canonical`), `skills.rs` (`expand_chained_plugins`, `hash_origin_key`), `crate_metadata.rs` (`symposium_metadata`), and `crate_sources/mod.rs` (`RustCrateFetch`, `WorkspaceCrate`). +The key code paths are in `pm/cargo/mod.rs` (`CargoPm::load_plugin`, `build_from_fetched`), `plugins.rs` (`load_crate_manifest`, `RawPluginManifest::merge`, `ManifestOrigin::Crate`, `ParsedPlugin::canonical`), `skills.rs` (`active_plugins`, `record_active`, `plugin_key`, `collect_skills`, `hash_origin_key`), `crate_metadata.rs` (`symposium_metadata`), `pm/cargo/workspace.rs` (`WorkspaceDeps`, `WorkspaceCrate`), and `crate_sources/mod.rs` (`RustCrateFetch`). + +## Dependency enablement + +A dependency's own plugin content — a `SYMPOSIUM.toml`, `[package.metadata.symposium]`, or a `skills/` directory — is reachable without any manifest pointing at it, but only with the user's consent: dependencies are not a trust root. + +1. `discovery::discover` asks the **untrusted** cargo transport for its `active_plugins(dep_ids)`: the plugins embedded in the workspace's dependencies. `CargoPm::active_plugins` fetches each dependency cache-only and inspects it — a workspace dep resolves into the source `cargo metadata` already extracted (`WorkspaceCrate::source_dir`), no probe/network — so registry-dep embedded plugins are discoverable too. The trusted registries (including the recommendations repo) are skipped, because their plugins are trust roots and never need consent. Each candidate is classified against `[plugins]` on its crate name — enabled by `use`, auto-enabled, declined, or an undecided candidate. Nothing is prompted or written. +2. At sync time, `skills::active_plugins` asks `discovery::enabled_dependencies` which crate names `[plugins] auto-enable` or an applicable `use` entry covers — workspace deps, plus `use`d crates that aren't deps at all — and seeds each as a cargo id on the same worklist a chained reference feeds, so `pms.load_plugin` honors the crate's manifest sources, skill groups, and its own `[[plugins]]` edges. This reads config rather than the offer list, so `cargo agents use ` loads a crate from crates.io whether or not the workspace depends on it, and even before its source has been fetched. (`CargoPm::search` is what lets `use` name such a crate; a name a configured registry already provides is skipped here so it isn't double-loaded.) +3. Independently, a registry plugin with no dependency gate anywhere loads *dormant* (`Plugin::requires_use`) and activates only when a `use` entry names it. The gate rides the `PredicateContext` (`with_used_names` / `is_used`), so skill resolution, hook dispatch, subcommand lookup, help, and MCP filtering all agree. + +The consent prompt and the `use` / `search` / `status` commands that record decisions are not implemented yet — today the `[plugins]` config is edited by hand. + +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`). ## Help rendering @@ -31,8 +44,8 @@ clap's auto help flag and help subcommand are disabled in `cli::Cli`; `--help`/` When the user runs `cargo agents ` for a name not built into the binary, clap's `allow_external_subcommands` routes it to `Commands::External(argv)`. -1. The binary (or library `cli::run`) calls `subcommand_dispatch::dispatch_external(sym, cwd, argv)`. -2. `find_subcommand` walks the plugin registry. For each plugin it applies the plugin-level `depends-on` predicate against the workspace, then looks up `argv[0]` in `plugin.subcommands`. If the entry has its own `depends-on` predicate, that must also match. Two or more matches → error. +1. The binary (or library `cli::run`) calls `subcommand_dispatch::dispatch_external(sym, cwd, argv)`, which first resolves the **active plugin set** (`skills::active_plugins` — registry plugins plus crate-sourced ones) so a crate's subcommands are dispatchable too. +2. `find_subcommand` walks that set. For each plugin it applies the plugin-level `depends-on` predicate against the workspace, then looks up `argv[0]` in `plugin.subcommands`. If the entry has its own `depends-on` predicate, that must also match. Two or more matches → error. 3. The matched subcommand's `command` field names an `Installation` on the same plugin. `installation::resolve_runnable` acquires the source if any, runs `install_commands`, and picks the `Runnable` (`Exec` for binaries, `Script` for shell scripts). 4. The child is spawned with stdio inherited. Its exit code is collapsed to a `u8` — the binary wraps it in `ExitCode::from`; the library treats non-zero as an error so the test harness can assert on success/failure. diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 6af82625..d2590a0b 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -6,7 +6,13 @@ Symposium is a Rust crate with both a library (`src/lib.rs`) and a binary (`src/ Everything hangs off the `Symposium` struct, which wraps the parsed `Config` with resolved paths for config, cache, and log directories. Two constructors: `from_environment()` for production and `from_dir()` for tests. -Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, plugin sources, defaults, and `auto-update` (off/warn/on, default on). User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `plugin_sources()` to resolve the effective list of plugin source directories. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching. +Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, `[[registry]]` entries (`[[plugin-source]]` is the retired spelling, still accepted), defaults, `auto-update` (off/warn/on, default on), and the `[plugins]` enablement section. User config is deserialized through `RawConfig` and validated into the runtime `Config`; runtime code does not deserialize `Config` directly. Provides `registry_instances()` to build the effective registry `PmInstance`s directly (the builtin recommendations entry, the builtin `user-plugins` entry, then the configured ones): a git `[[registry]]` entry becomes a `GitPm`, a path entry a `PathPm`, each a trust root named for its registry (that name is what its plugins are attributed to). There is no `ResolvedRegistry`/`content_dir` intermediate — a git registry's cache directory and its refresh live on the `GitPm` itself. `package_managers(deps)` prepends the fixed cargo transport (a `CargoPm` built over the shared `deps` resolver) to those to make the active `PmRegistry`. `detached_managers()` is the workspace-independent form (registry listing, crates.io search) — its cargo transport is built over a detached resolver that never runs `cargo metadata`. The `workspace_deps(cwd)` factory is the standard way to create a `WorkspaceDeps` — it wires in `cargo_override` and `cache_dir` so callers get both the `SYMPOSIUM_CARGO` override and cross-invocation disk caching, and returns it as an `Arc` so a `CargoPm` can hold one. + +`PluginsConfig` (the `[plugins]` section) is the config surface of the [enablement axis](#discoveryrs--dependency-discovery-and-enablement): `auto-enable` (dependency names pre-consented to, `"*"` for all), `use` (`UseEntry::Global(name)` or `{ name, workspace }` — the durable record of a deliberate enablement, scoped to one workspace or to all), and `disable` (names pruned from enablement, which is also where a declined discovery is recorded). Its query methods — `used_names_in(root)`, `is_auto_enabled`, `is_disabled`, `is_used_in` — all match names hyphen/underscore-insensitively, since these are user-typed package names; `has_enablement_entries` is the cheap "could enablement pull in a crate plugin?" check the hook path uses to decide whether to resolve the crate graph. The lists are plain `Vec`s so a later `cargo agents use` can add and remove entries and call `save_config`. + +### `pm/cargo/workspace.rs` — cargo workspace resolution + +The cargo-workspace resolution is **CargoPm's**, so it lives in the cargo PM's module (not a top-level `crate::workspace`) — `cargo metadata` is cargo's ecosystem, not a generic concern. `WorkspaceDeps` is the lazy, cached resolver for the cargo dependency graph: the first `load()` reads a disk cache keyed on `Cargo.lock` mtime, and on miss runs `cargo metadata` (extracting `root`, direct `crates`, and member dirs into a `LoadedWorkspace`) and writes through. The result is memoized in a `OnceLock`, so every accessor reads through a shared `&self` — which is what lets one resolver be shared as an `Arc`: a `CargoPm` holds one and *drives it* (`self.workspace.crates()` runs the metadata call), and core code that needs the workspace root/members reads the same instance, rather than each caller resolving its own. `WorkspaceCrate` carries `path` (the local dir for a path dependency) and `source_dir` (the extracted source `cargo metadata` located, populated for registry crates too), so a workspace dependency's source is served without a fresh probe. `detached()` is a resolver pre-set to "no workspace" for workspace-independent operations (registry listing, search). The types re-export at `crate::pm` for the core consumers of the workspace root/members (registry loading, sync, hook). The config-level `dirs.rs` (`SymposiumDirs`: config/cache paths + the `SYMPOSIUM_CARGO` override) stays in core; `Symposium::workspace_deps(cwd)` is the factory that wires them together. ### `agents.rs` — agent abstraction @@ -22,21 +28,25 @@ Implements `cargo agents sync`. Scans workspace dependencies, finds applicable s 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). -`sync` takes an `UpdateLevel` that it threads into skill resolution (`skills_applicable_to`), 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`. +`sync` takes an `UpdateLevel` that it threads into skill resolution (`skills::collect_skills`), controlling how aggressively `source.git` skill groups are re-fetched. Callers choose: the auto-sync path passes `Check` on `SessionStart` (refresh) and `None` otherwise (debounced); the binary's global `--update` flag feeds manual `cargo agents sync`. ### `plugins.rs` — plugin registry -Scans configured plugin source directories for TOML manifests and parses them into `Plugin` structs. Validation here turns the raw TOML into: +Loads plugin manifests from the configured registries and parses them into `Plugin` structs. Loading goes through the [package-manager layer](#pm--package-managers): `load_registry` asks each **trusted** `PmRegistry` instance (the configured registries — the cargo transport is not a trust root) for its plugins via `active_plugins`. A `PathPm` interprets each entry through `load_entry` as either a `SYMPOSIUM.toml` manifest plugin or a bare `SKILL.md` synthesized into a default plugin (`load_standalone_skill_plugin`); dependency-embedded crate plugins never load here. Refreshing a git registry's content is the `GitPm`'s `refresh` operation, driven by `ensure_registries` (startup) and `sync_registries` (`plugin sync`). `scan_source_dir` remains as the offline form used by the `plugin validate` CLI, which points at an arbitrary directory rather than a configured registry; it walks the same [layout](#pm--package-managers) rules and synthesizes bare skills the same way. + +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`. - `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 every-plugin-must-reference-a-dependency rule is waived (the chained reference 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`). +`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. -Also discovers standalone `SKILL.md` files not wrapped in a plugin. 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, the every-plugin-must-mention-a-dependency rule is waived, 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. +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. ### `installation.rs` — sources and acquisition @@ -52,10 +62,18 @@ Validates skill group source constraints during manifest validation: a group mus ### `pm/` — package managers -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. The `PackageManager` trait has two operations today — `fetch` and `list_deps` — and one implementation: `CargoPm` (`pm/cargo.rs`), whose `fetch` delegates to `crate_sources::RustCrateFetch` (path-dependency override, workspace pin, registry) and whose `list_deps` renders the workspace crates as cargo ids, the form [predicate evaluation](#predicaters--unified-activation-predicates) consumes. `CargoPm` also owns crate-to-plugin resolution: -- `load_plugin(name, workspace) -> Option` fetches the crate and 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). +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: +- `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 the trait; every dependency-list site (hook dispatch, sync, help rendering, subcommand dispatch, skill matching) builds its `PredicateContext` from `list_deps`. The RFD's other operations (`resolve`/`list_plugins`, `search`) are not routed through the seam yet. +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. + +One registry-instance PM exists today, reading content that is already on disk: + +- **`pm/layout.rs`** — the packaging convention it reads: `classify(dir)` (a directory with a `SYMPOSIUM.toml` is a plugin entry, one with a `SKILL.md` is a bare-skill entry — loaded as a default plugin — manifest wins) and `enumerate(root)` (recursive walk that does not descend into a claimed directory, sorted, erroring when the root is itself an entry). The layout carries no dependency information — an entry declares which dependencies activate it through its own manifest `depends-on`. Interpreting an entry's manifest stays in `plugins.rs`. +- **`pm/path.rs` — `PathPm`** — one local directory in the flat layout: `~/.symposium/plugins/`, a `[[registry]]` `path` entry, or the git cache directory a git registry unpacks into (serving as a `GitPm`'s inner reader). Its ids name the entry's subpath within the source. `active_plugins` loads every entry (via `plugins::load_entry`), `load_plugin(id)` loads the entry an id names, `fetch` joins the subpath back onto the directory, `search` substring-matches entry names, and `registry_source` reports it as a `Path`. A registry is a trust root, so its plugins activate without consent; entry-load failures surface as report warnings. +- **`pm/git.rs` — `GitPm`** — one `[[registry]]` `git` entry. Once fetched, a git repo is just a directory, so the reads (`active_plugins` / `load_plugin` / `search` / `fetch`) delegate to an inner `PathPm` over the cache directory; the git-specific part is `refresh` — pull the repository (honoring the entry's `auto-update` unless the caller forces it) — and `registry_source` reports it as a `Git`. The builtin `symposium-recommendations` repo is such a registry (an ordinary flat registry — each entry names the crates it advises with its own `depends-on`, so no namespacing or dedicated convention is involved). ### `crate_metadata.rs` — extract Cargo.toml metadata @@ -63,32 +81,60 @@ Extracts the `[package.metadata.symposium]` table from a crate `Cargo.toml` and ### `predicate.rs` — unified activation predicates -Defines one `Predicate` enum covering both dependency-graph matching and runtime/environment gating, plus `PredicateSet` (a list ANDed together) and `PredicateContext` (the workspace dependency list it evaluates against — `PackageId`s from the [package-manager layer](#pm--package-managers)'s `list_deps`). Two surface syntaxes lower to the same tree: +Defines one `Predicate` enum covering both dependency-graph matching and runtime/environment gating, plus `PredicateSet` (a list ANDed together) and `PredicateContext` (the workspace dependency list it evaluates against — `PackageId`s from the [package-manager layer](#pm--package-managers)'s `list_deps` — plus the `use`-enabled plugin names that wake dormant plugins, threaded in with `with_used_names` and read by `is_used`). Two surface syntaxes lower to the same tree: - 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. -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(...)` — wildcard and env/shell/path predicates dispatch without a cargo query. See the [predicates reference](../reference/predicates.md). +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 -Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources, discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. Every `source` funnels through one seam: `resolve_group_dirs` turns a group into a list of `ResolvedSkillDir` (a base directory + report labels), then `collect_skills_from_dirs` scans each base for `SKILL.md` files. `PluginSource` has exactly two variants — `Path` (already on disk, relative to the plugin's source dir) and `Git` (fetched via the git cache); a source is required, so there is no "no source" state. +Given a `PluginRegistry` and workspace dependencies, this module resolves skill group sources, discovers `SKILL.md` files, and evaluates dependency predicates at each level (plugin, group, skill) to determine which skills apply. It also owns `active_plugins` — the crate-expansion walk that produces the shared active plugin set every facet resolves over (see below) — so the same seam that resolves skills also feeds MCP-server, hook, and subcommand dispatch. Every `source` funnels through one seam: `resolve_group_dirs` turns a group into a list of `ResolvedSkillDir` (a base directory + report labels), then `collect_skills_from_dirs` scans each base for `SKILL.md` files. `PluginSource` has exactly two variants — `Path` (already on disk, relative to the plugin's source dir) and `Git` (fetched via the git cache); a source is required, so there is no "no source" state. + +The single seam every facet resolves over is `active_plugins`: it returns the full active set — every registry plugin whose gate holds (cloned), followed by the crate-sourced plugins transitively reached through `[[plugins]]` chained references and dependency enablement. Skills, MCP servers, hooks, and subcommands all iterate this one list, so a crate-sourced plugin's extensions dispatch exactly like a registry plugin's. Because crate loading is cache-only (`CargoPm::load_plugin` fetches with `UpdateLevel::None`), building the set is safe even on the per-event hook path. + +`active_plugins` is a **worklist fixed-point over the PM set** (`pms`). It seeds the active set with the trust-root plugins the registry loaded (`registry.plugins`, each gated by `record_active`), then works a queue of `PackageId`s: each active plugin's `[[plugins]]` chained `source.cargo` references (edges whose predicates hold, evaluated against the owning plugin's provenance) plus the consented enabled-dependency ids (below). For each id it calls `pms.load_plugin(id)` — dispatched to the owning PM (the cargo transport builds the crate as a first-class `ParsedPlugin` from `[package.metadata.symposium]` + `SYMPOSIUM.toml` + defaults) — gates each result, records it, and enqueues *its* own edges. A `visited` set keyed on `(pm, normalized name)` collapses diamonds (a crate reached two ways loads once, so its hooks don't double-fire and its subcommands aren't a false conflict) and breaks cycles; the finite crate universe bounds termination, so there is no depth cap. `collect_skills` then walks the active set and runs each plugin's skill groups through the ordinary `load_skills_for_group` pipeline; each discovered skill's install identity is the hash of its on-disk `SKILL.md` path (below), so a crate reached two ways dedupes to one install. A crate plugin's **custom predicate definitions** are the one facet not yet wired in — `warn_undispatched_crate_features` logs when a crate declares one. + +The enabled-dependency ids seed the same worklist: `discovery::enabled_dependencies` names the crates covered by `[plugins] auto-enable` or an applicable `use` entry — both the workspace deps it enables *and* the `use`d crates that aren't deps at all — and each is pushed as a cargo id, so a crate's `skills/` (or manifest) installs with no plugin manifest anywhere pointing at it. A name a configured registry already provides as a plugin (including a dormant one `use` wakes) is skipped here, so it isn't also fetched from crates.io. This is where consent lands — `enabled_dependencies` reads the `[plugins]` config, so only consented crates enter the worklist. `workspace_root` is a parameter because both this and the `use`-name context are scoped per workspace. -`[[plugins]]` chained references are how a crate becomes a plugin: after an active plugin's own skill groups, `skills_applicable_to` runs `expand_chained_plugins` over its `plugin.chained` edges. For each edge whose predicates hold it asks `CargoPm::load_plugin` for the crate, which always returns a first-class `ParsedPlugin` (built from `[package.metadata.symposium]` + `SYMPOSIUM.toml` + defaults). That plugin's own plugin-level predicates are honored, its skill groups run through the ordinary `load_skills_for_group` pipeline, and **its own `[[plugins]]` edges are expanded in turn** — a crate that names another crate (the reschema'd `[package.metadata.symposium]` redirect) is followed recursively. A per-top-level-plugin `visited` set (keyed on the normalized crate name via `canonical`) collapses diamonds and breaks cycles; `MAX_CHAIN_DEPTH` (10) is a backstop. The crate plugin's hooks/MCP/subcommands are parsed and carried but **not yet dispatched** (`warn_undispatched_crate_features` logs when present). Each discovered skill's install identity is the hash of its on-disk `SKILL.md` path (below), so a crate reached two ways dedupes to one install. +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 a `source.path` group and the standalone walk 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 — group discovery walks a canonicalized scan dir while `plugins.rs`'s standalone walk uses the configured source path verbatim, 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 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. `sync` prefers the plain `//` and only falls back to `-/` when needed: when more than one origin claims the same skill name, or when the unsuffixed slot is already occupied by a user-managed directory (one without the `.symposium` marker). The suffix is an 8-hex SHA-256 prefix for every origin kind. The `.symposium` marker, wildcard `.gitignore`, and stale-cleanup walk all key on the marker file rather than directory name shape, so transitions between unsuffixed and suffixed names self-heal across syncs. +### `discovery.rs` — dependency discovery and enablement + +Enablement is the second axis alongside activation predicates: predicates say *when* a plugin applies, enablement says *whether it may run at all*. The workspace and the configured registries are trust roots; a dependency deliberately is not, since depending on a crate should not let its author inject agent context. So a plugin embedded in a dependency runs only with consent, and a registry plugin with no gate to infer stays [dormant](#pluginsrs--plugin-registry) until named. The two trust roots are loaded and gated directly — registry plugins by `load_registry` + `Plugin::applies`, workspace plugins the same way — so they never reach discovery. What `discover` classifies is exactly the untrusted offers: the dependency-embedded plugins a transport (`CargoPm`) surfaces. The trust boundary is structural: a positional registry entry (an offer with a `subpath`) is loaded directly, while a dependency-embedded offer (no `subpath`) is the consent path's concern. + +`discover(sym, deps)` derives the workspace root from the resolver (empty when there is none) and asks the **untrusted** instances — the cargo transport — for their `active_plugins(dep_ids)`: the plugins embedded in the workspace's dependencies (fetched cache-only into the already-extracted source, no probe). Each is classified by `decide` against `[plugins]` on its crate name — `Used`, `Declined`, `AutoEnabled`, or `Candidate` — and lands in the matching field of the returned `Discovery` (`active` / `auto_enabled` / `candidates` / `declined`). Explicit decisions outrank standing ones, so a declined name stays declined. Discovery writes nothing; the trusted registries are skipped, since their plugins are trust roots and never need consent. + +`enabled_dependencies(sym, dep_ids, workspace_root)` is the activation side, consumed by `skills.rs` and `status.rs`: the crate names to load — workspace deps that `auto-enable` or an applicable `use` entry enables, plus `use`d crates that aren't deps at all (so `cargo agents use ` pulls a plugin from crates.io whether or not you depend on it), minus the disabled ones. `auto-enable` contributes only deps — it is consent for what a dependency carries, not a way to add crates. It reads config rather than the offer list, so a name works even before its source has been fetched. + +On top of that read side sits the consent write side. `prompt_for_consent(sym, deps, out)` asks one question per candidate (enable / ask me later / never ask again) and `apply_consent` records the answers — approvals into `auto-enable`, declines into `disable`, saved to the user config. Only explicit answers are recorded: the default ("ask me later") and Escape write nothing, so reflexively hitting Enter never permanently declines anything. + +**The prompt is inert unless `out.is_interactive()`** — a non-quiet, non-capturing `Output` attached to a terminal on *both* ends. That is the whole safety property: hook dispatch and anything an agent triggers run with a quiet output, and the library test harness runs with a capturing one, so neither can reach stdin. A bare TTY check would not do, since `cargo test` inherits the developer's terminal. The only caller is the `Commands::Sync` arm in `cli.rs` — the hook-triggered auto-sync path calls `sync::sync` directly and never passes through it. `pending_candidates` is the non-interactive counterpart: `hook.rs`'s `consent_hint` renders it into `SessionStart` context so the agent can tell the user, without symposium ever blocking. + +### `use_command.rs` / `search_command.rs` / `status_command.rs` — the enablement commands + +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. + +`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. + ### `subcommand_dispatch.rs` — plugin-vended subcommands -Routes the `Commands::External` arm of clap's `allow_external_subcommands`. `find_subcommand` walks the `PluginRegistry`, applying plugin-level and subcommand-level dependency predicates against the workspace, and returns the matched `(Plugin, Subcommand)` (or an error if more than one plugin claims the name). `dispatch_external` then looks up the named `Installation`, resolves it via `installation::resolve_runnable`, and spawns the child with stdio inherited — propagating the exit code as a `u8` so callers can convert to `ExitCode` (binary) or treat non-zero as an error (library). `applicable_subcommands` is the shared iterator over workspace-applicable plugin subcommands, reused by help rendering. +Routes the `Commands::External` arm of clap's `allow_external_subcommands`. `dispatch_external` first resolves the active plugin set (`skills::active_plugins`), so crate-sourced subcommands dispatch too; `find_subcommand` walks that set, applying plugin-level and subcommand-level dependency predicates against the workspace (with the applicable `use` names, so a dormant plugin's subcommands appear once it is enabled), and returns the matched `(Plugin, Subcommand)` (or an error if more than one plugin claims the name). `dispatch_external` then looks up the named `Installation`, resolves it via `installation::resolve_runnable`, and spawns the child with stdio inherited — propagating the exit code as a `u8` so callers can convert to `ExitCode` (binary) or treat non-zero as an error (library). `applicable_subcommands` is the shared iterator over the active set's applicable subcommands, taking an already-resolved `&[ParsedPlugin]` so help rendering and the `SessionStart` discovery hint reuse it. ### `help_render.rs` — `--help` rendering -Renders `cargo agents --help` as two audience-grouped sections, "Commands for humans" and "Commands for agents", mixing built-in subcommands with plugin-vended ones filtered by the active workspace. Built-in audience comes from `cli::builtin_audience`; plugin subcommands come from `subcommand_dispatch::applicable_subcommands`. +Renders `cargo agents --help` as two audience-grouped sections, "Commands for humans" and "Commands for agents", mixing built-in subcommands with plugin-vended ones filtered by the active workspace. Built-in audience comes from `cli::builtin_audience`; plugin subcommands come from `subcommand_dispatch::applicable_subcommands` over the resolved active plugin set (`skills::active_plugins`), so crate-sourced subcommands appear in help too. `help_text` is the single help decision, shared by the binary and the test harness. clap's own help flag and help subcommand are disabled (in `cli::Cli`), `--help`/`-h` is a manual `global` bool, and the entry points parse with `try_parse_from` — so help is decided *after* parsing and argument order (`--help --quiet`) is irrelevant. It returns the top-level grouped help for no subcommand / `--help` / `-h` / the bare `help` keyword; for ` --help` it re-renders clap's own per-command help by walking clap's command tree (so required-arg commands like `crate-info`, required-subcommand groups like `plugin`, and nested commands like `plugin list` all work); a plugin ` --help` returns `None` so dispatch forwards `--help` to the child. @@ -98,9 +144,9 @@ Renders `cargo agents --help` as two audience-grouped sections, "Commands for hu Handles the hook pipeline: parse agent wire-format input → auto-sync → builtin dispatch → plugin hook dispatch → serialize output. A single `WorkspaceDeps` (created via `sym.workspace_deps(cwd)`) is threaded through all stages — `run_auto_sync`, `dispatch_builtin`, `dispatch_plugin_hooks`, and the `SessionStart` prewarm. In-process, at most one `cargo metadata` invocation occurs per hook call (down from up to three previously). Across invocations, the disk cache means zero `cargo metadata` calls when `Cargo.lock` hasn't changed — the common case for `PreToolUse` hooks. -`run_auto_sync` takes a `session_start` flag: on `SessionStart` it skips the `Cargo.lock` freshness gate and syncs with `UpdateLevel::Check` (so upstream skill/source changes land once per session); every other event keeps the gated, `UpdateLevel::None` path. The matching `ensure_plugin_sources` refresh level is decided in the binary entry point from the same event. `SessionStart` additionally runs `prewarm_hook_sources` (best-effort, gated by `auto-sync`): it walks every applicable plugin's hooks and *refreshes* each installation's already-cached source via `refresh_installation_if_present` (`UpdateLevel::Check`). This is what keeps hook *binaries/scripts* (not just manifests) current once per session — in particular the only path that re-pulls a `cargo + git` hook binary whose branch moved — so the dispatch path can keep acquiring with `None` (cache/debounced) and pay no per-event network cost. It is **refresh-only**: a source that was never acquired is left alone (it installs lazily on first dispatch), so `SessionStart` never eagerly installs a tool a hook may never use. +`run_auto_sync` takes a `session_start` flag: on `SessionStart` it skips the `Cargo.lock` freshness gate and syncs with `UpdateLevel::Check` (so upstream skill/source changes land once per session); every other event keeps the gated, `UpdateLevel::None` path. The matching `ensure_registries` refresh level is decided in the binary entry point from the same event. `SessionStart` additionally runs `prewarm_hook_sources` (best-effort, gated by `auto-sync`): it walks every applicable plugin's hooks and *refreshes* each installation's already-cached source via `refresh_installation_if_present` (`UpdateLevel::Check`). This is what keeps hook *binaries/scripts* (not just manifests) current once per session — in particular the only path that re-pulls a `cargo + git` hook binary whose branch moved — so the dispatch path can keep acquiring with `None` (cache/debounced) and pay no per-event network cost. It is **refresh-only**: a source that was never acquired is left alone (it installs lazily on first dispatch), so `SessionStart` never eagerly installs a tool a hook may never use. -Builtin dispatch currently only acts on `SessionStart`, where `handle_session_start` composes two independently-computed `additionalContext` fragments: a `discovery_hint` (suggests `cargo agents --help` when the workspace exposes applicable plugin subcommands, reusing `subcommand_dispatch::applicable_subcommands`) and an `update_nudge` (the throttled self-update warning); the discovery hint is not gated behind the update-check throttle. The plugin dispatch path matches plugin `Hook`s against the event, selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a `ResolvedHook` per match (looking up the named installations on the plugin), then for each `ResolvedHook`: acquires its `requirements` (best-effort), runs `install_commands` after the source step, picks a `Runnable` from (hook-or-install) `executable`/`script`, and spawns it (binary directly for `Exec`, via `sh ` for `Script`). Input is delivered in the selected format; output is converted back to the agent's wire format before returning. +Builtin dispatch currently only acts on `SessionStart`, where `handle_session_start` composes three independently-computed `additionalContext` fragments: a `discovery_hint` (suggests `cargo agents --help` when the workspace exposes applicable plugin subcommands, reusing `subcommand_dispatch::applicable_subcommands`), a `consent_hint` (names the dependency plugins awaiting consent, via `discovery::pending_candidates` — a hook must never block on stdin, so the candidates are reported as context pointing at `cargo agents sync` / `cargo agents use` rather than asked about), and an `update_nudge` (the throttled self-update warning); only the nudge is gated behind the update-check throttle. The plugin dispatch path matches plugin `Hook`s against the event over the active plugin set (`skills::active_plugins`, so crate-sourced hooks fire too), selects the best format for each plugin (native match > symposium > single-other-agent fallback), builds a `ResolvedHook` per match (looking up the named installations on the plugin), then for each `ResolvedHook`: acquires its `requirements` (best-effort), runs `install_commands` after the source step, picks a `Runnable` from (hook-or-install) `executable`/`script`, and spawns it (binary directly for `Exec`, via `sh ` for `Script`). Input is delivered in the selected format; output is converted back to the agent's wire format before returning. ### `state.rs` — persistent state diff --git a/md/design/repositories.md b/md/design/repositories.md index d89e5e78..8489c04e 100644 --- a/md/design/repositories.md +++ b/md/design/repositories.md @@ -12,4 +12,4 @@ The Claude Code plugin that connects Symposium to Claude Code. Contains a static ### [recommendations](https://github.com/symposium-dev/recommendations) -The central plugin repository. Crate authors submit skills and plugin manifests here. Symposium fetches this as a plugin source by default. +The central plugin repository. Crate authors submit skills and plugin manifests here. Symposium fetches this as a registry by default. diff --git a/md/design/subcommands.md b/md/design/subcommands.md index 1c150429..0b417a24 100644 --- a/md/design/subcommands.md +++ b/md/design/subcommands.md @@ -98,7 +98,7 @@ This rule keeps `cargo agents --help` outside a workspace limited to globally-ap `cargo agents --help` is rendered in two sections: -- **Commands for humans** — operational commands a user runs themselves: `init`, `plugin`, `self-update`, `sync`, plus any plugin-vended subcommand with `audience = "humans"`. +- **Commands for humans** — operational commands a user runs themselves: `init`, `plugin`, `search`, `self-update`, `status`, `sync`, `telemetry`, `use`, plus any plugin-vended subcommand with `audience = "humans"`. - **Commands for agents** — discovery and analysis tools for the agent to invoke: `crate-info` and plugin-vended subcommands with `audience = "agents"` (the default). The default of `audience = "agents"` reflects the expected shape of plugin-vended commands: most are analysis or context-fetching tools surfaced to agents, not workflows for humans. The exceptional case explicitly opts in. @@ -113,7 +113,9 @@ The renderer reads the active plugin registry filtered by workspace, so the help `cargo agents --help` is a *pull* surface — an agent only sees the crate-aware subcommands if it already knows to run it. To *push* that affordance, the built-in `SessionStart` hook injects a one-line hint suggesting `cargo agents --help` whenever the active workspace exposes at least one applicable plugin-vended subcommand. The trigger reuses the same workspace-filtered set as the help renderer (`applicable_subcommands`), so the hint stays silent in projects with nothing to discover. -The hint shares `SessionStart`'s `additionalContext` with the [update nudge](./hook-flow.md); each fragment is computed independently, and the discovery hint is not gated behind the update-check throttle. Agents without hook registration (OpenCode, Goose) don't receive it; for them `cargo agents --help` is the only discovery surface. +The hint shares `SessionStart`'s `additionalContext` with the [update nudge](./hook-flow.md) and the pending-consent hint; each fragment is computed independently, and only the nudge is gated behind the update-check throttle. Agents without hook registration (OpenCode, Goose) don't receive it; for them `cargo agents --help` is the only discovery surface. + +The consent hint is the same pattern applied to enablement. A hook runs on the agent's behalf and must never block on stdin, so when dependency discovery finds plugins awaiting consent, `SessionStart` names them as context and points at `cargo agents sync` (which asks interactively) or `cargo agents use ` — explicitly telling the agent not to enable them itself. The interactive prompt lives only in the `sync` command's own CLI arm, gated on `Output::is_interactive()`. ## Audience as metadata, not enforcement diff --git a/md/design/sync-agent-flow.md b/md/design/sync-agent-flow.md index 627461fe..3ef94650 100644 --- a/md/design/sync-agent-flow.md +++ b/md/design/sync-agent-flow.md @@ -4,9 +4,11 @@ Scans workspace dependencies, installs applicable skills into agent directories, ## Flow +0. **Consent prompt** (interactive `cargo agents sync` only) — ask about each dependency plugin awaiting consent and record the answers in `[plugins]` before resolution runs, so an approval installs in this same sync. Gated on `Output::is_interactive()`: the hook-triggered auto-sync calls `sync::sync` directly and never reaches this step. See [dependency discovery](./module-structure.md#discoveryrs--dependency-discovery-and-enablement). + 1. **Find workspace root** — run `cargo metadata` to locate the workspace manifest directory. -2. **Load plugin sources** — read the user config's `[[plugin-source]]` entries and load their plugin manifests. For git sources, fetch/update as needed. +2. **Load registries** — read the user config's `[[registry]]` entries, ask each one's package manager for the plugin-bearing entries it offers, and load their plugin manifests. For git registries, fetch/update as needed. 3. **Scan dependencies** — read the full dependency graph from the workspace. diff --git a/md/reference/cargo-agents-search.md b/md/reference/cargo-agents-search.md new file mode 100644 index 00000000..a8289402 --- /dev/null +++ b/md/reference/cargo-agents-search.md @@ -0,0 +1,52 @@ +# `cargo agents search` + +Find plugins across every configured registry. + +## Usage + +```bash +cargo agents search +``` + +## Options + +| Flag | Description | +|------|-------------| +| `` | Name, or name fragment, to look for | + +## Behavior + +The query is a case-insensitive substring match — the same looseness +`cargo search` has. Results come from two arms and are printed grouped by the +instance each hit came from: + +1. **Already loaded** — plugin names in the plugin registry (a bare `SKILL.md` + is loaded as a plugin, so it appears here too). A configured registry is a + trust root, so a hit here is available now, with no `use` needed (unless the + plugin is dormant, which is noted). +2. **Offered by a package manager** — each configured + [registry's](./plugin-source.md) package manager is searched in turn. + +A package manager without a searchable registry contributes nothing rather +than failing, and an instance that errors outright (an offline registry, say) +is skipped — so `search` degrades to the results it can get instead of failing +the command. + +With `--json`, each hit is emitted as a `search_match` event carrying its +`origin`, `name`, and — where the registry provides them — `version` and +`description`. + +## Example + +```bash +$ cargo agents search widget +ℹ️ from user-plugins: + widget-guidance + Guidance for working with widgets +ℹ️ from symposium-recommendations: + widget-skills 1.2.3 + Skills for widget +``` + +Pass a name from the output to [`cargo agents use`](./cargo-agents-use.md) to +enable it. diff --git a/md/reference/cargo-agents-status.md b/md/reference/cargo-agents-status.md new file mode 100644 index 00000000..7016302d --- /dev/null +++ b/md/reference/cargo-agents-status.md @@ -0,0 +1,45 @@ +# `cargo agents status` + +Show which plugins are enabled for this workspace, and why. + +## Usage + +```bash +cargo agents status +``` + +Must be run from within a Rust workspace. + +## Behavior + +Symposium separates two questions. *Enablement* asks whether a plugin may run +at all; [*activation predicates*](./predicates.md) ask when it applies. +`status` reports both, one line per plugin, each naming its **enablement +root** — so it answers "why is this here?" with "enabled via `serde`". + +Each line is in one of four states: + +| State | Meaning | +|-------|---------| +| `active` | Enabled and its predicates hold here. The root names the trust root: workspace membership, a configured registry, `[plugins] auto-enable`, or a `[plugins] use` entry. | +| `dormant` | Loaded but contributing nothing: a registry plugin awaiting [`cargo agents use`](./cargo-agents-use.md), or one whose predicates don't currently hold. | +| `candidate` | Discovered in a dependency and awaiting consent. These are exactly what an interactive [`cargo agents sync`](./cargo-agents-sync.md) asks about. | +| `declined` | Recorded in `[plugins] disable` — the record of pruned plugins and declined discoveries. | + +Discovery is cache-only, so a dependency whose source has not been fetched yet +is simply not listed as a candidate. Enabling it by name still works. + +With `--json`, each line is emitted as a `plugin_status` event carrying +`name`, `state`, `root`, and — for a discovered dependency plugin — the +resolved `version`. + +## Example + +```bash +$ cargo agents status +✅ my-tool — workspace member +✅ serde-skills 1.0.0 — `[plugins] use` +💤 team-conventions — registry `user-plugins` (dormant: awaiting `cargo agents use`) +❓ widget-lib 0.3.1 — found via dependency `widget-lib`, awaiting consent (`cargo agents use widget-lib`) +➖ noisy-crate — declined (`[plugins] disable`) +``` diff --git a/md/reference/cargo-agents-sync.md b/md/reference/cargo-agents-sync.md index e3e4ea84..3c916e97 100644 --- a/md/reference/cargo-agents-sync.md +++ b/md/reference/cargo-agents-sync.md @@ -28,6 +28,25 @@ Must be run from within a Rust workspace. Performs the following steps: 7. **Register hooks** — ensures hooks and MCP servers are registered for all configured agents. Registers both global hooks (for all projects) and project-specific hooks (for the current project). Unregisters hooks for agents no longer in the config. +## Consent prompt + +Before syncing, an interactive `cargo agents sync` asks about each dependency +whose source embeds an agent plugin that you have not decided about yet. +Depending on a crate means compiling its code, not letting its author inject +agent context, so these stay off until you say otherwise. Three answers: + +- **Ask me later** (the default) — records nothing; you are asked again next time. +- **Enable** — recorded in `[plugins] auto-enable`, and installed by this same sync. +- **No — don't ask again** — recorded in `[plugins] disable`. + +Only explicit answers are recorded, so hitting Enter through the prompt never +permanently declines anything. Escape leaves the remaining questions undecided. + +The prompt only runs in a real terminal session. The automatic sync below — +and anything else an agent triggers — never prompts; there, pending candidates +are named in the `SessionStart` context instead, and +[`cargo agents status`](./cargo-agents-status.md) lists them as `candidate`. + ## Automatic sync By default (`auto-sync = true`), `cargo agents sync` runs automatically during hook invocations. This keeps skills in sync with workspace dependencies without manual intervention. Set `auto-sync = false` in the user config to disable this and sync manually. diff --git a/md/reference/cargo-agents-use.md b/md/reference/cargo-agents-use.md new file mode 100644 index 00000000..7d5db09c --- /dev/null +++ b/md/reference/cargo-agents-use.md @@ -0,0 +1,72 @@ +# `cargo agents use` + +Enable a plugin by name, and sync it into the workspace immediately. + +## Usage + +```bash +cargo agents use [--global] +cargo agents use --remove [--global] +``` + +## Options + +| Flag | Description | +|------|-------------| +| `` | Plugin or crate name to enable | +| `--global` | Enable for every workspace instead of just the current one | +| `--remove` | Drop a previously recorded enablement instead of adding one | + +## Behavior + +`use` is the durable, by-name form of consent. It records a `use` entry in the +[`[plugins]` section](./configuration.md) of the user config: + +```toml +[plugins] +use = [ + "everywhere-plugin", # --global + { name = "crate-a", workspace = "/home/me/my-project" }, # default +] +``` + +Then it runs a sync, so the plugin's skills install right away rather than +waiting for the next one. + +Two things a `use` entry can enable: + +- **A dependency's embedded plugin.** Depending on a crate means compiling its + code, not letting its author inject agent context, so a dependency is not a + trust root — its embedded plugin stays off until you say otherwise. `use` is + the by-name way to say so (`[plugins] auto-enable` is the ahead-of-time way). +- **A dormant registry plugin.** A plugin whose manifest names no dependency + has nothing to gate it on, so it loads dormant. A `use` entry naming it is + what wakes it. + +Anything a configured registry already offers under a `depends-on` gate is +enabled by configuration — pointing config at a registry is the act of +trusting its curation — so `use`-ing it is a no-op and reports as such. + +Enablement is not activation: `use` only adds to what *may* run. The plugin's +own [predicates](./predicates.md) still decide when it applies. + +The name must resolve to something before it is recorded — a dormant registry +plugin, a workspace dependency, or a registry search hit — otherwise the +command errors and writes nothing. Use +[`cargo agents search`](./cargo-agents-search.md) to find the right name. + +### `--remove` + +Removes the entry in the matching scope: without `--global` the entry recorded +for the current workspace, with it the unscoped one. A scope mismatch (or no +entry at all) is an error rather than a silent success. The sync that follows +reaps the plugin's installed skills. + +## Example + +```bash +cargo agents search widget # find it +cargo agents use widget-skills # enable it here +cargo agents status # confirm why it is on +cargo agents use widget-skills --remove +``` diff --git a/md/reference/cargo-agents.md b/md/reference/cargo-agents.md index 54a498ca..fcba4653 100644 --- a/md/reference/cargo-agents.md +++ b/md/reference/cargo-agents.md @@ -8,6 +8,9 @@ |---------|-------------| | [`cargo agents init`](./cargo-agents-init.md) | Set up user-wide configuration | | [`cargo agents sync`](./cargo-agents-sync.md) | Synchronize skills with workspace dependencies | +| [`cargo agents search`](./cargo-agents-search.md) | Search configured registries for plugins | +| [`cargo agents use`](./cargo-agents-use.md) | Enable a plugin by name (`--remove` to disable) | +| [`cargo agents status`](./cargo-agents-status.md) | Show which plugins are enabled for this workspace, and why | | [`cargo agents plugin`](./cargo-agents-plugin.md) | Manage plugin sources | | [`cargo agents self-update`](./cargo-agents-self-update.md) | Update symposium to the latest version | | [`cargo agents crate-info`](./cargo-agents-crate-info.md) | Find crate sources (agent-facing) | diff --git a/md/reference/configuration.md b/md/reference/configuration.md index c30f8702..ffcdae98 100644 --- a/md/reference/configuration.md +++ b/md/reference/configuration.md @@ -23,11 +23,11 @@ level = "info" symposium-recommendations = true user-plugins = true -[[plugin-source]] +[[registry]] name = "my-org" git = "https://github.com/my-org/symposium-plugins" -[[plugin-source]] +[[registry]] name = "local-dev" path = "my-plugins" ``` @@ -102,23 +102,52 @@ enabled = true ## `[defaults]` -Controls the two built-in plugin sources. Both are enabled by default. +Controls the two built-in registries. Both are enabled by default. | Key | Type | Default | Description | |-----|------|---------|-------------| | `symposium-recommendations` | bool | `true` | Fetch plugins from the [symposium-dev/recommendations](https://github.com/symposium-dev/recommendations) repository. | | `user-plugins` | bool | `true` | Scan `~/.symposium/plugins/` for user-defined plugins. | -## `[[plugin-source]]` +## `[[registry]]` -Defines additional plugin sources. Each entry must have exactly one of `git` or `path`. +Defines additional registries — directories or repositories offering plugins. Each entry must have exactly one of `git` or `path`. `[[plugin-source]]` is the retired spelling of this table and is still accepted. | Key | Type | Default | Description | |-----|------|---------|-------------| -| `name` | string | *(required)* | A name for this source (used in logs and cache paths). | -| `git` | string | — | Repository URL. Fetched and cached under `~/.symposium/cache/plugin-sources/`. | +| `name` | string | *(required)* | A name for this registry. Used in logs and cache paths, and to attribute the plugins loaded from it. | +| `git` | string | — | Repository URL. Fetched and cached under `~/.symposium/cache/plugin-sources/`, then read as a local directory. | | `path` | string | — | Local directory containing plugins. Relative paths are resolved from `~/.symposium/`. | -| `auto-update` | bool | `true` | Check for updates on startup. Only applies to `git` sources. | +| `auto-update` | bool | `true` | Check for updates on startup. Only applies to `git` registries. | + +## `[plugins]` + +Enablement: which plugins are allowed to run at all, as distinct from the [predicates](./predicates.md) that decide *when* an enabled plugin applies. + +Symposium trusts two things without asking: the workspace you are in, and the [registries](#registry) it is configured with. A registry exists to curate plugins, so enabling one is the act of accepting its curation. Both built-in registries count here and are on by default — `user-plugins` is your own directory, while `symposium-recommendations` is a list curated by the Symposium project and trusted until you turn it off in [`[defaults]`](#defaults). + +Your dependency list is deliberately not a trust root. Depending on a crate means compiling its code; it should not silently let the crate's author add instructions to your agent. So a plugin embedded in a dependency runs only once you say so, and a registry plugin that names no dependency anywhere is *dormant* — loaded and listed, but inactive — until you enable it by name. + +Trust follows whoever supplies the *content*, not the package the content is about: a registry entry recommending a plugin for `serde` is the registry's own content and is trusted, while `serde`'s embedded plugin is not. One consequence is worth knowing: a trusted plugin may name a crate with a [`[[plugins]]` chained reference](./plugin-definition.md), and that crate's plugin content then loads without a `[plugins]` entry of its own — the registry is vouching for it. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `auto-enable` | array of strings | `[]` | Dependency names whose embedded plugins load without being asked about. `"*"` pre-consents to every dependency. | +| `use` | array | `[]` | Plugins enabled deliberately. Each entry is either a plain name (enabled in every workspace) or `{ name = "...", workspace = "/path" }` (enabled only while working in that workspace root). | +| `disable` | array of strings | `[]` | Names that must never be enabled. Takes precedence over `auto-enable`, including over `"*"`. | + +Names are matched hyphen/underscore-insensitively, like crate names: `widget-lib` and `widget_lib` are the same entry. + +```toml +[plugins] +auto-enable = ["widget-lib"] +disable = ["noisy-crate"] +use = ["standalone-plugin", { name = "team-tools", workspace = "/home/me/work/service" }] +``` + +`use` is what wakes a dormant plugin, and it also enables a plugin whether or not any dependency references it. `auto-enable` is narrower: it is consent for what a dependency you already have carries with it. + +You rarely edit this section by hand. [`cargo agents use`](./cargo-agents-use.md) writes and removes `use` entries; the [consent prompt](./cargo-agents-sync.md#consent-prompt) in an interactive `cargo agents sync` writes `auto-enable` and `disable`; and [`cargo agents status`](./cargo-agents-status.md) reports what the section currently decides. ## Directory resolution diff --git a/md/reference/plugin-definition.md b/md/reference/plugin-definition.md index c6a22c77..04f4b111 100644 --- a/md/reference/plugin-definition.md +++ b/md/reference/plugin-definition.md @@ -41,7 +41,7 @@ source.path = "skills" | `predicate` | array of tables | no | Custom predicate definitions (`[[predicate]]`). See [Custom predicates](#predicate). | | `mcp_servers` | array of tables | no | MCP server registrations (`[[mcp_servers]]`). | -**Note**: Every plugin must reference at least one crate somewhere — at the plugin level, in `[[skills]]` groups, or in `[[mcp_servers]]` entries — via a `depends-on` list or a `depends-on(...)` [predicate](./predicates.md). Plugins without any crate targeting will fail validation. +**Note**: A plugin that references no dependency anywhere — at the plugin level, in `[[skills]]` groups, `[[mcp_servers]]` entries, or `[[plugins]]` entries — via a `depends-on` list or a `depends-on(...)` [predicate](./predicates.md) is **dormant**: it loads, but it never activates until the user enables it by name in the [`[plugins] use`](./configuration.md#plugins) config. Use `depends-on = ["*"]` for a plugin that should always be active. (Workspace plugins are unaffected: membership in the active workspace is itself the gate.) ## Plugin-level filtering diff --git a/md/reference/plugin-source.md b/md/reference/plugin-source.md index fb95996e..55453ba2 100644 --- a/md/reference/plugin-source.md +++ b/md/reference/plugin-source.md @@ -7,9 +7,9 @@ A **plugin source** is a directory or repository containing plugins and standalo Symposium scans a plugin source recursively to find plugins and standalone skills: * A [plugin](./plugin-definition.md) is a directory that contains a `SYMPOSIUM.toml` file; -* A [standalone skill](./skill-definition.md) is a directory that contains a `SKILL.md` and does not contain a `SYMPOSIUM.toml` file. Standalone skills must have `depends-on` metadata in their frontmatter. +* A directory that contains a `SKILL.md` and no `SYMPOSIUM.toml` is loaded as a plugin with default values — named for the skill's frontmatter `name`, with the skill's `depends-on` acting as the plugin's activation gate. `depends-on` is optional: a skill that names no dependency loads *dormant* (it activates once you enable it by name with [`cargo agents use`](./cargo-agents-use.md)), exactly like a gateless `SYMPOSIUM.toml` plugin. -**We do not allow plugins or standalone skills to be nested within one another.** When we find a directory that is either a plugin or a skill, we do not search its contents any further. +**We do not allow these entries to be nested within one another.** When we find a directory that is either a plugin or a skill, we do not search its contents any further. ### Example structure diff --git a/md/rfds/registry-centric-plugins/README.md b/md/rfds/registry-centric-plugins/README.md index aba58584..e6b1c7f9 100644 --- a/md/rfds/registry-centric-plugins/README.md +++ b/md/rfds/registry-centric-plugins/README.md @@ -94,6 +94,10 @@ This works the same way but activates those plugins across all workspaces. Users could also edit their config.toml to define their specific predicates for when they want plugins to be activated (e.g., when a certain file is present in the workspace, for Rust workspaces only, etc). +### Dormancy + +A registry plugin whose manifest references no dependency anywhere — no `depends-on`, no `depends-on(...)` predicate, no `[[skills]]`/`[[hooks]]`/`[[mcp]]`/`[[plugins]]` gate that names one — has nothing to infer an activation gate from. Rather than treat that as "always on" (which would fire every curated plugin in every workspace), such a plugin is *dormant*: installed and known, but inactive until a `[plugins] use` entry names it. `depends-on = ["*"]` is the explicit "always active" spelling. The positional origins never go dormant, because where they were found supplies the gate: a crate plugin is reached through a reference to its own crate, and a workspace plugin is gated by workspace membership. + ## As a crate author @@ -167,30 +171,18 @@ Every plugin has a canonical identifier — a tuple `(pm, name, version)` — as #### Predicates -The plugin itself and each of its subsections can be gated with a `predicates = [...]` field. When a plugin is installed, the content is only *activated* if the predicate matches. - -Common predicates include: - -* `workspace()`, true if this plugin is part of the active workspace; -* `used()`, true if this plugin was explicitly used by the user; -* `workspace-dependency()`, true if plugin is a dependency of some project in the current workspace; -* `env(FOO=BAR)`, true if the environment variable `FOO` is set to `BAR`; -* `file-exists(path)`, true if the given file exists; -* `depends-on(package-id)`, true if some project in the workspace depends on `package-id`; - * This is delegated to the installed Symposium package managers. By default it refers to any of them, but you can be more specific, e.g., `depends-on(cargo, serde, 1.0)` or `depends-on(npm, leftpad)`. -* `shell(command)`, true if the given command exits with code 0; -* `workspace-directory(/home/ferris/dev/rust)`, true if the active workspace is a subdirectory of the given path. - -Note that the first three predicates are not mutually exclusive. A given plugin may (a) appear in the workspace; (b) appear in the dependencies of a project in the workspace; and (c) be explicitly used all at the same time. +The plugin itself and each of its subsections can be gated with a `predicates = [...]` field (plus the `depends-on` shorthand). When a plugin is installed, the content is only *activated* if the predicates match. The full model is in the [predicates reference](../../reference/predicates.md); the functions are: -There is also a shorthand for `depends-on` that reuses the PM's `resolve` format: +* `depends-on()`, true if some project in the workspace depends on ``. A version requirement is allowed (`depends-on(serde>=1.0)`), and `depends-on(*)` matches any workspace. +* `workspace-member()`, true if the plugin this predicate belongs to is defined by a member of the active workspace. +* `env(FOO)` / `env(FOO=BAR)`, true if the environment variable is set (to `BAR`). +* `path_exists()`, true if the argument resolves to an existing path — checked on the filesystem, then on `$PATH` for a bare name (so it matches a local file or an installed binary). +* `shell()`, true if `` run via `sh -c` exits `0`. +* the combinators `not(

)`, `any(

, …)`, `all(

, …)`, which together give full boolean logic. -```toml -[depends-on] -cargo = { tokio = "1", serde = "1" } -``` +`depends-on` is sugar for the common dependency case: `depends-on = ["serde", "tokio"]` lowers to `any(depends-on(serde), depends-on(tokio))`, ANDed with any `predicates`. -This is equivalent to `predicates = ["depends-on(cargo, tokio, 1)", "depends-on(cargo, serde, 1)"]` — the value under `depends-on.$pm` is passed to that PM's `resolve` to determine what packages are referenced. +Whether a plugin was **explicitly used** and whether it is a **workspace dependency** are *not* predicates in the shipped design. "Used" is the enablement axis — a `[plugins] use` entry (see [Explicit use](#explicit-use)) — which is also how a [dormant plugin](#dormancy) wakes; dependency presence is `depends-on()`. These are not mutually exclusive: a plugin can be a workspace member, a dependency, and explicitly used all at once. #### Default content @@ -209,6 +201,8 @@ These defaults establish the skills conventions described earlier. For example, ### Package managers +> **Implementation note.** The shipped PM layer is **in-process**: [`PmRegistry`](../../design/module-structure.md#pm--package-managers) holds each PM as a `Box` — the cargo transport plus one `path` instance per configured registry — and the operation set is `active_plugins(deps)` / `load_plugin(id)` / `list_deps` / `search` / `fetch`. The original `resolve` operation folded into `load_plugin`: a `[[plugins]] source.cargo` reference is resolved by *loading* the named id, not by a separate lowering step. The separate-binary JSON-RPC protocol described below is the out-of-process *target* — not yet built; `PmRegistry` is the seam that will spawn and talk to those binaries. See [remaining work](#future-work). + A package manager (PM) is a pluggable backend that knows how to find, resolve, fetch, and enumerate plugins from a particular ecosystem. Each PM is a separate binary that Symposium invokes — installed as an `[[installable]]` from either the recommendations repository or the user's root config. The `path` PM is built into the Symposium binary itself (since it just reads local directories), but `cargo`, `git`, and any future PMs (npm, pypi, etc.) are separate binaries. Every PM implements four operations: @@ -226,6 +220,8 @@ See the [PM interface sub-RFD](./pm-interface/README.md) for full protocol detai #### Example: The recommendations manager +> **Implementation note.** The shipped design does *not* build a dedicated recommendations PM or the `cargo//` namespace convention below. The actual `symposium-recommendations` repository is a flat registry read by the ordinary `PathPm`, and each entry declares which crates activate it with its own `depends-on` (evaluated when the plugin is loaded, like any registry plugin). A recommendations plugin is just "a plugin activated when certain deps are present," which the normal `depends-on` predicate already expresses — so the layout carries no dependency information and no separate PM is involved. The namespace convention could be re-added later as a thin lowering inside `PathPm` (a `cargo//` entry implying `depends-on(cargo:)`) if it earns its keep. The proposal below is kept as the original design. + The recommendations PM is provided by the `symposium-recommendations` crate. It operates over a repository of curated plugin directories, organized by the PM namespace they relate to: ``` @@ -308,14 +304,19 @@ We plan follow-up RFDs with more details on each component: ### Future work -- **Fixed-point resolution** — a convergence loop for when plugins define custom predicates that other plugins depend on. Needed once custom predicates are used across plugin boundaries. -- **Custom PMs** — allowing plugins to define new PM types (npm, pypi, internal). The PM interface is the seam; registration and discovery mechanism TBD. +The remaining work, roughly in dependency order: + +- **Out-of-process PM binaries** — the shipped PM layer is in-process (see the note under [Package managers](#package-managers)). The design calls for each ecosystem PM (`cargo`, `git`, npm, pypi, …) to be a **separate binary** spoken to over JSON-RPC, with only the `path` PM built in. `PmRegistry` is the seam that will spawn and talk to them; the operation set and identity tuple are already in that shape, so this is a transport change, not a redesign. The JSON-RPC protocol, error semantics, and caching contract are the sub-RFD to write. +- **PMs defined by plugins** — letting a plugin *register a new PM type* (so an org can ship an internal-registry PM, or an ecosystem PM like npm/pypi, as an ordinary plugin). Depends on the out-of-process protocol above; the registration and discovery mechanism is TBD. +- **Additional built-in ecosystems** — there is no `git` PM yet (git *sources* for skill groups and installations exist, but a chained `source.git` is rejected); npm/pypi are unstarted. +- **Custom predicate dispatch across plugins (fixed-point)** — a crate-embedded plugin can *define* a custom predicate, but its definition is not yet registered, so it cannot be evaluated (only registry plugins' custom predicates are). Wiring a crate's *own* custom predicates into its facet evaluation is tractable; the general case — one plugin defines a predicate that another plugin's gate references — needs a convergence loop, since the definition must be loaded before the gate that uses it can be evaluated. +- **Chained-edge version enforcement** — `[[plugins]] source.cargo = "widget>=1"` records the version requirement but does not enforce it: expansion enqueues the crate with no version, so it resolves against the workspace pin regardless. Enforcement would compare the resolved version to the recorded requirement and warn/skip on mismatch. - **Policy plugins** — org-level enforcement (deny-lists, approval gates). Separate extension point, design TBD. -## Implementation order +## Implementation status -1. **Plugin model** — what a plugin is, defaults, predicates, chained plugins. -2. **PM interface + Cargo PM** — the foundation. Identity tuple, four operations, JSON-RPC protocol, first real PM. -3. **Discovery & sync** — PM `list-deps` + `search`, prompt UX. Enables "plugins from your dependencies." -4. **User-managed plugins** — `use`/`remove`/`status` UX. -5. **Future work** — fixed-point, custom PMs, policy — as demand arises. +1. **Plugin model** — ✅ landed. Plugins, `[defaults]`, predicates, chained plugins, dormancy. +2. **PM interface + Cargo PM** — ✅ landed **in-process**. Identity tuple and the operation set (`active_plugins` / `load_plugin` / `list_deps` / `search` / `fetch`); the out-of-process JSON-RPC form is future work. +3. **Discovery & sync** — ✅ landed. Dependency-embedded plugin discovery, the consent prompt, and the `[plugins]` config. The recommendations-via-`search` half was intentionally replaced by the flat-registry model (see the note under [the recommendations manager](#example-the-recommendations-manager)). +4. **User-managed plugins** — ✅ landed. `use` / `remove` / `status`, workspace vs. global scope. +5. **Remaining** — see [Future work](#future-work). diff --git a/md/workspace-skills.md b/md/workspace-skills.md index 56396533..245a10d8 100644 --- a/md/workspace-skills.md +++ b/md/workspace-skills.md @@ -33,7 +33,7 @@ The second group is how the `.agents/skills` convention works: it is gated by th Two details specific to workspace manifests: - `name` may be omitted; it defaults to the directory name. -- No top-level `depends-on` is required — a workspace plugin is always active in its own workspace, so the usual "every plugin must name a dependency" rule is waived. +- No top-level `depends-on` is required — workspace membership is the gate, so a workspace plugin never goes [dormant](./reference/plugin-definition.md) the way a gateless registry plugin does. Components that should apply only to people developing the workspace (not to dependents of a published crate) can be gated with the [`workspace-member()` predicate](./reference/predicates.md). diff --git a/src/bin/cargo-agents.rs b/src/bin/cargo-agents.rs index ea44826f..cab3773d 100644 --- a/src/bin/cargo-agents.rs +++ b/src/bin/cargo-agents.rs @@ -42,7 +42,7 @@ async fn main() -> ExitCode { // `--help` / `-h` / `help` / no subcommand -> audience-grouped top-level help (or clap's // per-command help for ` --help`). // Plugin ` --help` returns `None` here and is forwarded to the child by dispatch below. - if let Some(text) = help_render::help_text(parse.as_ref(), &args_str, &sym, &cwd) { + if let Some(text) = help_render::help_text(parse.as_ref(), &args_str, &sym, &cwd).await { print!("{text}"); return ExitCode::SUCCESS; } @@ -73,6 +73,13 @@ async fn main() -> ExitCode { match &cli.command { Some(Commands::Init { .. }) => tracing::info!("cargo agents init"), Some(Commands::Sync) => tracing::info!("cargo agents sync"), + Some(Commands::Search { query }) => tracing::info!(%query, "cargo agents search"), + Some(Commands::Use { + name, + global, + remove, + }) => tracing::info!(%name, global, remove, "cargo agents use"), + Some(Commands::Status) => tracing::info!("cargo agents status"), Some(Commands::Plugin { command }) => { tracing::info!(subcommand = ?command, "cargo agents plugin"); } @@ -115,7 +122,7 @@ async fn main() -> ExitCode { } _ => cli.update, }; - plugins::ensure_plugin_sources(&sym, source_update).await; + plugins::ensure_registries(&sym, source_update).await; // Auto-update = "on": check for updates and re-exec if a new binary was // installed. Skipped for self-update (which always checks explicitly) @@ -181,7 +188,7 @@ async fn main() -> ExitCode { async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) -> ExitCode { match command { PluginCommand::Sync { provider } => { - match plugins::sync_plugin_source(sym, provider.as_deref()).await { + match plugins::sync_registries(sym, provider.as_deref()).await { Ok(synced) => { if synced.is_empty() { if let Some(ref p) = provider { @@ -203,7 +210,7 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) } } PluginCommand::List => { - let providers = plugins::list_plugins(sym); + let providers = plugins::list_plugins(sym).await; for provider in &providers { tracing::info!( report = %report::ReportEvent::ProviderListed { @@ -282,8 +289,9 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) } else { let parent = path.parent().unwrap_or(&path); match plugins::load_plugin(&path, "", parent) { - Ok(p) => { - println!("{}", tokio::fs::read_to_string(p.path).await.unwrap()); + Ok(_) => { + // `path` is the manifest file being validated. + println!("{}", tokio::fs::read_to_string(&path).await.unwrap()); ExitCode::SUCCESS } Err(e) => { @@ -293,12 +301,22 @@ async fn handle_plugin_command(sym: &config::Symposium, command: PluginCommand) } } } - PluginCommand::Show { plugin } => match plugins::find_plugin(sym, &plugin) { + PluginCommand::Show { plugin } => match plugins::find_plugin(sym, &plugin).await { + // A plugin is identified by its id; render its effective (resolved) + // configuration rather than re-reading a manifest file. Some(p) => { - println!("# Source: {}", p.path.display()); - println!(); - print!("{}", tokio::fs::read_to_string(p.path).await.unwrap()); - ExitCode::SUCCESS + println!("# {}", p.canonical); + match toml::to_string_pretty(&p.plugin) { + Ok(rendered) => { + println!(); + print!("{rendered}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("cannot render `{}`: {e}", p.canonical); + ExitCode::FAILURE + } + } } None => { eprintln!("Plugin not found: {plugin}"); @@ -314,7 +332,7 @@ fn emit_validation_results(r: &plugins::ValidationResult) -> usize { Ok(()) => { tracing::info!( report = %report::ReportEvent::Validated { - path: r.path.display().to_string(), + path: r.id.clone(), item_kind: r.kind.to_string(), valid: true, error: None, @@ -325,7 +343,7 @@ fn emit_validation_results(r: &plugins::ValidationResult) -> usize { Err(e) => { tracing::info!( report = %report::ReportEvent::Validated { - path: r.path.display().to_string(), + path: r.id.clone(), item_kind: r.kind.to_string(), valid: false, error: Some(e.to_string()), diff --git a/src/cli.rs b/src/cli.rs index 1e3e58fc..4868a998 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -11,13 +11,17 @@ use clap::{Parser, Subcommand}; use crate::config::Symposium; use crate::crate_command::{self, DispatchResult}; +use crate::discovery; use crate::hook; use crate::init::{self, InitOpts}; use crate::output::Output; use crate::plugins::Audience; +use crate::search_command; use crate::self_update; +use crate::status_command; use crate::subcommand_dispatch::dispatch_external; use crate::sync; +use crate::use_command; /// Parsed CLI arguments. #[derive(Debug, Parser)] @@ -76,6 +80,29 @@ pub enum Commands { /// Synchronize skills with workspace dependencies Sync, + /// Search configured registries for plugins + Search { + /// Name (or name fragment) to look for + query: String, + }, + + /// Enable a plugin by name and sync it into the workspace + Use { + /// Plugin (crate) name to enable + name: String, + + /// Enable for every workspace instead of just the current one + #[arg(long)] + global: bool, + + /// Remove a previously recorded enablement instead of adding one + #[arg(long)] + remove: bool, + }, + + /// Show which plugins are enabled for this workspace, and why + Status, + /// Hook entry point invoked by your agent (internal) #[command(hide = true)] Hook { @@ -142,7 +169,9 @@ pub enum TelemetryCommand { /// this only covers the static `Commands` variants above. pub fn builtin_audience(name: &str) -> Option { match name { - "init" | "sync" | "self-update" | "plugin" | "telemetry" => Some(Audience::Humans), + "init" | "sync" | "search" | "use" | "status" | "self-update" | "plugin" | "telemetry" => { + Some(Audience::Humans) + } "crate-info" => Some(Audience::Agents), _ => None, } @@ -211,7 +240,31 @@ pub async fn run( init::init(sym, out, &opts).await } - Commands::Sync => sync::sync(sym, &mut sym.workspace_deps(cwd), update).await, + Commands::Sync => { + let deps = sym.workspace_deps(cwd); + // The consent prompt belongs to a human running `cargo agents + // sync`; it is inert unless `out` is interactive, and the + // 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 + } + + Commands::Search { query } => search_command::search(sym, &query).await, + + Commands::Use { + name, + global, + remove, + } => { + if remove { + use_command::remove_plugin(sym, cwd, &name, global, update).await + } else { + use_command::use_plugin(sym, cwd, &name, global, update).await + } + } + + Commands::Status => status_command::status(sym, cwd).await, Commands::SelfUpdate => self_update::self_update(sym, out), diff --git a/src/config.rs b/src/config.rs index 84b74bb7..6e743b63 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use tracing::Level; // --------------------------------------------------------------------------- @@ -92,6 +93,10 @@ pub struct Config { #[serde(default, skip_serializing_if = "TelemetryConfig::is_default")] pub telemetry: TelemetryConfig, + /// Which discovered plugins the user has consented to. + #[serde(default, skip_serializing_if = "PluginsConfig::is_default")] + pub plugins: PluginsConfig, + /// Agents configured for this user. #[serde(default, rename = "agent")] pub agents: Vec, @@ -99,13 +104,130 @@ pub struct Config { #[serde(default)] pub logging: LoggingConfig, - /// Default plugin sources that are always included unless disabled. + /// Default registries that are always included unless disabled. #[serde(default)] pub defaults: DefaultsConfig, - /// User-defined plugin sources (git repos or local paths). - #[serde(default, rename = "plugin-source")] - pub plugin_source: Vec, + /// User-defined registries (git repos or local paths). + /// `plugin-source` is the retired spelling, still accepted. + #[serde(default, rename = "registry", alias = "plugin-source")] + pub registries: Vec, +} + +/// The `[plugins]` section: enablement, the consent axis. +/// +/// Activation predicates answer *when* a plugin applies; enablement answers +/// *whether it may run at all*. The workspace and the configured registries +/// are trust roots — what they define needs no per-plugin consent. A +/// dependency is deliberately not a trust root: depending on a crate means +/// compiling its code, not letting its author inject agent context. So a +/// plugin embedded in a dependency runs only once the user consents, either +/// ahead of time (`auto-enable`) or by name (`use`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginsConfig { + /// Dependency names whose embedded plugins load without being asked + /// about; `"*"` pre-consents to every dependency. Matched + /// hyphen/underscore-insensitively, like crate names. + #[serde(default, rename = "auto-enable", skip_serializing_if = "Vec::is_empty")] + pub auto_enable: Vec, + + /// Plugins enabled deliberately, the durable record `cargo agents use` + /// writes. Unlike `auto-enable` (consent for what a dependency already + /// carries), a used plugin is enabled whether or not any dependency + /// references it — it is also what wakes a dormant registry plugin. + #[serde(default, rename = "use", skip_serializing_if = "Vec::is_empty")] + pub used: Vec, + + /// Plugin names pruned from enablement, and the record of declined + /// discoveries (so they are not offered again). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disable: Vec, +} + +impl PluginsConfig { + fn is_default(&self) -> bool { + *self == Self::default() + } + + /// Names enabled by `use` entries that apply while working in + /// `workspace_root`. + pub fn used_names_in(&self, workspace_root: &Path) -> Vec<&str> { + self.used + .iter() + .filter(|entry| entry.applies_in(workspace_root)) + .map(UseEntry::name) + .collect() + } + + /// Does `name` appear in `auto-enable` (directly or via `"*"`)? + pub fn is_auto_enabled(&self, name: &str) -> bool { + self.auto_enable + .iter() + .any(|entry| name_matches(entry, name)) + } + + /// Does `name` appear in `disable`? + pub fn is_disabled(&self, name: &str) -> bool { + self.disable.iter().any(|entry| name_matches(entry, name)) + } + + /// Is `name` enabled by a `use` entry applicable in `workspace_root`? + pub fn is_used_in(&self, name: &str, workspace_root: &Path) -> bool { + self.used_names_in(workspace_root) + .iter() + .any(|entry| name_matches(entry, name)) + } + + /// Whether any enablement entry could pull in a crate plugin. With neither + /// `auto-enable` nor `use` naming anything, + /// [`enabled_dependencies`](crate::discovery::enabled_dependencies) is empty + /// regardless of the dependency graph — so a caller can skip resolving the + /// workspace crates on that basis. + pub fn has_enablement_entries(&self) -> bool { + !self.auto_enable.is_empty() || !self.used.is_empty() + } +} + +/// Does a configured entry name `name`? `"*"` matches everything; otherwise +/// the comparison is hyphen/underscore-insensitive, since these entries are +/// user-typed package names. +fn name_matches(entry: &str, name: &str) -> bool { + entry == "*" + || crate::crate_sources::normalize_crate_name(entry) + == crate::crate_sources::normalize_crate_name(name) +} + +/// One `[plugins] use` entry: a plugin name enabled deliberately, scoped +/// either to a single workspace or to every workspace. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UseEntry { + /// `use = ["name"]` — enabled in every workspace. + Global(String), + /// `use = [{ name = "...", workspace = "/path" }]` — enabled while + /// working in the named workspace root. + Workspace { name: String, workspace: PathBuf }, +} + +impl UseEntry { + pub fn name(&self) -> &str { + match self { + UseEntry::Global(name) => name, + UseEntry::Workspace { name, .. } => name, + } + } + + /// Does this entry apply while working in `workspace_root`? + pub fn applies_in(&self, workspace_root: &Path) -> bool { + match self { + UseEntry::Global(_) => true, + UseEntry::Workspace { workspace, .. } => { + let canon = |p: &Path| fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()); + canon(workspace) == canon(workspace_root) + } + } + } } /// An `[[agent]]` entry — just identifies an agent by name. @@ -157,10 +279,11 @@ impl Default for Config { hook_scope: HookScope::default(), auto_update: AutoUpdate::default(), telemetry: TelemetryConfig::default(), + plugins: PluginsConfig::default(), agents: Vec::new(), logging: LoggingConfig::default(), defaults: DefaultsConfig::default(), - plugin_source: Vec::new(), + registries: Vec::new(), } } } @@ -180,14 +303,16 @@ struct RawConfig { auto_update: AutoUpdate, #[serde(default)] telemetry: TelemetryConfig, + #[serde(default)] + plugins: PluginsConfig, #[serde(default, rename = "agent")] agents: Vec, #[serde(default)] logging: LoggingConfig, #[serde(default)] defaults: DefaultsConfig, - #[serde(default, rename = "plugin-source")] - plugin_source: Vec, + #[serde(default, rename = "registry", alias = "plugin-source")] + registries: Vec, } impl Default for RawConfig { @@ -205,10 +330,11 @@ impl RawConfig { hook_scope: self.hook_scope, auto_update: self.auto_update, telemetry: self.telemetry, + plugins: self.plugins, agents: self.agents, logging: self.logging, defaults: self.defaults, - plugin_source: self.plugin_source, + registries: self.registries, } } } @@ -222,15 +348,16 @@ impl From for RawConfig { hook_scope: config.hook_scope, auto_update: config.auto_update, telemetry: config.telemetry, + plugins: config.plugins, agents: config.agents, logging: config.logging, defaults: config.defaults, - plugin_source: config.plugin_source, + registries: config.registries, } } } -/// Controls which built-in plugin sources are enabled. +/// Controls which built-in registries are enabled. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DefaultsConfig { /// Include the `symposium-dev/recommendations` git source (default: true). @@ -251,11 +378,13 @@ impl Default for DefaultsConfig { } } -/// A configured plugin source — either a git repository or a local path. +/// A configured registry — a git repository or a local path offering plugins. #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(deny_unknown_fields)] -pub struct PluginSourceConfig { - /// Display name for this source. +pub struct RegistryConfig { + /// Display name for this registry. Plugins loaded from it are attributed + /// to this name, which is also the `pm` component of the ids its package + /// manager mints. pub name: String, /// GitHub URL (fetched as tarball, cached locally). @@ -275,15 +404,8 @@ pub struct PluginSourceConfig { // Merged configuration view // --------------------------------------------------------------------------- -/// A plugin source together with its base directory for resolving relative paths. -#[derive(Debug, Clone)] -pub struct ResolvedPluginSource { - pub source: PluginSourceConfig, - /// Directory to resolve relative `path` values against. - /// For user sources this is the user config dir; for project sources - /// this is the project root. - pub base_dir: PathBuf, -} +/// Cache subdirectory holding git registries' unpacked content. +pub const REGISTRY_CACHE_SUBDIR: &str = "plugin-sources"; const BUILTIN_RECOMMENDATIONS_URL: &str = "https://github.com/symposium-dev/recommendations"; @@ -293,7 +415,7 @@ const BUILTIN_RECOMMENDATIONS_URL: &str = "https://github.com/symposium-dev/reco #[derive(Clone)] pub struct Symposium { pub config: Config, - dirs: symposium_sdk::dirs::SymposiumDirs, + dirs: crate::dirs::SymposiumDirs, home_dir: PathBuf, } @@ -306,7 +428,7 @@ impl Symposium { /// 3. `~/.symposium` pub fn from_environment() -> Self { let home_dir = dirs::home_dir().expect("could not determine home directory"); - let dirs = symposium_sdk::dirs::SymposiumDirs::from_environment(); + let dirs = crate::dirs::SymposiumDirs::from_environment(); let _ = fs::create_dir_all(&dirs.config_dir); let _ = fs::create_dir_all(&dirs.cache_dir); @@ -338,7 +460,7 @@ impl Symposium { // global hook registration writes into the tempdir. let home_dir = root.to_path_buf(); - let dirs = symposium_sdk::dirs::SymposiumDirs::new(config_dir, cache_dir, None); + let dirs = crate::dirs::SymposiumDirs::new(config_dir, cache_dir, None); Self { config, @@ -348,7 +470,7 @@ impl Symposium { } /// The resolved directory paths. - pub fn dirs(&self) -> &symposium_sdk::dirs::SymposiumDirs { + pub fn dirs(&self) -> &crate::dirs::SymposiumDirs { &self.dirs } @@ -357,9 +479,10 @@ impl Symposium { self.dirs.cargo_override.as_deref() } - /// Create a `WorkspaceDeps` with disk caching enabled. - pub fn workspace_deps(&self, cwd: &Path) -> symposium_sdk::workspace::WorkspaceDeps { - self.dirs.workspace_deps(cwd) + /// Create a `WorkspaceDeps` with disk caching enabled, shareable as an + /// [`Arc`] (a [`CargoPm`](crate::pm::CargoPm) holds one). + pub fn workspace_deps(&self, cwd: &Path) -> Arc { + Arc::new(self.dirs.workspace_deps(cwd)) } /// Build a `Command` for the cargo binary. @@ -381,6 +504,34 @@ impl Symposium { } } + /// The active package-manager set: the fixed ecosystem transports plus one + /// [`PathPm`](crate::pm::PathPm) per configured registry + /// ([`registries`](Self::registries)) over its content directory — + /// including git registries, whose repository is unpacked into the cache + /// before it is read. Each instance is named for its registry, since that + /// name is what its plugins are attributed to. + pub fn package_managers( + &self, + workspace: &Arc, + ) -> crate::pm::PmRegistry { + // The cargo transport first — over dependencies, so not a trust root. + let mut instances = vec![crate::pm::PmInstance { + name: crate::pm::CARGO_PM.to_string(), + trusted: false, + pm: Box::new(crate::pm::CargoPm::new(Arc::clone(workspace))), + }]; + // One registry instance per configured registry — trust roots. + instances.extend(self.registry_instances()); + crate::pm::PmRegistry::new(instances) + } + + /// The package managers for a workspace-independent operation (registry + /// listing, crates.io search): the cargo transport is built over a detached + /// resolver that never runs `cargo metadata`. + pub fn detached_managers(&self) -> crate::pm::PmRegistry { + self.package_managers(&Arc::new(crate::pm::WorkspaceDeps::detached())) + } + /// Override the cargo binary path (test-only). #[doc(hidden)] pub fn set_cargo_override(&mut self, path: PathBuf) { @@ -451,42 +602,76 @@ impl Symposium { &self.home_dir } - /// Returns the effective list of plugin sources, including built-in defaults. - pub fn plugin_sources(&self) -> Vec { - let mut sources = Vec::new(); + /// The registry package-manager instances, in effect order: the builtin + /// recommendations repo, the builtin `user-plugins` directory, then the + /// configured `[[registry]]` entries. Each is a trust root — a git entry is + /// a [`GitPm`](crate::pm::GitPm), a path entry a [`PathPm`](crate::pm::PathPm) + /// — so refreshing (pulling git content) is the PM's own concern rather than + /// a separate step. A registry whose source can't be resolved (a malformed + /// git URL, or an entry naming neither `git` nor `path`) is skipped with a + /// warning. + pub fn registry_instances(&self) -> Vec { + let mut configs: Vec = Vec::new(); if self.config.defaults.symposium_recommendations { - sources.push(ResolvedPluginSource { - source: PluginSourceConfig { - name: "symposium-recommendations".to_string(), - git: Some(BUILTIN_RECOMMENDATIONS_URL.to_string()), - path: None, - auto_update: true, - }, - base_dir: self.dirs.config_dir.clone(), + configs.push(RegistryConfig { + name: "symposium-recommendations".to_string(), + git: Some(BUILTIN_RECOMMENDATIONS_URL.to_string()), + path: None, + auto_update: true, }); } if self.config.defaults.user_plugins { - sources.push(ResolvedPluginSource { - source: PluginSourceConfig { - name: "user-plugins".to_string(), - git: None, - path: Some("plugins".to_string()), - auto_update: true, - }, - base_dir: self.dirs.config_dir.clone(), + configs.push(RegistryConfig { + name: "user-plugins".to_string(), + git: None, + path: Some("plugins".to_string()), + auto_update: true, }); } - for s in &self.config.plugin_source { - sources.push(ResolvedPluginSource { - source: s.clone(), - base_dir: self.dirs.config_dir.clone(), - }); - } + configs.extend(self.config.registries.iter().cloned()); + configs + .into_iter() + .filter_map(|cfg| self.registry_instance(cfg)) + .collect() + } - sources + /// Build the registry instance for one config entry. Relative `path` values + /// resolve against the config dir. + fn registry_instance(&self, cfg: RegistryConfig) -> Option { + let name = cfg.name.clone(); + let pm: Box = if let Some(url) = &cfg.git { + match crate::pm::GitPm::new( + name.clone(), + url.clone(), + cfg.auto_update, + self.install_context(), + ) { + Some(git) => Box::new(git), + None => { + tracing::warn!(registry = %name, url = %url, "bad registry URL"); + return None; + } + } + } else if let Some(path) = &cfg.path { + let p = PathBuf::from(path); + let dir = if p.is_absolute() { + p + } else { + self.dirs.config_dir.join(p) + }; + Box::new(crate::pm::PathPm::new(name.clone(), dir)) + } else { + tracing::warn!(registry = %name, "registry names neither git nor path"); + return None; + }; + Some(crate::pm::PmInstance { + name, + trusted: true, + pm, + }) } /// Write the user config to disk. @@ -578,7 +763,7 @@ mod tests { let config = parse_config(""); assert!(config.defaults.symposium_recommendations); assert!(config.defaults.user_plugins); - assert!(config.plugin_source.is_empty()); + assert!(config.registries.is_empty()); } #[test] @@ -602,58 +787,70 @@ mod tests { } #[test] - fn parse_plugin_source_git() { + fn parse_registry_git() { let config = parse_config(indoc! {r#" - [[plugin-source]] + [[registry]] name = "my-org" git = "https://github.com/my-org/plugins" auto-update = false "#}); - assert_eq!(config.plugin_source.len(), 1); - assert_eq!(config.plugin_source[0].name, "my-org"); + assert_eq!(config.registries.len(), 1); + assert_eq!(config.registries[0].name, "my-org"); assert_eq!( - config.plugin_source[0].git.as_deref(), + config.registries[0].git.as_deref(), Some("https://github.com/my-org/plugins") ); - assert!(!config.plugin_source[0].auto_update); + assert!(!config.registries[0].auto_update); } + /// `[[plugin-source]]` is the retired spelling of `[[registry]]`. #[test] - fn parse_plugin_source_path() { + fn parse_retired_plugin_source_spelling() { let config = parse_config(indoc! {r#" [[plugin-source]] + name = "my-org" + git = "https://github.com/my-org/plugins" + "#}); + assert_eq!(config.registries.len(), 1); + assert_eq!(config.registries[0].name, "my-org"); + } + + #[test] + fn parse_registry_path() { + let config = parse_config(indoc! {r#" + [[registry]] name = "local" path = "my-plugins" "#}); - assert_eq!(config.plugin_source.len(), 1); - assert_eq!(config.plugin_source[0].path.as_deref(), Some("my-plugins")); - assert!(config.plugin_source[0].auto_update); // default true + assert_eq!(config.registries.len(), 1); + assert_eq!(config.registries[0].path.as_deref(), Some("my-plugins")); + assert!(config.registries[0].auto_update); // default true } #[test] - fn parse_multiple_plugin_sources() { + fn parse_multiple_registries() { let config = parse_config(indoc! {r#" [defaults] symposium-recommendations = false - [[plugin-source]] + [[registry]] name = "org-a" git = "https://github.com/a/plugins" - [[plugin-source]] + [[registry]] name = "org-b" git = "https://github.com/b/plugins" auto-update = false - [[plugin-source]] + [[registry]] name = "local" path = "extras" "#}); assert!(!config.defaults.symposium_recommendations); - assert_eq!(config.plugin_source.len(), 3); - assert_eq!(config.plugin_source[0].name, "org-a"); - assert_eq!(config.plugin_source[1].name, "org-b"); - assert_eq!(config.plugin_source[2].name, "local"); + assert_eq!(config.registries.len(), 3); + assert_eq!(config.registries[0].name, "org-a"); + assert_eq!(config.registries[1].name, "org-b"); + assert_eq!(config.registries[2].name, "local"); } #[test] @@ -738,6 +935,69 @@ mod tests { assert!(!config.agents_syncing); } + #[test] + fn parse_plugins_defaults_are_empty() { + let config = parse_config(""); + assert!(config.plugins.auto_enable.is_empty()); + assert!(config.plugins.used.is_empty()); + assert!(config.plugins.disable.is_empty()); + + // An all-default section is not written back out. + let serialized = toml::to_string_pretty(&config).unwrap(); + assert!( + !serialized.contains("[plugins]"), + "default enablement config should not be written: {serialized}" + ); + } + + #[test] + fn parse_plugins_enablement() { + let config = parse_config(indoc! {r#" + [plugins] + auto-enable = ["widget-lib"] + disable = ["noisy-crate"] + use = ["everywhere", { name = "scoped", workspace = "/ws/a" }] + "#}); + + assert!(config.plugins.is_auto_enabled("widget_lib")); + assert!(!config.plugins.is_auto_enabled("other")); + assert!(config.plugins.is_disabled("noisy-crate")); + + assert_eq!( + config.plugins.used, + vec![ + UseEntry::Global("everywhere".into()), + UseEntry::Workspace { + name: "scoped".into(), + workspace: PathBuf::from("/ws/a"), + }, + ] + ); + assert_eq!( + config.plugins.used_names_in(Path::new("/ws/a")), + vec!["everywhere", "scoped"] + ); + assert_eq!( + config.plugins.used_names_in(Path::new("/ws/other")), + vec!["everywhere"] + ); + assert!(config.plugins.is_used_in("scoped", Path::new("/ws/a"))); + assert!(!config.plugins.is_used_in("scoped", Path::new("/ws/other"))); + + // Entries survive a round trip through the config file. + let reparsed = parse_config(&toml::to_string_pretty(&config).unwrap()); + assert_eq!(reparsed.plugins, config.plugins); + } + + #[test] + fn auto_enable_wildcard_matches_every_name() { + let config = parse_config(indoc! {r#" + [plugins] + auto-enable = ["*"] + "#}); + assert!(config.plugins.is_auto_enabled("anything")); + } + #[test] fn resolve_logs_dir_uses_xdg_state_home() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/crate_command.rs b/src/crate_command.rs index 6d704375..4647efd8 100644 --- a/src/crate_command.rs +++ b/src/crate_command.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::config::Symposium; -use crate::pm::{CargoPm, PackageManager as _}; +use crate::pm::CargoPm; /// Result of dispatching a command. pub enum DispatchResult { @@ -20,10 +20,13 @@ pub async fn dispatch_crate( cwd: &Path, ) -> DispatchResult { tracing::debug!(%name, ?version, "crate-info dispatched"); - let mut deps = sym.workspace_deps(cwd); - let workspace = deps.crates(); + let deps = sym.workspace_deps(cwd); let id = CargoPm::id_for(name, version); - match CargoPm.fetch(&id, workspace).await { + match sym + .package_managers(&deps) + .fetch(&id, symposium_install::UpdateLevel::None) + .await + { Ok(result) => { let output = format!( "Crate: {}\nVersion: {}\nSource: {}\n", diff --git a/src/crate_sources/mod.rs b/src/crate_sources/mod.rs index 10d2a1ee..2a4575c5 100644 --- a/src/crate_sources/mod.rs +++ b/src/crate_sources/mod.rs @@ -7,8 +7,8 @@ use std::path::PathBuf; +use crate::pm::WorkspaceCrate; use anyhow::Result; -use symposium_sdk::workspace::WorkspaceCrate; mod probe; @@ -58,26 +58,27 @@ impl<'a> RustCrateFetch<'a> { /// Fetch the crate sources, returning a path to the source directory. /// /// Resolution order: - /// 1. If the crate is a local path dependency in the workspace (and no - /// explicit `--version` was requested), return the path directly. + /// 1. If the crate is a workspace dependency with a known extracted source + /// (and no explicit `--version` was requested), return that directory + /// directly — the workspace's own `cargo metadata` already located it, + /// for both path *and* registry crates, so no probe is needed. /// 2. Otherwise, run `cargo fetch` in a temporary dummy package to /// populate cargo's registry cache, then read `cargo metadata` to get /// the extracted source path under `~/.cargo/registry/src/`. pub async fn fetch(self) -> Result { - // Check path overrides first (local path dependencies). + // Serve a workspace dependency from its already-extracted source. if self.version_spec.is_none() { let normalized = normalize_crate_name(&self.crate_name); - if let Some(wc) = self - .workspace - .iter() - .find(|wc| wc.path.is_some() && normalize_crate_name(&wc.name) == normalized) - { - let path = wc.path.as_ref().unwrap(); - tracing::debug!(crate_name = %wc.name, path = %path.display(), "resolved from path override"); + if let Some((wc, dir)) = self.workspace.iter().find_map(|wc| { + (normalize_crate_name(&wc.name) == normalized) + .then(|| wc.source_dir.as_ref().map(|d| (wc, d))) + .flatten() + }) { + tracing::debug!(crate_name = %wc.name, path = %dir.display(), "resolved from workspace source"); return Ok(FetchResult { name: wc.name.clone(), version: wc.version.to_string(), - path: path.clone(), + path: dir.clone(), }); } } diff --git a/symposium-sdk/src/dirs.rs b/src/dirs.rs similarity index 97% rename from symposium-sdk/src/dirs.rs rename to src/dirs.rs index a0b0c352..7972cecc 100644 --- a/symposium-sdk/src/dirs.rs +++ b/src/dirs.rs @@ -1,13 +1,13 @@ //! Resolved symposium directory paths. //! //! Plugin binaries (hooks, predicates, subcommands) use [`SymposiumDirs`] to -//! locate cache directories and construct [`WorkspaceDeps`](crate::workspace::WorkspaceDeps) +//! locate cache directories and construct [`WorkspaceDeps`](crate::pm::WorkspaceDeps) //! with the correct cargo override and disk-cache path. use std::env; use std::path::{Path, PathBuf}; -use crate::workspace::WorkspaceDeps; +use crate::pm::WorkspaceDeps; /// Resolved directory paths for the symposium installation. /// diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 00000000..9296324f --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,551 @@ +//! Dependency discovery: which plugins the workspace's dependencies bring +//! within reach, and which of them the user has consented to. +//! +//! Discovery is the read side of the enablement axis. It runs in two phases: +//! +//! 1. list the workspace's dependencies ([`pm::workspace_dep_ids`]); +//! 2. ask each *untrusted* instance — the cargo transport — for the plugins +//! its dependencies embed ([`PackageManager::active_plugins`]). +//! +//! Each such offer is then classified against the `[plugins]` config: +//! already enabled, auto-enabled, declined, or a candidate still awaiting +//! consent. Discovery itself neither fetches nor writes. +//! +//! On top of that read side sits the consent write side: +//! [`prompt_for_consent`] asks about each candidate and [`apply_consent`] +//! records the answers. The prompt is inert unless its [`Output`] is +//! interactive, so hook dispatch — and anything else an agent triggers — +//! can never block on stdin; those contexts get [`pending_candidates`] as a +//! `SessionStart` hint instead. +//! +//! Enablement matters because a dependency is deliberately *not* a trust +//! root: depending on a crate means compiling its code, not letting its +//! author inject agent context. Registry instances are trust roots — a +//! registry exists to curate plugins — but their plugins are loaded and +//! gated directly ([`plugins::load_registry`], evaluated by +//! [`Plugin::applies`]), so they never reach discovery. What discovery +//! classifies is exactly the untrusted offers: the dependency-embedded +//! plugins an ecosystem transport surfaces, which run only with consent. +//! +//! [`plugins::load_registry`]: crate::plugins::load_registry +//! [`Plugin::applies`]: crate::plugins::ParsedPlugin::applies +//! +//! [`pm::workspace_dep_ids`]: crate::pm::workspace_dep_ids +//! [`PackageManager::active_plugins`]: crate::pm::PackageManager::active_plugins + +use std::path::Path; + +use crate::pm::WorkspaceDeps; +use anyhow::{Context, Result}; +use std::sync::Arc; + +use crate::config::Symposium; +use crate::crate_sources::normalize_crate_name; +use crate::output::Output; +use crate::pm::{CARGO_PM, PackageId}; +use crate::report::ReportEvent; + +/// Why a discovered offer is (or is not) enabled. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Enablement { + /// Enabled by a `[plugins] use` entry naming it. + Used, + /// Enabled ahead of time by `[plugins] auto-enable`. + AutoEnabled, + /// Declined: `[plugins] disable` names it. + Declined, + /// Nobody has decided yet — this is what a consent prompt would ask about. + Candidate, +} + +impl Enablement { + /// Does this decision let the plugin run? + pub fn is_enabled(self) -> bool { + matches!(self, Self::Used | Self::AutoEnabled) + } +} + +/// One plugin offer whose recommended dependency the workspace has. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DiscoveredPlugin { + /// The offering package-manager instance: an ecosystem transport + /// (`cargo`) for a dependency-embedded plugin, or a registry's name. + pub registry: String, + /// The offered package. + pub id: PackageId, + /// The dependency this offer is a plugin for. + pub recommends: String, + /// A short human summary of what the plugin contributes, for the consent + /// prompt. + pub description: Option, + /// How the `[plugins]` config decided this offer. + pub enablement: Enablement, +} + +impl DiscoveredPlugin { + /// The name the user would type to enable this plugin. + pub fn name(&self) -> &str { + &self.id.name + } +} + +/// Every dependency-matched offer, grouped by what the config decided. +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Discovery { + /// Already enabled: named by a `use` entry, or offered by a registry. + pub active: Vec, + /// Enabled by `[plugins] auto-enable`. + pub auto_enabled: Vec, + /// Awaiting consent — newly discovered and not yet decided. + pub candidates: Vec, + /// Declined previously, recorded in `[plugins] disable`. + pub declined: Vec, +} + +impl Discovery { + /// Every offer that may run, whatever enabled it. + pub fn enabled(&self) -> impl Iterator { + self.active.iter().chain(&self.auto_enabled) + } +} + +/// Discover the plugins offered for this workspace's dependencies. +/// +/// `workspace_root` scopes the `use` entries that count (an entry can be +/// recorded for one workspace only). `active_plugins` fetches each dependency +/// cache-only — for a workspace dependency, into the source `cargo metadata` +/// already extracted (no probe, no network) — so every dependency-embedded +/// plugin is discoverable, registry crates included. +pub async fn discover(sym: &Symposium, deps: &Arc) -> Discovery { + let Some(workspace_root) = deps.workspace_root().map(Path::to_path_buf) else { + return Discovery::default(); + }; + let pms = sym.package_managers(deps); + let dep_ids = pms.list_deps().await.unwrap_or_default(); + + let mut discovery = Discovery::default(); + // Untrusted instances = the cargo transport: its `active_plugins` are the + // plugins embedded in dependencies, which run only with consent. Classify + // each against the `[plugins]` config. + for inst in pms.instances().filter(|i| !i.trusted) { + for plugin in inst.pm.active_plugins(&dep_ids).await { + let name = plugin.canonical.name.clone(); + let description = Some(describe_plugin(&plugin.plugin)); + let enablement = decide(sym, &name, &workspace_root); + let discovered = DiscoveredPlugin { + registry: inst.name.clone(), + id: plugin.canonical, + recommends: name, + description, + enablement, + }; + match enablement { + Enablement::Used => discovery.active.push(discovered), + Enablement::AutoEnabled => discovery.auto_enabled.push(discovered), + Enablement::Declined => discovery.declined.push(discovered), + Enablement::Candidate => discovery.candidates.push(discovered), + } + } + } + discovery +} + +/// The crate names whose embedded plugins the user has enabled, to load at +/// sync time. Two sources: +/// +/// 1. workspace **dependencies** covered by `[plugins] auto-enable` or an +/// applicable `use` entry, and +/// 2. crates named by a `use` entry that are **not** dependencies — +/// `cargo agents use ` pulls a plugin in from its registry +/// (crates.io) whether or not the workspace depends on it. +/// +/// `auto-enable` intentionally contributes only (1): it is consent for what a +/// dependency you already have carries, not a way to add crates. Declined +/// names are pruned. This reads the config rather than the offer list, so a +/// `use`d crate that isn't a dependency at all (source not resolved yet) still +/// works. +pub fn enabled_dependencies( + sym: &Symposium, + dep_ids: &[PackageId], + workspace_root: &Path, +) -> Vec { + let plugins = &sym.config.plugins; + let mut names: Vec = dep_ids + .iter() + .filter(|id| id.pm == CARGO_PM) + .filter(|id| !plugins.is_disabled(&id.name)) + .filter(|id| { + plugins.is_auto_enabled(&id.name) || plugins.is_used_in(&id.name, workspace_root) + }) + .map(|id| id.name.clone()) + .collect(); + + for used in plugins.used_names_in(workspace_root) { + let norm = normalize_crate_name(used); + let known = plugins.is_disabled(used) + || names.iter().any(|n| normalize_crate_name(n) == norm) + || dep_ids + .iter() + .any(|id| normalize_crate_name(&id.name) == norm); + if !known { + names.push(used.to_string()); + } + } + + names +} + +/// Run [`discover`] for the workspace `deps` points at, or an empty +/// [`Discovery`] when there is no workspace. +pub async fn discover_for(sym: &Symposium, deps: &Arc) -> Discovery { + discover(sym, deps).await +} + +/// The names of the discovered offers still awaiting consent, deduplicated +/// and sorted — what a consent prompt would ask about, and what the +/// non-interactive hint names. +pub async fn pending_candidates(sym: &Symposium, deps: &Arc) -> Vec { + let mut names: Vec = discover_for(sym, deps) + .await + .candidates + .into_iter() + .map(|c| c.id.name) + .collect(); + names.sort(); + names.dedup(); + names +} + +/// Record consent decisions: approvals into `[plugins] auto-enable`, +/// declines into `[plugins] disable`, then save the config. +/// +/// Split out from the prompt so the decision recording is testable without a +/// terminal, and so other entry points can record the same way. +pub fn apply_consent(sym: &mut Symposium, approved: &[String], declined: &[String]) -> Result<()> { + if approved.is_empty() && declined.is_empty() { + return Ok(()); + } + let plugins = &mut sym.config.plugins; + for name in approved { + if !plugins.is_auto_enabled(name) { + plugins.auto_enable.push(name.clone()); + } + } + for name in declined { + if !plugins.is_disabled(name) { + plugins.disable.push(name.clone()); + } + } + sym.save_config().context("failed to write user config")?; + + if !approved.is_empty() { + tracing::info!( + report = %ReportEvent::Info { + message: format!("enabled dependency plugins: {}", approved.join(", ")), + }, + ); + } + if !declined.is_empty() { + tracing::info!( + report = %ReportEvent::Info { + message: format!( + "declined dependency plugins (recorded in `[plugins] disable`): {}", + declined.join(", ") + ), + }, + ); + } + Ok(()) +} + +/// Ask the user about each undecided offer, then record the answers. +/// +/// **Never prompts unless `out` is interactive** ([`Output::is_interactive`]): +/// hook dispatch and anything an agent triggers use a quiet or capturing +/// output, so they return here immediately without touching stdin. In those +/// contexts the candidates surface as a `SessionStart` hint instead (see +/// [`pending_candidates`]). +/// +/// Only explicit answers are recorded — the default ("ask me later") leaves +/// the dependency undecided, so reflexively hitting Enter never permanently +/// declines anything, and Escape leaves the remaining offers undecided too. +pub async fn prompt_for_consent( + sym: &mut Symposium, + deps: &Arc, + out: &Output, +) -> Result<()> { + if !out.is_interactive() { + return Ok(()); + } + let candidates = discover_for(sym, deps).await.candidates; + if candidates.is_empty() { + return Ok(()); + } + + let mut approved = Vec::new(); + let mut declined = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for candidate in candidates { + let name = candidate.id.name; + if !seen.insert(name.clone()) { + continue; + } + let what = candidate + .description + .as_deref() + .unwrap_or("agent extensions"); + let answer = dialoguer::Select::new() + .with_prompt(format!("Dependency `{name}` provides {what}. Enable it?")) + .items(["Ask me later", "Enable", "No — don't ask again"]) + .default(0) + .interact_opt() + .context("consent prompt failed")?; + match answer { + Some(1) => approved.push(name), + Some(2) => declined.push(name), + Some(_) => {} // ask me later — record nothing + None => break, // Esc — leave the rest undecided + } + } + apply_consent(sym, &approved, &declined) +} + +/// A short human summary of what a discovered plugin contributes, for the +/// consent prompt and status output. Emphasizes the facets that matter to a +/// trust decision — a plugin that only ships skills is lower-stakes than one +/// that runs a hook or an MCP server. +fn describe_plugin(plugin: &crate::plugins::Plugin) -> String { + let parts: Vec = [ + count_phrase(plugin.skills.len(), "skill group", "skill groups"), + count_phrase(plugin.hooks.len(), "hook", "hooks"), + count_phrase(plugin.mcp_servers.len(), "MCP server", "MCP servers"), + count_phrase(plugin.subcommands.len(), "subcommand", "subcommands"), + ] + .into_iter() + .flatten() + .collect(); + if parts.is_empty() { + "agent extensions".to_string() + } else { + parts.join(", ") + } +} + +/// `" "`, or `None` when `n` is zero. +fn count_phrase(n: usize, singular: &str, plural: &str) -> Option { + (n > 0).then(|| format!("{n} {}", if n == 1 { singular } else { plural })) +} + +/// Classify one offer against the `[plugins]` config. An explicit decision — +/// `use`, then `disable` — outranks the standing `auto-enable`, so a name the +/// user declined stays declined. +fn decide(sym: &Symposium, name: &str, workspace_root: &Path) -> Enablement { + let plugins = &sym.config.plugins; + if plugins.is_used_in(name, workspace_root) { + Enablement::Used + } else if plugins.is_disabled(name) { + Enablement::Declined + } else if plugins.is_auto_enabled(name) { + Enablement::AutoEnabled + } else { + Enablement::Candidate + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pm::ANY_VERSION; + use crate::pm::WorkspaceCrate; + use indoc::indoc; + + /// A workspace with `widget-lib` as a path dependency carrying skills, + /// plus a plain registry dependency (an extracted source with no plugin + /// content, as `cargo metadata` always yields a `source_dir`). + fn workspace(root: &Path) -> Arc { + let widget = root.join("widget-lib"); + std::fs::create_dir_all(widget.join("skills/guidance")).unwrap(); + std::fs::write(widget.join("skills/guidance/SKILL.md"), "").unwrap(); + let serde = root.join("serde-src"); + std::fs::create_dir_all(&serde).unwrap(); + WorkspaceDeps::fixture( + root.to_path_buf(), + vec![ + WorkspaceCrate::new( + "widget-lib".to_string(), + semver::Version::new(1, 0, 0), + Some(widget), + ), + WorkspaceCrate::new("serde".to_string(), semver::Version::new(1, 0, 210), None) + .with_source_dir(Some(serde)), + ], + ) + } + + /// A `Symposium` over a fresh config dir with only the given config, and + /// no built-in registries (so tests see only what they set up). + fn sym_with(root: &Path, config: &str) -> Symposium { + let config_dir = root.join("config"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("config.toml"), + format!( + "{}\n[defaults]\nsymposium-recommendations = false\nuser-plugins = false\n", + config + ), + ) + .unwrap(); + Symposium::from_dir(&config_dir) + } + + #[tokio::test] + async fn undecided_dependency_plugin_is_a_candidate() { + let tmp = tempfile::tempdir().unwrap(); + let ws = workspace(tmp.path()); + let sym = sym_with(tmp.path(), ""); + + let found = discover(&sym, &ws).await; + assert!(found.active.is_empty()); + assert!(found.auto_enabled.is_empty()); + let names: Vec<&str> = found.candidates.iter().map(|c| c.name()).collect(); + assert_eq!(names, vec!["widget-lib"]); + assert_eq!(found.candidates[0].recommends, "widget-lib"); + } + + #[tokio::test] + async fn auto_enable_moves_a_candidate_to_enabled() { + let tmp = tempfile::tempdir().unwrap(); + let ws = workspace(tmp.path()); + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + auto-enable = ["widget_lib"] + "#}, + ); + + let found = discover(&sym, &ws).await; + assert!(found.candidates.is_empty()); + let names: Vec<&str> = found.auto_enabled.iter().map(|c| c.name()).collect(); + assert_eq!(names, vec!["widget-lib"]); + assert!(found.enabled().count() == 1); + } + + #[tokio::test] + async fn use_entry_and_disable_outrank_the_standing_decisions() { + let tmp = tempfile::tempdir().unwrap(); + let ws = workspace(tmp.path()); + + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + use = ["widget-lib"] + "#}, + ); + let found = discover(&sym, &ws).await; + assert_eq!(found.active.len(), 1); + assert_eq!(found.active[0].enablement, Enablement::Used); + + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + auto-enable = ["*"] + disable = ["widget-lib"] + "#}, + ); + let found = discover(&sym, &ws).await; + assert!(found.auto_enabled.is_empty()); + assert_eq!(found.declined.len(), 1); + } + + /// A `use` entry recorded for another workspace does not enable anything + /// here. + #[tokio::test] + async fn workspace_scoped_use_entries_only_count_in_their_workspace() { + let tmp = tempfile::tempdir().unwrap(); + let ws = workspace(tmp.path()); + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + use = [{ name = "widget-lib", workspace = "/elsewhere" }] + "#}, + ); + + let found = discover(&sym, &ws).await; + assert!(found.active.is_empty()); + assert_eq!(found.candidates.len(), 1); + } + + #[test] + fn enabled_dependencies_reads_config_not_offers() { + let tmp = tempfile::tempdir().unwrap(); + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + auto-enable = ["serde"] + use = ["tokio"] + disable = ["clap"] + "#}, + ); + let deps = [ + // A registry dependency, invisible to `active_plugins`, is still + // enabled by name. + PackageId::new(CARGO_PM, "serde", "1.0.210"), + PackageId::new(CARGO_PM, "tokio", "1.0.0"), + PackageId::new(CARGO_PM, "clap", "4.0.0"), + PackageId::new(CARGO_PM, "anyhow", "1.0.0"), + // Not a cargo package: not a crate to load. + PackageId::new("npm", "serde", ANY_VERSION), + ]; + + assert_eq!( + enabled_dependencies(&sym, &deps, tmp.path()), + vec!["serde".to_string(), "tokio".to_string()] + ); + } + + /// `use`-ing a crate that isn't a workspace dependency still enables it, so + /// sync loads its plugin from the registry. `auto-enable` does not — it is + /// consent for dependencies you already have. + #[test] + fn use_enables_a_non_dependency_crate_but_auto_enable_does_not() { + let tmp = tempfile::tempdir().unwrap(); + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + auto-enable = ["not-a-dep-autoenable"] + use = ["my-skills-crate", { name = "scoped-crate", workspace = "/elsewhere" }] + "#}, + ); + // Only `anyhow` is an actual dependency; the rest are not. + let deps = [PackageId::new(CARGO_PM, "anyhow", "1.0.0")]; + + let enabled = enabled_dependencies(&sym, &deps, tmp.path()); + // The `use`d non-dependency crate is enabled; the workspace-scoped one + // (for /elsewhere) and the auto-enabled non-dependency are not. + assert_eq!(enabled, vec!["my-skills-crate".to_string()]); + } + + /// A `use`d crate that *is* a dependency appears once, not twice. + #[test] + fn used_dependency_is_not_duplicated() { + let tmp = tempfile::tempdir().unwrap(); + let sym = sym_with( + tmp.path(), + indoc! {r#" + [plugins] + use = ["serde"] + "#}, + ); + let deps = [PackageId::new(CARGO_PM, "serde", "1.0.210")]; + assert_eq!( + enabled_dependencies(&sym, &deps, tmp.path()), + vec!["serde".to_string()] + ); + } +} diff --git a/src/help_render.rs b/src/help_render.rs index 837166ab..823b26ed 100644 --- a/src/help_render.rs +++ b/src/help_render.rs @@ -10,13 +10,11 @@ use std::{fmt::Write as _, path::Path}; use clap::{Command, CommandFactory}; -use symposium_sdk::workspace::WorkspaceCrate; - use crate::{ cli::{Cli, Commands, builtin_audience}, config::Symposium, - plugins::{Audience, PluginRegistry, load_registry_with_workspace}, - pm::{PackageId, PackageManager as _}, + plugins::{Audience, ParsedPlugin, load_registry_with_workspace}, + pm::PackageId, subcommand_dispatch::applicable_subcommands, }; @@ -33,7 +31,7 @@ pub const AGENTS_HEADING: &str = "Commands for agents"; /// - no subcommand, or the bare `help` keyword -> top-level /// - ` --help` (incl. nested and required-arg commands) -> clap's own per command help, re-rendered; /// - a plugin-vended ` --help` -> `None`, so dispatch forwards `--help` to the child. -pub fn help_text( +pub async fn help_text( parse: Result<&Cli, &clap::Error>, args: &[String], sym: &Symposium, @@ -44,12 +42,15 @@ pub fn help_text( let help_keyword = matches!(&cli.command, Some(Commands::External(argv)) if argv.first().and_then(|fst| fst.to_str()) == Some("help")); if cli.command.is_none() || help_keyword { - return Some(render_help(sym, cwd)); + return Some(render_help(sym, cwd).await); } if cli.help { // ` --help`; fall back to top-level if the target is a plugin (External) or `--help` come before any subcommand name. - return Some(subcommand_help(args).unwrap_or_else(|| render_help(sym, cwd))); + return match subcommand_help(args) { + Some(text) => Some(text), + None => Some(render_help(sym, cwd).await), + }; } None @@ -88,14 +89,32 @@ pub fn subcommand_help(args: &[String]) -> Option { }) } -pub fn render_help(sym: &Symposium, cwd: &Path) -> String { - let mut deps = sym.workspace_deps(cwd); +pub async fn render_help(sym: &Symposium, cwd: &Path) -> String { + let deps = sym.workspace_deps(cwd); let workspace = deps.load().cloned(); - let registry = load_registry_with_workspace(sym, workspace.as_deref()); - render(®istry, deps.crates()) + let registry = load_registry_with_workspace(sym, workspace.as_deref()).await; + let dep_ids = crate::pm::workspace_dep_ids(sym, &deps).await; + let used = workspace + .as_ref() + .map(|ws| sym.config.plugins.used_names_in(&ws.root)) + .unwrap_or_default(); + + // Resolve the active plugin set so crate-sourced subcommands appear in help. + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used); + let pms = sym.package_managers(&deps); + let active = crate::plugins::active_plugins( + sym, + ®istry, + &pms, + workspace.as_ref().map(|ws| ws.root.as_path()), + &mut ctx, + ) + .await; + + render(&active, &dep_ids, &used) } -fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { +fn render(plugins: &[ParsedPlugin], deps: &[PackageId], used: &[&str]) -> String { let mut cmd = Cli::command(); let full = cmd.render_help().to_string(); @@ -108,10 +127,8 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { let header = &full[..commands_idx]; let options = &full[options_idx..]; - let deps = crate::pm::CargoPm.list_deps(workspace); - - let humans = collect_section(&cmd, registry, &deps, Audience::Humans); - let agents = collect_section(&cmd, registry, &deps, Audience::Agents); + let humans = collect_section(&cmd, plugins, deps, used, Audience::Humans); + let agents = collect_section(&cmd, plugins, deps, used, Audience::Agents); let col_width = humans .iter() @@ -145,8 +162,9 @@ fn render(registry: &PluginRegistry, workspace: &[WorkspaceCrate]) -> String { /// Collect entries for one audience section: clap's builtins first (sorted), then plugin-vended subs whose predicates apply (sorted). fn collect_section( cmd: &Command, - registry: &PluginRegistry, + plugins: &[ParsedPlugin], deps: &[PackageId], + used: &[&str], target: Audience, ) -> Vec<(String, String)> { let mut builtins = cmd @@ -162,35 +180,35 @@ fn collect_section( builtins.sort(); - let mut plugins = applicable_subcommands(registry, deps) + let mut plugin_subs = applicable_subcommands(plugins, deps, used) .into_iter() .filter(|(_, _, subcommand)| subcommand.audience == target) .map(|(_, name, subcommand)| (name.to_string(), subcommand.description.clone())) .collect::>(); - plugins.sort(); + plugin_subs.sort(); - builtins.extend(plugins); + builtins.extend(plugin_subs); builtins } #[cfg(test)] mod tests { - use std::{collections::BTreeMap, path::PathBuf}; + use std::collections::BTreeMap; use expect_test::expect; use crate::{ - plugins::{ParsedPlugin, Plugin, Subcommand}, + plugins::{Plugin, PluginRegistry, Subcommand}, pm::ANY_VERSION, predicate::PredicateSet, }; use super::*; - fn workspace_crate(name: &str, version: &str) -> WorkspaceCrate { - WorkspaceCrate::new(name.into(), semver::Version::parse(version).unwrap(), None) + fn workspace_crate(name: &str, version: &str) -> PackageId { + PackageId::new(crate::pm::CARGO_PM, name, version) } fn crate_set(spec: &str) -> PredicateSet { @@ -203,7 +221,6 @@ mod tests { subcommands: BTreeMap, ) -> ParsedPlugin { ParsedPlugin { - path: PathBuf::from(format!("/test/{name}.toml")), plugin: Plugin { name: name.into(), hooks: vec![], @@ -214,8 +231,8 @@ mod tests { installations: vec![], custom_predicates: vec![], chained: vec![], + requires_use: false, }, - source_dir: PathBuf::from("/test"), workspace_member: false, canonical: PackageId::new("test", name, ANY_VERSION), } @@ -233,7 +250,6 @@ mod tests { fn registry(plugins: Vec) -> PluginRegistry { PluginRegistry { plugins, - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), } @@ -259,7 +275,7 @@ mod tests { #[test] fn renders_with_no_plugin_subs() { let reg = registry(vec![]); - let ws: Vec = vec![]; + let ws: Vec = vec![]; expect![[r#" AI the Rust Way @@ -268,9 +284,12 @@ mod tests { Commands for humans: init Set up user-wide configuration plugin Manage plugins + search Search configured registries for plugins self-update Update symposium to the latest version + status Show which plugins are enabled for this workspace, and why sync Synchronize skills with workspace dependencies telemetry Manage opt-in usage telemetry (status, enable, disable, show) + use Enable a plugin by name and sync it into the workspace Commands for agents: crate-info Find crate sources @@ -283,7 +302,7 @@ mod tests { -h, --help Print help -V, --version Print version "#]] - .assert_eq(&redact(render(®, &ws))); + .assert_eq(&redact(render(®.plugins, &ws, &[]))); } #[test] @@ -296,7 +315,7 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = vec![workspace_crate("example-crate", "1.0.0")]; - let out = render(®, &ws); + let out = render(®.plugins, &ws, &[]); let humans = extract_section(&out, "Commands for humans:"); assert!( humans.contains("example-tool"), @@ -321,7 +340,7 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = vec![workspace_crate("example-crate", "1.0.0")]; - let out = render(®, &ws); + let out = render(®.plugins, &ws, &[]); let agents = extract_section(&out, "Commands for agents:"); assert!( agents.contains("example-tool"), @@ -350,7 +369,7 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = vec![workspace_crate("other-crate-sources", "1.0.0")]; - let out = render(®, &ws); + let out = render(®.plugins, &ws, &[]); assert!( !out.contains("example-tool"), "example-tool should be filtered when workspace lacks example-crate:\n{out}" @@ -372,7 +391,7 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = vec![workspace_crate("example-crate", "1.0.0")]; - let out = render(®, &ws); + let out = render(®.plugins, &ws, &[]); let agents = extract_section(&out, "Commands for agents:"); let bar_pos = agents.find("bar-tool").expect("bar-tool present"); let foo_pos = agents.find("foo-tool").expect("foo-tool present"); diff --git a/src/hook.rs b/src/hook.rs index 62605d3a..006ea60c 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -11,7 +11,7 @@ use crate::installation::{ resolve_runnable, }; use crate::plugins::{HookFormat, Installation}; -use crate::pm::PackageManager as _; +use crate::pm::WorkspaceDeps; use crate::{ config::Symposium, hook_schema::{AgentHookInput, symposium}, @@ -22,7 +22,7 @@ use crate::{ hook_schema::symposium::{OutputEvent, SessionStartInput}, subcommand_dispatch::applicable_subcommands, }; -use symposium_sdk::workspace::WorkspaceDeps; +use std::sync::Arc; /// A hook prepared for dispatch — installation names looked up to concrete /// `Installation` entries, so the dispatch loop never has to scan the plugin's @@ -239,22 +239,22 @@ pub async fn execute_hook( Some(s) => PathBuf::from(s), None => fallback_cwd, }; - let mut deps = sym.workspace_deps(&cwd); + let deps = sym.workspace_deps(&cwd); // Auto-sync: install applicable skills into agent dirs (non-fatal). // SessionStart refreshes source caches and syncs unconditionally. let session_start = event == HookEvent::SessionStart; - run_auto_sync(sym, &mut deps, session_start).await; + run_auto_sync(sym, &deps, session_start).await; // SessionStart (once per session) also refreshes every hook's already- // cached source, so later events dispatch fresh binaries without per- // event network cost. Best-effort; gated by `auto-sync`. if session_start && sym.config.auto_sync { - prewarm_hook_sources(sym, &mut deps).await; + prewarm_hook_sources(sym, &deps).await; } // Builtin dispatch → symposium output → host agent output as Value - let builtin_sym_output = dispatch_builtin(sym, &sym_input, &mut deps).await; + let builtin_sym_output = dispatch_builtin(sym, &sym_input, &deps).await; let builtin_agent_output = handler.translate_output(&builtin_sym_output); let prior_output = builtin_agent_output.to_hook_output(); @@ -266,7 +266,7 @@ pub async fn execute_hook( &sym_input, payload.as_ref(), prior_output, - &mut deps, + &deps, ) .await .map_err(|stderr| { @@ -351,7 +351,7 @@ fn write_hook_trace(agent: HookAgent, event: HookEvent, input: &str, output: &[u /// every source cache (`UpdateLevel::Check`) and sync unconditionally, ignoring /// the `Cargo.lock` freshness gate — upstream skill changes land even when the /// workspace's dependencies are unchanged. -async fn run_auto_sync(sym: &Symposium, deps: &mut WorkspaceDeps, session_start: bool) { +async fn run_auto_sync(sym: &Symposium, deps: &Arc, session_start: bool) { if !sym.config.auto_sync { tracing::debug!("auto-sync disabled, skipping"); return; @@ -397,6 +397,20 @@ async fn run_auto_sync(sym: &Symposium, deps: &mut WorkspaceDeps, session_start: } } +/// Whether the hook pipeline must resolve the workspace crate graph before +/// building the active plugin set. True when some plugin's hook gating names a +/// concrete crate, or when there is any crate-plugin expansion to perform — a +/// chained `[[plugins]]` edge, or an enablement entry that could pull a crate +/// plugin in — since expansion evaluates edge and plugin predicates against the +/// crate graph too. Registry plugins reached without any of these dispatch on a +/// crate-free context (the fast path for `PreToolUse`). +fn hook_dispatch_needs_deps(sym: &Symposium, registry_plugins: &[ParsedPlugin]) -> bool { + registry_plugins + .iter() + .any(|p| p.plugin.hooks_need_dep_resolution() || !p.plugin.chained.is_empty()) + || sym.config.plugins.has_enablement_entries() +} + /// Refresh the source cache for every hook the workspace could fire this /// session. Run once on `SessionStart` (where the per-session cost is /// acceptable) so later events dispatch fresh binaries from cache — dispatch @@ -407,18 +421,32 @@ async fn run_auto_sync(sym: &Symposium, deps: &mut WorkspaceDeps, session_start: /// Refresh-only: a source that was never acquired is left alone (it installs /// lazily when the hook first fires) — `SessionStart` updates installed tools /// but never installs eagerly. Best-effort: failures are logged and skipped. -async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { +async fn prewarm_hook_sources(sym: &Symposium, deps: &Arc) { let workspace = deps.load().cloned(); - let plugins = crate::plugins::load_all_plugins(sym, workspace.as_deref()); + let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref()).await; // Resolving the workspace runs cargo, so only do it when some hook's - // gating references a concrete crate (mirrors dispatch). - let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { - crate::pm::CargoPm.list_deps(deps.crates()) + // gating references a concrete crate, or there is crate-plugin expansion to + // perform (mirrors dispatch). + let dep_ids = if hook_dispatch_needs_deps(sym, ®istry.plugins) { + crate::pm::workspace_dep_ids(sym, deps).await } else { Vec::new() }; - let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); + let used_names = workspace + .as_ref() + .map(|ws| sym.config.plugins.used_names_in(&ws.root)) + .unwrap_or_default(); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used_names); + let pms = sym.package_managers(deps); + let plugins = crate::plugins::active_plugins( + sym, + ®istry, + &pms, + workspace.as_ref().map(|ws| ws.root.as_path()), + &mut ctx, + ) + .await; for parsed in &plugins { if !parsed.applies(&mut ctx) { @@ -457,7 +485,7 @@ async fn prewarm_hook_sources(sym: &Symposium, deps: &mut WorkspaceDeps) { pub async fn dispatch_builtin( sym: &Symposium, input: &symposium::InputEvent, - deps: &mut WorkspaceDeps, + deps: &Arc, ) -> symposium::OutputEvent { match input { symposium::InputEvent::PreToolUse(_) => { @@ -467,7 +495,9 @@ pub async fn dispatch_builtin( symposium::InputEvent::UserPromptSubmit(prompt) => { handle_user_prompt_submit(sym, prompt).await } - symposium::InputEvent::SessionStart(session) => handle_session_start(sym, session, deps), + symposium::InputEvent::SessionStart(session) => { + handle_session_start(sym, session, deps).await + } _ => symposium::OutputEvent::empty_for(HookEvent::PreToolUse), } } @@ -475,15 +505,19 @@ pub async fn dispatch_builtin( /// Handle SessionStart: orient the agent toward crate-aware tooling and, when due, nudge the /// user to update. The two fragments are computed independently -- the discovery hint is never gated /// behind the update-check throttle -- then joined into a single context block. -fn handle_session_start( +async fn handle_session_start( sym: &Symposium, _payload: &SessionStartInput, - deps: &mut WorkspaceDeps, + deps: &Arc, ) -> OutputEvent { - let fragments = [discovery_hint(sym, deps), update_nudge(sym)] - .into_iter() - .flatten() - .collect::>(); + let fragments = [ + discovery_hint(sym, deps).await, + consent_hint(sym, deps).await, + update_nudge(sym), + ] + .into_iter() + .flatten() + .collect::>(); if fragments.is_empty() { OutputEvent::empty_for(HookEvent::SessionStart) @@ -494,12 +528,26 @@ fn handle_session_start( /// Suggest `cargo agents --help` when the active workspace exposes crate-aware plugin subcommands. /// Reuses the help renderer's `applicable_subcommands`, so the hint fires only when there is actually something to discover; `None` otherwise. -fn discovery_hint(sym: &Symposium, deps: &mut WorkspaceDeps) -> Option { +async fn discovery_hint(sym: &Symposium, deps: &Arc) -> Option { let workspace = deps.load().cloned(); - let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref()); - let dep_ids = crate::pm::CargoPm.list_deps(deps.crates()); - - let any_subcommand = !applicable_subcommands(®istry, &dep_ids).is_empty(); + let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref()).await; + let dep_ids = crate::pm::workspace_dep_ids(sym, deps).await; + + let used = workspace + .as_ref() + .map(|ws| sym.config.plugins.used_names_in(&ws.root)) + .unwrap_or_default(); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used); + let pms = sym.package_managers(deps); + let active = crate::plugins::active_plugins( + sym, + ®istry, + &pms, + workspace.as_ref().map(|ws| ws.root.as_path()), + &mut ctx, + ) + .await; + let any_subcommand = !applicable_subcommands(&active, &dep_ids, &used).is_empty(); any_subcommand.then(|| { format!( @@ -511,6 +559,24 @@ fn discovery_hint(sym: &Symposium, deps: &mut WorkspaceDeps) -> Option { }) } +/// Surface dependency plugins awaiting consent. A hook runs on the agent's +/// behalf, so it must never block on stdin — the candidates are reported as +/// context, with a pointer at the interactive command that can actually ask. +/// `None` when nothing is pending. +async fn consent_hint(sym: &Symposium, deps: &Arc) -> Option { + let names = crate::discovery::pending_candidates(sym, deps).await; + if names.is_empty() { + return None; + } + Some(format!( + "These dependencies ship agent plugins that are not enabled yet: {}. \ + They stay off until the user consents — tell the user to run \ + `cargo agents sync` (which asks about each one) or \ + `cargo agents use `. Do not enable them yourself.", + names.join(", ") + )) +} + /// Nudge the user to update. Gated by `auto-update = \"warn\"`, the 25h throttle, /// and a newer published version on the registry; `None` otherwise. fn update_nudge(sym: &Symposium) -> Option { @@ -569,20 +635,37 @@ pub async fn dispatch_plugin_hooks( sym_input: &symposium::InputEvent, original_input: &dyn AgentHookInput, prior_output: serde_json::Value, - deps: &mut WorkspaceDeps, + deps: &Arc, ) -> Result> { let workspace = deps.load().cloned(); - let plugins = crate::plugins::load_all_plugins(sym, workspace.as_deref()); + let registry = crate::plugins::load_registry_with_workspace(sym, workspace.as_deref()).await; // Resolving the workspace means running cargo, so only do it when some - // plugin's hook gating actually references a concrete crate (a `depends-on(*)` - // wildcard or env/shell/path predicate never needs the crate graph). - let dep_ids = if plugins.iter().any(|p| p.plugin.hooks_need_dep_resolution()) { - crate::pm::CargoPm.list_deps(deps.crates()) + // plugin's hook gating references a concrete crate (a `depends-on(*)` + // wildcard or env/shell/path predicate never needs the crate graph), or + // when there is crate-plugin expansion to perform — that too evaluates + // predicates against the crate graph. + let dep_ids = if hook_dispatch_needs_deps(sym, ®istry.plugins) { + crate::pm::workspace_dep_ids(sym, deps).await } else { Vec::new() }; - let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); + let used_names = workspace + .as_ref() + .map(|ws| sym.config.plugins.used_names_in(&ws.root)) + .unwrap_or_default(); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used_names); + // Dispatch over the active set — registry plugins plus crate-sourced ones — + // so a crate plugin's hooks fire exactly like a registry plugin's. + let pms = sym.package_managers(deps); + let plugins = crate::plugins::active_plugins( + sym, + ®istry, + &pms, + workspace.as_ref().map(|ws| ws.root.as_path()), + &mut ctx, + ) + .await; let hooks = dispatched_hooks_for_payload(&plugins, sym_input, host_agent, &mut ctx); let mut output = prior_output; @@ -944,14 +1027,14 @@ mod tests { async fn builtin_pre_tool_use_returns_empty() { let tmp = tempfile::tempdir().unwrap(); let sym = Symposium::from_dir(tmp.path()); - let mut deps = sym.workspace_deps(tmp.path()); + let deps = sym.workspace_deps(tmp.path()); let input = symposium::InputEvent::PreToolUse(symposium::PreToolUseInput::new( "Bash".to_string(), serde_json::Value::default(), None, None, )); - let output = dispatch_builtin(&sym, &input, &mut deps).await; + let output = dispatch_builtin(&sym, &input, &deps).await; assert!(output.additional_context().is_none()); } @@ -959,7 +1042,7 @@ mod tests { async fn builtin_post_tool_use_returns_empty_for_now() { let tmp = tempfile::tempdir().unwrap(); let sym = Symposium::from_dir(tmp.path()); - let mut deps = sym.workspace_deps(tmp.path()); + let deps = sym.workspace_deps(tmp.path()); let input = symposium::InputEvent::PostToolUse(symposium::PostToolUseInput::new( "Bash".to_string(), serde_json::json!({"command": "ls"}), @@ -967,7 +1050,7 @@ mod tests { Some("test-session".to_string()), Some("/tmp".to_string()), )); - let output = dispatch_builtin(&sym, &input, &mut deps).await; + let output = dispatch_builtin(&sym, &input, &deps).await; assert!(output.additional_context().is_none()); } @@ -975,13 +1058,13 @@ mod tests { async fn builtin_user_prompt_submit_returns_empty_for_now() { let tmp = tempfile::tempdir().unwrap(); let sym = Symposium::from_dir(tmp.path()); - let mut deps = sym.workspace_deps(tmp.path()); + let deps = sym.workspace_deps(tmp.path()); let input = symposium::InputEvent::UserPromptSubmit(symposium::UserPromptSubmitInput::new( "Use tokio for async".to_string(), Some("test-session".to_string()), Some("/tmp".to_string()), )); - let output = dispatch_builtin(&sym, &input, &mut deps).await; + let output = dispatch_builtin(&sym, &input, &deps).await; assert!(output.additional_context().is_none()); } @@ -1056,11 +1139,10 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; crate::plugins::ParsedPlugin { - path: std::path::PathBuf::from("test.toml"), plugin, - source_dir: PathBuf::from(".".to_string()), workspace_member: false, canonical: PackageId::new("test", "test-plugin", ANY_VERSION), } diff --git a/src/init.rs b/src/init.rs index 162bd022..3bc561ff 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,7 +1,5 @@ //! Init command: `cargo agents init`. -use std::io::IsTerminal; - use anyhow::{Context, Result}; use dialoguer::MultiSelect; @@ -22,7 +20,7 @@ pub struct InitOpts { /// Whether we can prompt the user interactively. fn interactive(out: &Output) -> bool { - !out.is_quiet() && std::io::stdin().is_terminal() + out.is_interactive() } /// Resolve which agents to configure. Priority: @@ -109,7 +107,9 @@ pub async fn init(sym: &mut Symposium, out: &Output, opts: &InitOpts) -> Result< if agents.is_empty() { // Uninstall: unregister all hooks and MCP servers for every agent. - crate::sync::register_hooks(sym, out).context("failed to unregister hooks")?; + crate::sync::register_hooks(sym, out) + .await + .context("failed to unregister hooks")?; out.done(format!( "{}: wrote user config (no agents — symposium uninstalled)", display_path(&config_path), @@ -125,7 +125,9 @@ pub async fn init(sym: &mut Symposium, out: &Output, opts: &InitOpts) -> Result< )); if sym.config.hook_scope == crate::config::HookScope::Global { - crate::sync::register_hooks(sym, out).context("failed to register global hooks")?; + crate::sync::register_hooks(sym, out) + .await + .context("failed to register global hooks")?; } Ok(()) diff --git a/src/lib.rs b/src/lib.rs index 6b1bf965..cbe71013 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,8 @@ pub mod agents; pub mod cli; pub mod config; pub mod crate_command; +pub mod dirs; +pub mod discovery; pub mod help_render; pub mod hook; pub mod hook_schema; @@ -10,10 +12,13 @@ pub mod output; pub mod plugins; pub mod pm; pub mod report; +pub mod search_command; pub mod self_update; pub mod state; +pub mod status_command; pub mod subcommand_dispatch; pub mod telemetry; +pub mod use_command; pub mod workspace_state; pub(crate) mod crate_metadata; diff --git a/src/output.rs b/src/output.rs index be0dc157..a253a906 100644 --- a/src/output.rs +++ b/src/output.rs @@ -51,6 +51,21 @@ impl Output { self.quiet } + /// May we block on stdin to ask the user a question? + /// + /// Only a non-quiet, non-capturing output attached to a terminal on both + /// ends qualifies. Hook dispatch and the library test harness both use + /// quiet/capturing outputs, so neither can ever reach a prompt — the TTY + /// check alone would not guarantee that, since `cargo test` inherits the + /// developer's terminal. + pub fn is_interactive(&self) -> bool { + use std::io::IsTerminal; + !self.quiet + && self.capture.is_none() + && std::io::stdin().is_terminal() + && std::io::stdout().is_terminal() + } + fn emit(&self, msg: String) { if self.quiet { return; diff --git a/src/plugins.rs b/src/plugins.rs index bff6a952..efea5ea2 100644 --- a/src/plugins.rs +++ b/src/plugins.rs @@ -8,7 +8,6 @@ use crate::config::Symposium; use crate::hook::HookEvent; use crate::hook_schema::HookAgent; use crate::pm::{ANY_VERSION, PackageId}; -use crate::skills::skill_origin_hash; use symposium_install::Source; use sacp::schema::McpServer; @@ -173,9 +172,15 @@ pub struct SkillGroup { skip_serializing_if = "crate::predicate::PredicateSet::is_empty" )] pub predicates: crate::predicate::PredicateSet, - /// Remote source for skills. + /// Remote source for skills. For a `Path` source this is resolved to an + /// absolute directory by the package manager before the plugin reaches + /// core symposium. #[serde(default)] pub source: PluginSource, + /// Display label for the resolved source (`path:` / `git:`), + /// produced by the package manager. `None` until resolved. + #[serde(skip)] + pub source_label: Option, /// The group is defined by a workspace-member plugin. Provenance, stamped /// during manifest validation, not manifest content: workspace skills are /// informal, so their SKILL.md `name` defaults to the skill directory's @@ -208,6 +213,7 @@ impl RawSkillGroup { Ok(SkillGroup { predicates: crate::predicate::PredicateSet::merged(self.depends_on, self.predicates), source, + source_label: None, workspace_member: false, }) } @@ -399,16 +405,10 @@ pub struct CustomPredicate { /// A parsed plugin with its path and manifest. #[derive(Debug, Clone)] pub struct ParsedPlugin { - /// The path from which the plugin was parsed. - pub path: PathBuf, - - /// The parsed plugin manifest. + /// The parsed plugin manifest, with every `source.path` group resolved to + /// an absolute directory by the package manager. pub plugin: Plugin, - /// The plugin source's root directory on disk. Used to compute a - /// `source.path` group's base directory and its `path:` report label. - pub source_dir: PathBuf, - /// Whether this plugin is defined by a member of the active workspace. /// Provenance, stamped by the loader: registry sources stamp `false`; /// the workspace-plugin loader (workspace-local extensions) will stamp @@ -475,6 +475,18 @@ pub struct Plugin { /// too. Expanded during skill resolution by `skills::expand_chained_plugins`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub chained: Vec, + /// A registry plugin whose manifest references no dependency anywhere + /// has nothing to infer a gate from, so it is *dormant*: installed and + /// known, but never active until the user enables it by name (a + /// `[plugins] use` entry). `depends-on = ["*"]` is the explicit + /// always-active spelling. + /// + /// Never set for the positional origins, whose gate is implied by where + /// they were found: a recommendations entry implies `depends-on()`, + /// a crate plugin is reached through a reference to its own crate, and a + /// workspace plugin is gated by workspace membership. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub requires_use: bool, } /// A validated `[[plugins]]` chained reference: whenever the owning plugin is @@ -495,8 +507,13 @@ pub struct ChainedPlugin { } impl Plugin { - /// Check if this plugin's activation predicates hold in `ctx`. + /// Check if this plugin's activation predicates hold in `ctx`. A dormant + /// plugin ([`requires_use`](Self::requires_use)) applies only when an + /// applicable `[plugins] use` entry names it. pub fn applies(&self, ctx: &mut crate::predicate::PredicateContext) -> bool { + if self.requires_use && !ctx.is_used(&self.name) { + return false; + } self.predicates.evaluate(ctx) } @@ -852,16 +869,6 @@ pub struct PluginInfo { pub skill_groups_count: usize, } -/// A standalone skill in the registry, paired with the origin it should be -/// attributed to (derived from the source name + the skill's path within -/// that source, so two registries can each contribute a same-named -/// standalone skill without colliding). -#[derive(Debug, Clone)] -pub struct StandaloneSkill { - pub skill: crate::skills::Skill, - pub origin_hash: String, -} - /// A resolved custom predicate definition in the registry. /// /// Stores the plugin index and predicate index within that plugin so that @@ -908,16 +915,14 @@ impl CustomPredicateRegistry { } } -/// Loaded plugin registry: plugins from TOML manifests and standalone skills -/// discovered directly in plugin source directories. +/// Loaded plugin registry: plugins from TOML manifests plus bare `SKILL.md` +/// entries synthesized into default plugins. #[derive(Debug)] pub struct PluginRegistry { - /// Plugins loaded from `.toml` manifest files. + /// Plugins loaded from `.toml` manifest files, and bare-`SKILL.md` + /// entries loaded as default plugins (one `source.path = "."` group). pub plugins: Vec, - /// Skills discovered as standalone directories containing a `SKILL.md` - /// file directly in a plugin source directory (no TOML manifest needed). - pub standalone_skills: Vec, - /// Non-fatal load warnings for plugins or standalone skills that were skipped. + /// Non-fatal load warnings for entries that were skipped. pub warnings: Vec, /// Global custom predicate registry. Built from all plugins' `custom_predicates`. pub custom_predicates: CustomPredicateRegistry, @@ -932,12 +937,11 @@ pub struct LoadWarning { pub message: String, } -/// Raw scan results from a plugin source directory. +/// Raw scan results from a plugin source directory. Bare `SKILL.md` entries +/// are synthesized into default plugins, so this is just a plugin list. #[derive(Debug)] struct SourceDirContents { plugins: Vec>, - /// Paths to discovered `SKILL.md` files (after recursive search and pruning). - skill_files: Vec, } /// A `[[predicate]]` entry in the raw TOML manifest. @@ -974,9 +978,10 @@ impl Default for RawDefaults { } /// Where a plugin manifest came from, for validation rules that differ by -/// origin: a registry manifest must carry its own `name` and must reference -/// at least one dependency; a workspace-member manifest is already gated by -/// workspace membership, and a crate-embedded manifest is already gated by +/// origin: a registry manifest must carry its own `name`, and one that +/// references no dependency is stamped dormant +/// ([`Plugin::requires_use`]); a workspace-member manifest is already gated +/// by workspace membership, and a crate-embedded manifest is already gated by /// the chained reference that reached it, so both are relaxed (the name /// defaults to a fallback) and default content applies. enum ManifestOrigin<'a> { @@ -1131,283 +1136,255 @@ struct RawHook { predicates: crate::predicate::PredicateSet, } -/// Fetch/update git-based plugin sources. -/// -/// Ensure git-based plugin sources are up to date. +/// Fetch/update git-based registries. /// /// `update` controls freshness checking behavior (see `UpdateLevel`). -/// Only refreshes sources with `auto-update = true` (unless `update` is `Fetch`). -/// Path-based sources are skipped (no fetching needed). -pub async fn ensure_plugin_sources(sym: &Symposium, update: UpdateLevel) { - let sources = sym.plugin_sources(); - - for resolved in &sources { - let source = &resolved.source; - if !matches!(update, UpdateLevel::Fetch) && !source.auto_update { - tracing::debug!(source = %source.name, "skipping (auto-update disabled)"); - continue; - } - - let Some(ref git_url) = source.git else { - tracing::debug!(source = %source.name, "skipping (can only auto-update git)"); - continue; - }; - - tracing::debug!(source = %source.name, url = %git_url, "ensuring plugin source"); - - match fetch_plugin_source(sym, git_url, update).await { - Ok(path) => { - tracing::debug!(source = %source.name, path = %path.display(), "plugin source ready"); - } - Err(e) => { - tracing::warn!(source = %source.name, git_url = %git_url, error = %e, "failed to fetch plugin source"); - } +/// Only refreshes registries with `auto-update = true` (unless `update` is +/// `Fetch`). Path-based registries are skipped (no fetching needed). +pub async fn ensure_registries(sym: &Symposium, update: UpdateLevel) { + // `Fetch` forces even auto-update-disabled registries; otherwise each + // registry's `refresh` honors its own auto-update flag (and a path registry + // is a no-op). + let force = matches!(update, UpdateLevel::Fetch); + for inst in sym.registry_instances() { + if let Err(e) = inst.pm.refresh(update, force).await { + tracing::warn!(registry = %inst.name, error = %e, "failed to refresh registry"); } } } -/// Load all plugins from all configured plugin source directories plus the -/// active workspace, discarding load errors with warnings. +/// Refresh registry content. /// -/// Use `load_registry_with_workspace()` instead if you also need standalone -/// skills. -pub fn load_all_plugins( - sym: &Symposium, - workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, -) -> Vec { - load_registry_impl(sym, workspace).plugins -} - -/// Sync plugin sources. -/// -/// If `provider` is Some, sync only that provider (ignores auto-update). -/// If `provider` is None, sync all sources with auto-update = true. -pub async fn sync_plugin_source(sym: &Symposium, provider: Option<&str>) -> Result> { - let sources = sym.plugin_sources(); +/// If `provider` is Some, sync only that registry (ignores auto-update). +/// If `provider` is None, sync all registries with auto-update = true. +pub async fn sync_registries(sym: &Symposium, provider: Option<&str>) -> Result> { let mut synced = Vec::new(); - for resolved in &sources { - let source = &resolved.source; - if let Some(name) = provider { - if source.name != name { - continue; - } - } else if !source.auto_update { - tracing::debug!(source = %source.name, "skipping (auto-update disabled)"); - continue; - } - - if let Some(ref git_url) = source.git { - tracing::debug!(source = %source.name, url = %git_url, "syncing plugin source"); - match fetch_plugin_source(sym, git_url, UpdateLevel::Fetch).await { - Ok(path) => { - tracing::info!(source = %source.name, path = %path.display(), "synced"); - synced.push(source.name.clone()); - } - Err(e) => { - tracing::warn!(source = %source.name, error = %e, "failed to sync"); - } + for inst in sym.registry_instances() { + // An explicit provider forces just that registry (ignoring its + // auto-update flag); with no provider, every auto-update registry is + // force-fetched. A path registry's `refresh` is a no-op, so it never + // reports as synced. + let force = match provider { + Some(name) if inst.name == name => true, + Some(_) => continue, + None => false, + }; + match inst.pm.refresh(UpdateLevel::Fetch, force).await { + Ok(true) => { + tracing::info!(registry = %inst.name, "synced"); + synced.push(inst.name.clone()); } - } else { - tracing::debug!(source = %source.name, "skipping path-based source"); + Ok(false) => {} + Err(e) => tracing::warn!(registry = %inst.name, error = %e, "failed to sync"), } } Ok(synced) } -/// List all providers and their plugins. -pub fn list_plugins(sym: &Symposium) -> Vec { - let sources = sym.plugin_sources(); - let mut providers = Vec::new(); - - for resolved in &sources { - let source = &resolved.source; - let source_path = resolve_plugin_source_dir(sym, resolved); - let plugins: Vec = source_path - .and_then(|p| scan_source_dir(&p, &source.name).ok()) - .map(|c| c.plugins) - .unwrap_or_default() - .into_iter() - .filter_map(|r| r.ok()) - .map(|p| PluginInfo { - name: p.plugin.name, - hooks_count: p.plugin.hooks.len(), - skill_groups_count: p.plugin.skills.len(), - }) - .collect(); - - providers.push(ProviderInfo { - name: source.name.clone(), - source_type: if source.git.is_some() { "git" } else { "path" }, - git_url: source.git.clone(), - path: source.path.clone(), - plugins, - }); +/// List all providers and their plugins. Routed through the same package +/// managers as registry loading, so what `plugin list` shows can't diverge +/// from what `sync` sees. +pub async fn list_plugins(sym: &Symposium) -> Vec { + let pms = sym.detached_managers(); + let mut by_registry: std::collections::HashMap> = + std::collections::HashMap::new(); + + // Trusted instances only: registry entries, not dependency-embedded crates. + for inst in pms.instances().filter(|i| i.trusted) { + for p in inst.pm.active_plugins(&[]).await { + by_registry + .entry(inst.name.clone()) + .or_default() + .push(PluginInfo { + name: p.plugin.name, + hooks_count: p.plugin.hooks.len(), + skill_groups_count: p.plugin.skills.len(), + }); + } } - providers + // Each registry instance describes its own source (git vs path), so the + // listing metadata comes off the PM rather than a parallel config walk. + pms.instances() + .filter(|i| i.trusted) + .map(|inst| { + let (source_type, git_url, path) = match inst.pm.registry_source() { + Some(crate::pm::RegistrySource::Git { url }) => ("git", Some(url), None), + Some(crate::pm::RegistrySource::Path { dir }) => { + ("path", None, Some(dir.display().to_string())) + } + None => ("unknown", None, None), + }; + ProviderInfo { + plugins: by_registry.remove(inst.name.as_str()).unwrap_or_default(), + name: inst.name.clone(), + source_type, + git_url, + path, + } + }) + .collect() } -/// Find a plugin by name across all sources. -pub fn find_plugin(sym: &Symposium, name: &str) -> Option { - let sources = sym.plugin_sources(); - - for resolved in &sources { - let source_path = resolve_plugin_source_dir(sym, resolved); - if let Some(ref path) = source_path - && let Ok(contents) = scan_source_dir(path, &resolved.source.name) - { - for parsed_plugin in contents.plugins.into_iter().flatten() { - if parsed_plugin.plugin.name == name { - return Some(parsed_plugin); - } +/// Find a plugin by name across all registries. First match wins. +pub async fn find_plugin(sym: &Symposium, name: &str) -> Option { + let pms = sym.detached_managers(); + for inst in pms.instances().filter(|i| i.trusted) { + for parsed in inst.pm.active_plugins(&[]).await { + if parsed.plugin.name == name { + return Some(parsed); } } } None } -/// Resolve the directories for all configured plugin sources, paired with -/// each source's display name (used to attribute standalone skills to a -/// stable origin). -/// -/// For `path` sources: resolves relative to the source's `base_dir`, or uses absolute paths as-is. -/// For `git` sources: computes the cache path under `~/.symposium/cache/plugin-sources/`. -/// -/// Does no network I/O — just computes paths. -fn resolve_plugin_source_dirs( - sym: &Symposium, - sources: &[crate::config::ResolvedPluginSource], -) -> Vec<(String, PathBuf)> { - let cache_base = sym.cache_dir().join("plugin-sources"); - - let mut dirs = Vec::new(); - for resolved in sources { - if let Some(dir) = resolve_one_source(&resolved.source, &resolved.base_dir, &cache_base) { - dirs.push((resolved.source.name.clone(), dir)); - } +/// 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 +/// synthesized into a default plugin ([`load_standalone_skill_plugin`]). `None` +/// when the directory is neither. Called by [`PathPm`](crate::pm::PathPm). +pub(crate) fn load_entry( + root: &Path, + subpath: &Path, + source_name: &str, +) -> Option> { + let dir = root.join(subpath); + match crate::pm::layout::classify(&dir)? { + crate::pm::layout::EntryKind::Plugin(toml_path) => Some( + load_plugin_as(&toml_path, source_name, root, ManifestOrigin::Registry) + .with_context(|| format!("loading plugin from `{}`", toml_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())), + ), } - dirs } -fn resolve_plugin_source_dir( - sym: &Symposium, - resolved: &crate::config::ResolvedPluginSource, -) -> Option { - let cache_base = sym.cache_dir().join("plugin-sources"); - resolve_one_source(&resolved.source, &resolved.base_dir, &cache_base) -} - -fn resolve_one_source( - source: &crate::config::PluginSourceConfig, - base_dir: &Path, - cache_base: &Path, -) -> Option { - if let Some(ref path) = source.path { - let p = PathBuf::from(path); - if p.is_absolute() { - return Some(p); - } else { - return Some(base_dir.join(p)); - } - } else if let Some(ref git_url) = source.git { - let cache_mgr = symposium_install::git::GitCacheManager::from_cache_dir(cache_base); - match cache_mgr.cache_path_for_url(git_url) { - Some(path) => return Some(path), - None => { - tracing::warn!(source = %source.name, url = %git_url, "bad plugin source URL"); - } - } +/// 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 +/// to. Git sources are left untouched — they are fetched at collection time. +/// +/// This is what lets a `ParsedPlugin` carry absolute skill dirs and no +/// manifest/base path: the package manager bakes location in before returning. +pub(crate) fn resolve_group_sources(plugin: &mut Plugin, base_dir: &Path, attribution_root: &Path) { + let attribution = + fs::canonicalize(attribution_root).unwrap_or_else(|_| attribution_root.into()); + for group in &mut plugin.skills { + let PluginSource::Path(rel) = &group.source else { + continue; + }; + let abs = base_dir.join(rel); + let abs = fs::canonicalize(&abs).unwrap_or(abs); + let label = abs + .strip_prefix(&attribution) + .unwrap_or(&abs) + .display() + .to_string(); + group.source_label = Some(format!("path:{label}")); + group.source = PluginSource::Path(abs); } - None } -/// Fetch a plugin source repository, returning the cached directory path. -async fn fetch_plugin_source( - sym: &Symposium, - git_url: &str, - update: UpdateLevel, -) -> Result { - let cache_mgr = - symposium_install::git::GitCacheManager::new(&sym.install_context(), "plugin-sources"); - cache_mgr.fetch_url(git_url, update).await +/// 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 +/// entry directory), and the skill's frontmatter `depends-on`/`predicates` are +/// hoisted to the plugin gate, so the ordinary dormancy rule applies — a skill +/// that names a dependency activates when present, a bare one is dormant until +/// `use`d. Skill identity is unchanged (the `SKILL.md` path hash), so a skill +/// reached this way and via a plugin group dedupes to one install. +fn load_standalone_skill_plugin( + skill_md: &Path, + source_name: &str, + source_dir: &Path, +) -> Result { + let (frontmatter_name, predicates) = crate::skills::standalone_skill_meta(skill_md)?; + let name = frontmatter_name + .or_else(|| { + skill_md + .parent() + .and_then(|dir| dir.file_name()) + .and_then(|n| n.to_str()) + .map(str::to_string) + }) + .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()); + + // A single group scanning the entry directory (the SKILL.md's parent, via + // `path`) discovers the skill itself. + let group: RawSkillGroup = + toml::from_str(r#"source.path = ".""#).expect("static default group"); + let skills = vec![group.validate()?]; + + let mut plugin = Plugin { + name: name.clone(), + predicates, + installations: Vec::new(), + hooks: Vec::new(), + skills, + mcp_servers: Vec::new(), + subcommands: std::collections::BTreeMap::new(), + custom_predicates: Vec::new(), + chained: Vec::new(), + requires_use, + }; + let base = skill_md.parent().unwrap_or(source_dir); + resolve_group_sources(&mut plugin, base, source_dir); + Ok(ParsedPlugin { + canonical: PackageId::new(source_name, &name, ANY_VERSION), + plugin, + workspace_member: false, + }) } -/// Scan all configured plugin source directories and load the registry. +/// Load the plugin registry from the active package-manager instances. /// -/// Discovers TOML plugin manifests and standalone skill directories, -/// then loads both into a `PluginRegistry`. +/// Each registry instance lists the plugin-bearing entries it offers +/// (`list_plugins`, no network), and each entry is loaded as a plugin: a +/// `SYMPOSIUM.toml` manifest, or a bare `SKILL.md` synthesized into a default +/// plugin. Refreshing git registries is a separate concern +/// ([`ensure_registries`]). /// -/// This form loads plugin sources only; workspace-scoped callers use +/// This form loads registries only; workspace-scoped callers use /// [`load_registry_with_workspace`] to also pick up plugins defined by the /// active workspace. -pub fn load_registry(sym: &Symposium) -> PluginRegistry { - load_registry_impl(sym, None) +pub async fn load_registry(sym: &Symposium) -> PluginRegistry { + load_registry_impl(sym, None).await } /// [`load_registry`] plus the plugins defined by the active workspace (the /// workspace root and every member directory), stamped as workspace -/// members. `None` (not in a workspace) degrades to plugin sources only. -pub fn load_registry_with_workspace( +/// members. `None` (not in a workspace) degrades to registries only. +pub async fn load_registry_with_workspace( sym: &Symposium, - workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, + workspace: Option<&crate::pm::LoadedWorkspace>, ) -> PluginRegistry { - load_registry_impl(sym, workspace) + load_registry_impl(sym, workspace).await } -fn load_registry_impl( +async fn load_registry_impl( sym: &Symposium, - workspace: Option<&symposium_sdk::workspace::LoadedWorkspace>, + workspace: Option<&crate::pm::LoadedWorkspace>, ) -> PluginRegistry { - let sources = sym.plugin_sources(); + let pms = sym.detached_managers(); let mut plugins = Vec::new(); - let mut standalone_skills = Vec::new(); let mut warnings = Vec::new(); - for (source_name, dir) in resolve_plugin_source_dirs(sym, &sources) { - match scan_source_dir(&dir, &source_name) { - Ok(contents) => { - for result in contents.plugins { - match result { - Ok(p) => plugins.push(p), - Err(e) => { - tracing::warn!(error = %e, "failed to load plugin"); - warnings.push(LoadWarning { - path: dir.join(".toml"), - message: format!("failed to load plugin: {e}"), - }); - } - } - } - for skill_md in contents.skill_files { - match crate::skills::load_standalone_skill(&skill_md) { - Ok(skill) => { - let origin_hash = skill_origin_hash(&skill_md); - standalone_skills.push(StandaloneSkill { skill, origin_hash }); - } - Err(e) => { - tracing::warn!( - path = %skill_md.display(), - error = %e, - "failed to load standalone skill" - ); - warnings.push(LoadWarning { - path: skill_md, - message: format!("failed to load standalone skill: {e}"), - }); - } - } - } - } - Err(e) => { - tracing::warn!(dir = %dir.display(), error = %e, "failed to scan plugin source dir"); - warnings.push(LoadWarning { - path: dir, - message: format!("failed to scan plugin source dir: {e}"), - }); - } - } + // Trust roots only: the configured registries (and, below, the workspace). + // 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. + for inst in pms.instances().filter(|i| i.trusted) { + plugins.extend(inst.pm.active_plugins(&[]).await); } if let Some(ws) = workspace { @@ -1417,22 +1394,144 @@ fn load_registry_impl( warnings.extend(ws_warnings); } - tracing::debug!( - plugins = plugins.len(), - standalone_skills = standalone_skills.len(), - "plugin registry loaded" - ); + tracing::debug!(plugins = plugins.len(), "plugin registry loaded"); let custom_predicates = build_custom_predicate_registry(&plugins, &mut warnings); PluginRegistry { plugins, - standalone_skills, warnings, custom_predicates, } } +/// The full set of plugins active for a workspace, resolved as a fixed-point +/// over the **package-manager set** (`pms`): the trust-root plugins the +/// registry already loaded, plus the crate-sourced plugins transitively reached +/// through `[[plugins]]` chained references and dependency enablement. +/// +/// This is the single seam every facet resolves over — skills, MCP servers, +/// hooks, and subcommands — so a crate-sourced plugin's extensions dispatch +/// exactly like a registry plugin's. Each returned plugin has passed its own +/// plugin-level gate; a facet still calls [`ParsedPlugin::applies`] before its +/// own predicates, to re-stamp `workspace-member()` for the plugin being read. +/// +/// Crate loading goes through `pms` ([`crate::pm::PmRegistry::load_plugin`]), +/// which fetches cache-only, so this is safe on the per-event hook path. +/// `ctx.deps` supplies the dependency list expansion evaluates against. +pub async fn active_plugins( + sym: &Symposium, + registry: &PluginRegistry, + pms: &crate::pm::PmRegistry, + workspace_root: Option<&Path>, + ctx: &mut crate::predicate::PredicateContext<'_>, +) -> Vec { + let mut active = Vec::new(); + // Crate identities already loaded through the set, keyed on `(pm, name)` so + // a crate reached through two chains — or a chain and dependency enablement — + // loads once (its hooks don't double-fire, its subcommands aren't a false + // conflict), while a registry plugin and a crate of the same name stay + // distinct. + let mut visited = std::collections::HashSet::new(); + // Package ids still to resolve through the set. + let mut worklist: Vec = Vec::new(); + + // Seed with the trust-root plugins (registry + workspace), gated. + for parsed in ®istry.plugins { + record_active(parsed.clone(), ctx, &mut active, &mut worklist); + } + + // Enabled crates: consented dependencies and `use`d crates that aren't + // dependencies. A name a registry already provides as a plugin was seeded + // above, so skip it here rather than also fetching it as a crate. + if let Some(root) = workspace_root { + let registry_names: std::collections::HashSet = registry + .plugins + .iter() + .map(|p| crate::crate_sources::normalize_crate_name(&p.plugin.name)) + .collect(); + for name in crate::discovery::enabled_dependencies(sym, ctx.deps, root) { + if !registry_names.contains(&crate::crate_sources::normalize_crate_name(&name)) { + worklist.push(crate::pm::CargoPm::id_for(&name, None)); + } + } + } + + // Fixed-point: resolve each id through the set, record any new plugin, and + // enqueue its own chained references. + while let Some(id) = worklist.pop() { + for plugin in pms.load_plugin(&id).await { + if visited.insert(plugin_key(&plugin.canonical)) { + record_active(plugin, ctx, &mut active, &mut worklist); + } + } + } + + active +} + +/// The dedup key for a loaded crate plugin: its ecosystem plus normalized name, +/// so hyphen/underscore spellings collapse but a registry plugin and a crate of +/// the same name stay distinct. +fn plugin_key(id: &crate::pm::PackageId) -> String { + format!( + "{}/{}", + id.pm, + crate::crate_sources::normalize_crate_name(&id.name) + ) +} + +/// Gate `plugin` and, if it passes, record it into `active` and enqueue its +/// `[[plugins]]` chained references (evaluated against this plugin's provenance) +/// onto `worklist`. +fn record_active( + plugin: ParsedPlugin, + ctx: &mut crate::predicate::PredicateContext<'_>, + active: &mut Vec, + worklist: &mut Vec, +) { + if !plugin.applies(ctx) { + tracing::debug!( + report = %crate::report::ReportEvent::PluginConsidered { + plugin: plugin.plugin.name.clone(), + matched: false, + reason: Some("plugin-level predicates not satisfied".into()), + }, + ); + return; + } + tracing::debug!( + report = %crate::report::ReportEvent::PluginConsidered { + plugin: plugin.plugin.name.clone(), + matched: true, + reason: None, + }, + ); + warn_undispatched_crate_features(&plugin); + for edge in &plugin.plugin.chained { + ctx.set_workspace_member(plugin.workspace_member); + if edge.predicates.evaluate(ctx) { + worklist.push(crate::pm::CargoPm::id_for(&edge.name, None)); + } + } + active.push(plugin); +} + +/// Warn when a crate-embedded plugin declares custom predicates. Its skills, +/// hooks, MCP servers, and subcommands now dispatch through the active-plugin +/// set, but custom predicate *definitions* are still resolved only from +/// configured registries, so a crate that vends its own predicate cannot yet +/// have it evaluated. +fn warn_undispatched_crate_features(parsed: &ParsedPlugin) { + if !parsed.plugin.custom_predicates.is_empty() { + tracing::warn!( + plugin = %parsed.plugin.name, + "crate-embedded plugin declares custom predicates, which are not yet \ + registered (its skills, hooks, MCP servers, and subcommands are dispatched)" + ); + } +} + /// Display name workspace plugins are attributed to. Parenthesized so it /// can't collide with a configured plugin-source name. /// Load the plugins defined by the active workspace: the workspace root @@ -1501,7 +1600,7 @@ fn workspace_plugin_for_dir( .file_name() .and_then(|n| n.to_str()) .unwrap_or("workspace"); - let plugin = validate_manifest( + let mut plugin = validate_manifest( raw, ManifestOrigin::WorkspaceMember { dir_name, @@ -1509,141 +1608,59 @@ fn workspace_plugin_for_dir( }, ) .with_context(|| format!("validating `{}`", manifest_path.display()))?; + resolve_group_sources(&mut plugin, dir, workspace_root); Ok(Some(ParsedPlugin { canonical: PackageId::new("local", &plugin.name, ANY_VERSION), - path: manifest_path, plugin, - source_dir: workspace_root.to_path_buf(), workspace_member: true, })) } -/// Scan a plugin source directory for TOML plugin manifests and standalone skills. +/// Scan a directory laid out like a plugin source, loading its plugin +/// manifests and collecting its standalone skills. /// -/// Discovery rules: -/// 1. Plugin = directory with `SYMPOSIUM.toml` file -/// 2. Skill = directory with `SKILL.md` file -/// 3. Plugin takes precedence over skill in the same directory -/// 4. Once a directory is claimed as plugin/skill, don't recurse into it +/// Entry discovery is the [flat layout](crate::pm::layout): a directory with +/// a `SYMPOSIUM.toml` is a plugin, one with a `SKILL.md` is a standalone +/// skill (manifest wins when both are present), and a claimed directory is +/// not recursed into. /// -/// `source_name` is the registry source the directory was reached -/// through; it becomes each `ParsedPlugin`'s canonical `pm` tag. Callers -/// that don't care (CLI validation, tests) pass `""`. +/// This is the *offline* form used by the `plugin validate` CLI, which +/// points at an arbitrary directory rather than a configured registry. +/// Registry loading goes through the package-manager instances instead +/// ([`load_registry`]). `source_name` becomes each `ParsedPlugin`'s +/// canonical `pm` tag; callers that don't care pass `""`. fn scan_source_dir>(dir: P, source_name: &str) -> Result { - let mut plugins = Vec::new(); - let mut skill_files = Vec::new(); - let dir = dir.as_ref(); + let mut plugins = Vec::new(); - // A plugin source should *contain* plugins/skills, not *be* one. - if let Some(dir_type) = discover_directory_type(dir)? { - match dir_type { - DirectoryType::Plugin(_) => anyhow::bail!( - "plugin source root contains SYMPOSIUM.toml — it should contain subdirectories with plugins, not be a plugin itself: {}", - dir.display() - ), - DirectoryType::Skill(_) => anyhow::bail!( - "plugin source root contains SKILL.md — it should contain subdirectories with skills, not be a skill itself: {}", - dir.display() - ), - } - } - - discover_in_directory(dir, source_name, dir, &mut plugins, &mut skill_files)?; - - Ok(SourceDirContents { - plugins, - skill_files, - }) -} - -/// Recursively discover plugins and skills with precedence and pruning. -/// -/// `source_name` and `source_dir` describe the registry source root — -/// passed through unchanged on recursion and stamped onto each -/// discovered `ParsedPlugin`. -fn discover_in_directory( - dir: &Path, - source_name: &str, - source_dir: &Path, - plugins: &mut Vec>, - skill_files: &mut Vec, -) -> Result<()> { - let entries = match fs::read_dir(dir) { - Ok(e) => e, - Err(_) => return Ok(()), - }; - - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - - // Check what this directory contains (plugin takes precedence) - if let Some(discovered) = discover_directory_type(&path)? { - match discovered { - DirectoryType::Plugin(toml_path) => { - let plugin = load_plugin(&toml_path, source_name, source_dir) - .with_context(|| format!("loading plugin from `{}`", toml_path.display())); - - tracing::debug!( - path = %toml_path.display(), - plugin = ?plugin, - "loaded plugin", - ); - - plugins.push(plugin); - } - DirectoryType::Skill(skill_md_path) => { - tracing::debug!( - path = %skill_md_path.display(), - "found standalone skill", - ); - skill_files.push(skill_md_path); - } + for entry in crate::pm::layout::enumerate(dir)? { + match crate::pm::layout::classify(&dir.join(&entry.subpath)) { + Some(crate::pm::layout::EntryKind::Plugin(toml_path)) => { + let plugin = load_plugin(&toml_path, source_name, dir) + .with_context(|| format!("loading plugin from `{}`", toml_path.display())); + tracing::debug!(path = %toml_path.display(), plugin = ?plugin, "loaded plugin"); + 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())); + tracing::debug!(path = %skill_md_path.display(), "loaded bare skill as plugin"); + plugins.push(plugin); } - // Don't recurse - directory is claimed - } else { - // Directory doesn't contain plugin/skill, recurse into it - discover_in_directory(&path, source_name, source_dir, plugins, skill_files)?; + None => {} } } - Ok(()) -} - -/// What type of directory this is (plugin or skill). -enum DirectoryType { - Plugin(PathBuf), // Path to SYMPOSIUM.toml - Skill(PathBuf), // Path to SKILL.md file -} - -/// Determine if a directory contains a plugin or skill. -/// Returns None if it contains neither. -/// SYMPOSIUM.toml takes precedence over SKILL.md. -fn discover_directory_type(dir: &Path) -> Result> { - // Check for SYMPOSIUM.toml (the only valid plugin manifest) - let symposium_toml = dir.join("SYMPOSIUM.toml"); - if symposium_toml.is_file() { - return Ok(Some(DirectoryType::Plugin(symposium_toml))); - } - - // Check for SKILL.md - let skill_md = dir.join("SKILL.md"); - if skill_md.is_file() { - return Ok(Some(DirectoryType::Skill(skill_md))); - } - - Ok(None) + Ok(SourceDirContents { plugins }) } /// Result of validating a single item in a plugin source directory. #[derive(Debug)] pub struct ValidationResult { - /// Path to the validated file (TOML manifest or SKILL.md). - pub path: PathBuf, + /// Identifier for the validated item: the plugin/skill name (its id within + /// the source), or `` when a load failed before a name was known. + pub id: String, /// What kind of item was validated. pub kind: ValidationKind, /// `Ok(())` if valid, `Err` with the validation error. @@ -1677,35 +1694,34 @@ impl std::fmt::Display for ValidationKind { pub fn validate_source_dir(dir: &Path) -> Result> { let contents = scan_source_dir(dir, "")?; let mut results = Vec::new(); - let mut plugin_skill_dirs: Vec = Vec::new(); for plugin_result in contents.plugins { - let (path, plugin, result) = match plugin_result { - Ok(parsed) => (parsed.path.clone(), Some(parsed), Ok(())), - Err(e) => { - let path = dir.join(".toml"); - (path, None, Err(e)) - } + let (id, plugin, result) = match plugin_result { + // The plugin's own name is its id; the load error already names the + // file it came from. + Ok(parsed) => (parsed.canonical.name.clone(), Some(parsed), Ok(())), + Err(e) => ("".to_string(), None, Err(e)), }; let mut children = Vec::new(); // Validate that local skill groups contain discoverable skills. if let Some(parsed) = &plugin { - let plugin_dir = parsed.path.parent().unwrap_or(dir); for group in &parsed.plugin.skills { - if let PluginSource::Path(ref rel_path) = group.source { - let joined = plugin_dir.join(rel_path); - let skills_dir: PathBuf = joined.components().collect(); - plugin_skill_dirs.push(skills_dir.clone()); + if let PluginSource::Path(ref skills_dir) = group.source { + let skills_dir = skills_dir.clone(); let found = crate::skills::discover_skills( &skills_dir, group.workspace_member, &group.predicates, ); + let group_label = group + .source_label + .clone() + .unwrap_or_else(|| "skills".to_string()); if found.is_empty() { children.push(ValidationResult { - path: skills_dir, + id: group_label, kind: ValidationKind::Skill, result: Ok(()), warning: Some( @@ -1715,12 +1731,12 @@ pub fn validate_source_dir(dir: &Path) -> Result> { }); } else { for skill_result in found { - let (skill_path, result) = match skill_result { - Ok(skill) => (skill.path.clone(), Ok(())), - Err(e) => (skills_dir.join("SKILL.md"), Err(e)), + let (skill_id, result) = match skill_result { + Ok(skill) => (skill.name().to_string(), Ok(())), + Err(e) => (group_label.clone(), Err(e)), }; children.push(ValidationResult { - path: skill_path, + id: skill_id, kind: ValidationKind::Skill, result, warning: None, @@ -1732,30 +1748,24 @@ pub fn validate_source_dir(dir: &Path) -> Result> { } } + let warning = plugin.as_ref().and_then(|parsed| { + parsed.plugin.requires_use.then(|| { + format!( + "plugin `{name}` references no dependency; it stays dormant until enabled \ + with `cargo agents use {name}`", + name = parsed.plugin.name, + ) + }) + }); results.push(ValidationResult { - path: path.clone(), + id, kind: ValidationKind::Plugin, result, - warning: None, + warning, children, }); } - for skill_md in contents.skill_files { - // Skip skills already validated as part of a plugin group. - if plugin_skill_dirs.iter().any(|d| skill_md.starts_with(d)) { - continue; - } - let result = crate::skills::load_standalone_skill(&skill_md).map(|_| ()); - results.push(ValidationResult { - path: skill_md, - kind: ValidationKind::Skill, - result, - warning: None, - children: Vec::new(), - }); - } - Ok(results) } @@ -1781,12 +1791,6 @@ pub fn collect_crate_names_in_source_dir(dir: &Path) -> Result> { } } - for skill_md in contents.skill_files { - if let Ok(skill) = crate::skills::load_standalone_skill(&skill_md) { - skill.predicates.collect_dep_names(&mut names); - } - } - Ok(names.into_iter().collect()) } @@ -1813,16 +1817,33 @@ pub fn load_plugin( manifest_path: &Path, source_name: &str, source_dir: &Path, +) -> Result { + load_plugin_as( + manifest_path, + source_name, + source_dir, + ManifestOrigin::Registry, + ) +} + +/// [`load_plugin`] with an explicit manifest origin — the entry position +/// within its registry decides the validation rules (a recommendations +/// `cargo//` entry gains an implied gate and default name). +fn load_plugin_as( + manifest_path: &Path, + source_name: &str, + source_dir: &Path, + origin: ManifestOrigin<'_>, ) -> Result { let content = fs::read_to_string(manifest_path)?; let manifest: RawPluginManifest = toml::from_str(&content)?; - let plugin = validate_manifest(manifest, ManifestOrigin::Registry) + let mut plugin = validate_manifest(manifest, origin) .with_context(|| format!("validating `{}`", manifest_path.display()))?; + let base = manifest_path.parent().unwrap_or(source_dir); + resolve_group_sources(&mut plugin, base, source_dir); Ok(ParsedPlugin { canonical: PackageId::new(source_name, &plugin.name, ANY_VERSION), - path: manifest_path.to_path_buf(), plugin, - source_dir: source_dir.to_path_buf(), // Registry sources are never workspace members; the workspace-plugin // loader is the only place that stamps true. workspace_member: false, @@ -2029,34 +2050,31 @@ fn validate_manifest( .map(RawPluginMcpServer::validate) .collect::>>()?; - // Every registry plugin must reference at least one dependency (or - // custom predicate) somewhere — at the plugin, skill-group, hook, or - // MCP-server level — via `depends-on`, a `depends-on(...)` predicate, or - // a custom predicate. Otherwise it would never apply to any project. - // Workspace plugins are exempt: being in the workspace is their gate. - if matches!(origin, ManifestOrigin::Registry) { + let chained = manifest + .plugins + .into_iter() + .map(RawChainedPlugin::validate) + .collect::>>()?; + + // A registry plugin that references no dependency anywhere — at the + // plugin, skill-group, hook, MCP-server, or chain-edge level, via + // `depends-on`, a `depends-on(...)` predicate, or a custom predicate — + // has no gate to infer, so it loads dormant: known, but inactive until + // a `[plugins] use` entry names it. The positional origins are exempt + // because their gate comes from where they were found (workspace + // membership, or the reference that reached a crate). + let requires_use = matches!(origin, ManifestOrigin::Registry) && { let has_custom_predicate = predicates .predicates .iter() .any(|p| matches!(p, crate::predicate::Predicate::Custom { .. })); - let mentions_dep = has_custom_predicate + !(has_custom_predicate || predicates.mentions_dep() || skills.iter().any(|g| g.predicates.mentions_dep()) || hooks.iter().any(|h| h.predicates.mentions_dep()) - || mcp_servers.iter().any(|m| m.predicates.mentions_dep()); - if !mentions_dep { - bail!( - "plugin `{name}` references no dependency — add `depends-on = [...]` or a \ - `depends-on(...)` predicate at the plugin, `[[skills]]`, or `[[mcp_servers]]` level" - ); - } - } - - let chained = manifest - .plugins - .into_iter() - .map(RawChainedPlugin::validate) - .collect::>>()?; + || mcp_servers.iter().any(|m| m.predicates.mentions_dep()) + || chained.iter().any(|c| c.predicates.mentions_dep())) + }; Ok(Plugin { name, @@ -2068,6 +2086,7 @@ fn validate_manifest( subcommands, custom_predicates, chained, + requires_use, }) } @@ -2183,7 +2202,7 @@ fn build_custom_predicate_registry( let existing: &ResolvedCustomPredicate = existing; let existing_plugin_name = &plugins[existing.plugin_index].plugin.name; warnings.push(LoadWarning { - path: parsed.path.clone(), + path: PathBuf::from(&parsed.plugin.name), message: format!( "custom predicate `{}` defined by both `{}` and `{}` — skipping both", cp.name, existing_plugin_name, parsed.plugin.name @@ -2228,6 +2247,30 @@ mod tests { validate_manifest(manifest, ManifestOrigin::Registry) } + fn from_str_as(s: &str, origin: ManifestOrigin<'_>) -> Result { + let manifest: RawPluginManifest = toml::from_str(s)?; + validate_manifest(manifest, origin) + } + + /// A flat registry plugin gates itself on its own `depends-on`, evaluated + /// when it is loaded — the layout supplies no implied gate. + #[test] + fn registry_plugin_gates_on_its_own_depends_on() { + let plugin = from_str_as( + indoc! {r#" + name = "widget-tools" + depends-on = ["widget-lib"] + [[skills]] + source.path = "skills" + "#}, + ManifestOrigin::Registry, + ) + .unwrap(); + assert_eq!(plugin.name, "widget-tools"); + assert!(plugin.applies(&mut ctx(&[PackageId::new("cargo", "widget-lib", "1.0.0")]))); + assert!(!plugin.applies(&mut ctx(&[PackageId::new("cargo", "serde", "1.0.0")]))); + } + #[test] fn chained_plugins_parse_cargo_source() { let plugin = from_str( @@ -2666,7 +2709,7 @@ mod tests { } #[test] - fn scan_source_dir_finds_plugins_and_standalone_skills() { + fn scan_source_dir_finds_manifest_and_bare_skill_plugins() { use crate::test_utils::{File, instantiate_fixture}; let tmp = instantiate_fixture(&[ File( @@ -2697,14 +2740,60 @@ mod tests { // Also create a random directory (should be ignored) std::fs::create_dir_all(tmp.path().join("not-a-plugin-or-skill")).unwrap(); + // Both the manifest plugin and the bare SKILL.md (synthesized into a + // plugin named for its directory) are returned as plugins. let contents = scan_source_dir(tmp.path(), "").unwrap(); - assert_eq!(contents.plugins.len(), 1); - assert_eq!( - contents.plugins[0].as_ref().unwrap().plugin.name, - "my-plugin" - ); - assert_eq!(contents.skill_files.len(), 1); - assert!(contents.skill_files[0].ends_with("assert-struct/SKILL.md")); + let mut names: Vec<&str> = contents + .plugins + .iter() + .map(|p| p.as_ref().unwrap().plugin.name.as_str()) + .collect(); + names.sort(); + assert_eq!(names, vec!["assert-struct", "my-plugin"]); + + // The bare-skill plugin is gated on the skill's own `depends-on`. + let bare = contents + .plugins + .iter() + .map(|p| p.as_ref().unwrap()) + .find(|p| p.plugin.name == "assert-struct") + .unwrap(); + assert!(!bare.plugin.requires_use); + assert!(bare.plugin.predicates.references_dep("serde")); + } + + #[test] + fn bare_skill_plugin_hoists_gate_and_applies_dormancy() { + use crate::test_utils::{File, instantiate_fixture}; + let tmp = instantiate_fixture(&[ + File( + "gated/SKILL.md", + "---\nname: gated-skill\ndescription: d\ndepends-on: serde\n---\nBody.\n", + ), + File( + "bare/SKILL.md", + "---\nname: bare-skill\ndescription: d\n---\nBody.\n", + ), + ]); + + // A skill that names a dependency: the frontmatter gate is hoisted to + // the plugin, which takes the skill's declared name and is not dormant. + let gated = + load_standalone_skill_plugin(&tmp.path().join("gated/SKILL.md"), "recs", tmp.path()) + .unwrap(); + assert_eq!(gated.plugin.name, "gated-skill"); + assert!(!gated.plugin.requires_use); + assert!(gated.plugin.predicates.references_dep("serde")); + assert_eq!(gated.plugin.skills.len(), 1); + assert_eq!(gated.canonical.pm, "recs"); + + // A bare skill names no dependency anywhere, so the ordinary dormancy + // rule leaves it dormant until `use`d. + let bare = + load_standalone_skill_plugin(&tmp.path().join("bare/SKILL.md"), "recs", tmp.path()) + .unwrap(); + assert_eq!(bare.plugin.name, "bare-skill"); + assert!(bare.plugin.requires_use); } #[test] @@ -2712,14 +2801,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let contents = scan_source_dir(tmp.path(), "").unwrap(); assert!(contents.plugins.is_empty()); - assert!(contents.skill_files.is_empty()); } #[test] fn scan_source_dir_missing() { let contents = scan_source_dir("/nonexistent/path/abc123", "").unwrap(); assert!(contents.plugins.is_empty()); - assert!(contents.skill_files.is_empty()); } #[test] @@ -2788,9 +2875,10 @@ mod tests { ), ]); + // Manifest wins: the sibling SKILL.md is part of the plugin, not a + // separate bare-skill plugin. let contents = scan_source_dir(tmp.path(), "").unwrap(); assert_eq!(contents.plugins.len(), 1); - assert_eq!(contents.skill_files.len(), 0); expect_test::expect![[r#"mixed-plugin"#]] .assert_eq(&contents.plugins[0].as_ref().unwrap().plugin.name); } @@ -2816,7 +2904,6 @@ mod tests { let contents = scan_source_dir(tmp.path(), "").unwrap(); assert_eq!(contents.plugins.len(), 1); - assert_eq!(contents.skill_files.len(), 0); expect_test::expect![[r#"preferred-plugin"#]] .assert_eq(&contents.plugins[0].as_ref().unwrap().plugin.name); } @@ -2874,12 +2961,17 @@ mod tests { ), ]); + // `foo/` (manifest) and `baz/` (bare SKILL.md → plugin) are the two + // entries; both claim their directory, so `foo/bar` and `baz/qux` are + // pruned rather than discovered separately. let contents = scan_source_dir(tmp.path(), "").unwrap(); - assert_eq!(contents.plugins.len(), 1); - assert_eq!(contents.skill_files.len(), 1); - expect_test::expect![[r#"foo-plugin"#]] - .assert_eq(&contents.plugins[0].as_ref().unwrap().plugin.name); - assert!(contents.skill_files[0].ends_with("baz/SKILL.md")); + let mut names: Vec<&str> = contents + .plugins + .iter() + .map(|p| p.as_ref().unwrap().plugin.name.as_str()) + .collect(); + names.sort(); + assert_eq!(names, vec!["baz-skill", "foo-plugin"]); } #[test] @@ -2920,11 +3012,20 @@ mod tests { ]); let results = validate_source_dir(tmp.path()).unwrap(); - let ok_count = results.iter().filter(|r| r.result.is_ok()).count(); - let err_count = results.iter().filter(|r| r.result.is_err()).count(); + // Four top-level entries: two manifest plugins plus two bare-skill + // plugins (each named for its directory). assert_eq!(results.len(), 4); - assert_eq!(ok_count, 2); - assert_eq!(err_count, 2); + // The malformed manifest fails to load; the other three plugins + // synthesize fine. + assert_eq!(results.iter().filter(|r| r.result.is_err()).count(), 1); + // The bad skill (missing frontmatter `name`) surfaces as a failed + // child of its plugin's `source.path = "."` group. + let child_errors = results + .iter() + .flat_map(|r| &r.children) + .filter(|c| c.result.is_err()) + .count(); + assert_eq!(child_errors, 1); } #[test] @@ -3080,6 +3181,7 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; assert!(plugin_wildcard.applies(&mut ctx(&workspace_crates))); @@ -3094,6 +3196,7 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; assert!(plugin_serde.applies(&mut ctx(&workspace_crates))); @@ -3108,6 +3211,7 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; assert!(!plugin_other.applies(&mut ctx(&workspace_crates))); @@ -3122,6 +3226,7 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; assert!(!plugin_version.applies(&mut ctx(&workspace_crates))); } @@ -3167,7 +3272,6 @@ mod tests { for parsed in &plugins { assert!(parsed.workspace_member); - assert_eq!(parsed.source_dir, root); // Groups carry the provenance too: workspace skills load with // lenient frontmatter rules. assert!(parsed.plugin.skills.iter().all(|g| g.workspace_member)); @@ -3176,14 +3280,15 @@ mod tests { // Root and bare member each get the two default groups: `skills/` // and the `workspace-member()`-gated `.agents/skills`. assert_eq!(plugins[0].plugin.skills.len(), 2); - assert_eq!( - plugins[1].plugin.skills[0].source, - PluginSource::Path(PathBuf::from("skills")) - ); - assert_eq!( - plugins[1].plugin.skills[1].source, - PluginSource::Path(PathBuf::from(".agents/skills")) - ); + // The PM resolved both default groups to absolute directories. + assert!(matches!( + &plugins[1].plugin.skills[0].source, + PluginSource::Path(p) if p.is_absolute() && p.ends_with("skills") + )); + assert!(matches!( + &plugins[1].plugin.skills[1].source, + PluginSource::Path(p) if p.is_absolute() && p.ends_with(".agents/skills") + )); assert!(!plugins[1].plugin.skills[1].predicates.predicates.is_empty()); // The opt-out member has no groups. assert!(plugins[2].plugin.skills.is_empty()); @@ -3211,8 +3316,9 @@ mod tests { #[test] fn workspace_manifest_may_omit_dependency_gate() { - // A registry manifest without any depends-on is rejected; the same - // manifest is fine as a workspace plugin (membership is the gate). + // A registry manifest without any depends-on loads dormant; the same + // manifest is fully active as a workspace plugin (membership is the + // gate). let manifest: RawPluginManifest = toml::from_str(indoc! {r#" name = "gateless" @@ -3220,8 +3326,8 @@ mod tests { source.path = "extra-skills" "#}) .unwrap(); - let err = validate_manifest(manifest, ManifestOrigin::Registry).unwrap_err(); - assert!(err.to_string().contains("references no dependency")); + let dormant = validate_manifest(manifest, ManifestOrigin::Registry).unwrap(); + assert!(dormant.requires_use); let manifest: RawPluginManifest = toml::from_str(indoc! {r#" name = "gateless" @@ -3240,6 +3346,53 @@ mod tests { .unwrap(); // Explicit group plus the two appended default groups. assert_eq!(plugin.skills.len(), 3); + assert!(!plugin.requires_use); + } + + /// A gate anywhere in the manifest — including on a `[[plugins]]` chain + /// edge — keeps a registry plugin out of dormancy. + #[test] + fn dormancy_honors_gates_at_every_level() { + let dormant_if = |manifest: &str| { + let raw: RawPluginManifest = toml::from_str(manifest).unwrap(); + validate_manifest(raw, ManifestOrigin::Registry) + .unwrap() + .requires_use + }; + + assert!(dormant_if(r#"name = "p""#)); + assert!(!dormant_if(indoc! {r#" + name = "p" + depends-on = ["*"] + "#})); + assert!(!dormant_if(indoc! {r#" + name = "p" + + [[skills]] + depends-on = ["serde"] + source.path = "skills" + "#})); + assert!(!dormant_if(indoc! {r#" + name = "p" + + [[plugins]] + depends-on = ["serde"] + source.cargo = "serde-skills" + "#})); + } + + /// A dormant plugin activates only when a `[plugins] use` entry names it. + #[test] + fn dormant_plugin_applies_only_when_used() { + let manifest: RawPluginManifest = toml::from_str(r#"name = "gate-less""#).unwrap(); + let plugin = validate_manifest(manifest, ManifestOrigin::Registry).unwrap(); + assert!(plugin.requires_use); + + let deps: Vec = Vec::new(); + assert!(!plugin.applies(&mut ctx(&deps))); + assert!(!plugin.applies(&mut ctx(&deps).with_used_names(&["something-else"]))); + // Hyphen/underscore spellings name the same plugin. + assert!(plugin.applies(&mut ctx(&deps).with_used_names(&["gate_less"]))); } #[test] @@ -3270,11 +3423,10 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let mut parsed = ParsedPlugin { - path: PathBuf::from("/test/SYMPOSIUM.toml"), plugin, - source_dir: PathBuf::from("/test"), workspace_member: false, canonical: PackageId::new("test", "test", ANY_VERSION), }; @@ -3286,7 +3438,7 @@ mod tests { } #[test] - fn validate_source_dir_enforces_crates_requirement() { + fn validate_source_dir_warns_that_gateless_plugins_are_dormant() { use crate::test_utils::{File, instantiate_fixture}; let tmp = instantiate_fixture(&[ File( @@ -3317,13 +3469,16 @@ mod tests { let results = validate_source_dir(tmp.path()).unwrap(); assert_eq!(results.len(), 2); - let ok_count = results.iter().filter(|r| r.result.is_ok()).count(); - let err_count = results.iter().filter(|r| r.result.is_err()).count(); - assert_eq!(ok_count, 1, "Plugin with crates should pass"); - assert_eq!( - err_count, 1, - "Plugin without crates should fail TOML parsing" - ); + // Both manifests are valid; the gateless one is merely dormant, which + // is reported as a warning rather than a failure. + assert!(results.iter().all(|r| r.result.is_ok())); + let warnings: Vec<&str> = results + .iter() + .filter_map(|r| r.warning.as_deref()) + .collect(); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("no-crates-plugin"), "{warnings:?}"); + assert!(warnings[0].contains("dormant"), "{warnings:?}"); } #[test] @@ -4697,7 +4852,6 @@ mod tests { fn make_plugin_with_predicate(plugin_name: &str, predicate_name: &str) -> ParsedPlugin { ParsedPlugin { - path: std::path::PathBuf::from(format!("{plugin_name}.toml")), plugin: Plugin { name: plugin_name.to_string(), predicates: pred_set("*"), @@ -4720,8 +4874,8 @@ mod tests { args: vec![], }], chained: vec![], + requires_use: false, }, - source_dir: std::path::PathBuf::from("/test"), workspace_member: false, canonical: PackageId::new("test", plugin_name, ANY_VERSION), } diff --git a/src/pm/cargo.rs b/src/pm/cargo.rs deleted file mode 100644 index f566f6b7..00000000 --- a/src/pm/cargo.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! The cargo package manager: crates from the active workspace's dependency -//! graph, resolved by [`RustCrateFetch`] (path-dependency override, then the -//! cargo registry cache, then crates.io). - -use anyhow::Result; -use symposium_sdk::workspace::WorkspaceCrate; - -use crate::crate_sources::RustCrateFetch; -use crate::plugins::ParsedPlugin; - -use super::{ANY_VERSION, CARGO_PM, FetchedPackage, PackageId, PackageManager}; - -pub struct CargoPm; - -impl CargoPm { - /// Cargo id for a crate name and optional version requirement. - pub fn id_for(name: &str, version: Option<&str>) -> PackageId { - PackageId::new(CARGO_PM, name, version.unwrap_or(ANY_VERSION)) - } - - /// Resolve a crate to its plugin definition. - /// - /// Fetches the crate and 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 - /// (see [`load_crate_manifest`](crate::plugins::load_crate_manifest)). The - /// plugin is stamped with the resolved crate id as its - /// [`canonical`](ParsedPlugin::canonical) identity (which keys chained-plugin - /// cycle detection). A crate with no manifest sources still yields a plugin - /// whose only content is the default `skills/` group. - /// - /// Returns `None` only when the crate can't be fetched or the merged - /// manifest fails validation (both logged); the caller then contributes no - /// skills for this reference. - pub async fn load_plugin( - &self, - name: &str, - workspace: &[WorkspaceCrate], - ) -> Option { - let id = Self::id_for(name, None); - let fetched = match self.fetch(&id, workspace).await { - Ok(f) => f, - Err(e) => { - tracing::warn!(crate_name = %name, error = %e, "failed to fetch crate for plugin"); - return None; - } - }; - - let metadata = crate::crate_metadata::symposium_metadata(&fetched.root.join("Cargo.toml")) - .unwrap_or_else(|e| { - tracing::warn!( - crate_name = %name, - error = %e, - "failed to read crate Cargo.toml; ignoring [package.metadata.symposium]" - ); - None - }); - - let manifest_path = fetched.root.join("SYMPOSIUM.toml"); - let file = if manifest_path.is_file() { - match std::fs::read_to_string(&manifest_path) { - Ok(c) => Some(c), - Err(e) => { - tracing::warn!( - path = %manifest_path.display(), - error = %e, - "failed to read crate SYMPOSIUM.toml" - ); - None - } - } - } else { - None - }; - - let plugin = match crate::plugins::load_crate_manifest( - metadata, - file.as_deref(), - &fetched.id.name, - ) { - Ok(p) => p, - Err(e) => { - tracing::warn!( - crate_name = %name, - error = %e, - "failed to build crate plugin manifest" - ); - return None; - } - }; - - Some(ParsedPlugin { - path: manifest_path, - source_dir: fetched.root, - plugin, - workspace_member: false, - canonical: fetched.id, - }) - } -} - -impl PackageManager for CargoPm { - async fn fetch(&self, id: &PackageId, workspace: &[WorkspaceCrate]) -> Result { - debug_assert_eq!(id.pm, CARGO_PM); - let mut fetch = RustCrateFetch::new(&id.name, workspace); - if id.version != ANY_VERSION { - fetch = fetch.version(&id.version); - } - let result = fetch.fetch().await?; - Ok(FetchedPackage { - id: PackageId::new(CARGO_PM, result.name, result.version), - root: result.path, - }) - } - - fn list_deps(&self, workspace: &[WorkspaceCrate]) -> Vec { - workspace - .iter() - .map(|c| PackageId::new(CARGO_PM, c.name.clone(), c.version.to_string())) - .collect() - } -} diff --git a/src/pm/cargo/mod.rs b/src/pm/cargo/mod.rs new file mode 100644 index 00000000..7b6dd7fc --- /dev/null +++ b/src/pm/cargo/mod.rs @@ -0,0 +1,320 @@ +//! The cargo package manager: crates from the active workspace's dependency +//! graph, resolved by [`RustCrateFetch`] (path-dependency override, then the +//! cargo registry cache, then crates.io). +//! +//! The [`workspace`] submodule owns the cargo-workspace resolution — the +//! `cargo metadata` invocation, its cache, and the [`WorkspaceCrate`] / +//! [`WorkspaceDeps`] types — since that is cargo's ecosystem, not a generic +//! concern. A [`CargoPm`] *holds* its [`WorkspaceDeps`] resolver (as an +//! [`Arc`], so several instances share one lazily-run, cached `cargo metadata`) +//! and drives it (`self.workspace.crates()`). + +use std::sync::Arc; + +use anyhow::Result; +use symposium_install::UpdateLevel; + +use crate::crate_sources::RustCrateFetch; +use crate::plugins::ParsedPlugin; + +pub mod workspace; +pub use workspace::{ + LoadedWorkspace, WorkspaceCrate, WorkspaceDeps, file_mtime, workspace_dir_name, +}; + +use super::{ANY_VERSION, CARGO_PM, FetchedPackage, PackageId, PackageManager, PluginInfo}; + +/// How many crates.io hits a search returns — enough to surface the crate a +/// user is looking for without flooding the report. +const SEARCH_PAGE_SIZE: u64 = 10; + +/// The cargo transport, bound to one workspace's [`WorkspaceDeps`] resolver. +/// +/// Holds the resolver as an [`Arc`] so the transport in a [`PmRegistry`] and any +/// ad-hoc [`CargoPm`] built for crate loading share one lazily-run, cached +/// `cargo metadata` — the in-process stand-in for a per-workspace PM process. +pub struct CargoPm { + workspace: Arc, +} + +impl CargoPm { + /// A transport resolving against `workspace`. + pub fn new(workspace: Arc) -> Self { + Self { workspace } + } + + /// Cargo id for a crate name and optional version requirement. + pub fn id_for(name: &str, version: Option<&str>) -> PackageId { + PackageId::new(CARGO_PM, name, version.unwrap_or(ANY_VERSION)) + } + + /// Build a first-class [`ParsedPlugin`] from an already-fetched crate + /// source, layering its manifest sources — `[package.metadata.symposium]` + /// in `Cargo.toml` and a `SYMPOSIUM.toml` at the root — over the crate + /// defaults (see + /// [`load_crate_manifest`](crate::plugins::load_crate_manifest)) and + /// resolving its `source.path` groups to absolute directories. The plugin is + /// stamped with the resolved crate id as its + /// [`canonical`](ParsedPlugin::canonical) identity. A crate with no manifest + /// sources still yields a plugin whose only content is the default `skills/` + /// group. + /// + /// `None` only when the merged manifest fails validation (logged). + fn build_from_fetched(&self, fetched: FetchedPackage) -> Option { + let name = &fetched.id.name; + let metadata = crate::crate_metadata::symposium_metadata(&fetched.root.join("Cargo.toml")) + .unwrap_or_else(|e| { + tracing::warn!( + crate_name = %name, + error = %e, + "failed to read crate Cargo.toml; ignoring [package.metadata.symposium]" + ); + None + }); + + let manifest_path = fetched.root.join("SYMPOSIUM.toml"); + let file = if manifest_path.is_file() { + match std::fs::read_to_string(&manifest_path) { + Ok(c) => Some(c), + Err(e) => { + tracing::warn!( + path = %manifest_path.display(), + error = %e, + "failed to read crate SYMPOSIUM.toml" + ); + None + } + } + } else { + None + }; + + let mut plugin = match crate::plugins::load_crate_manifest( + metadata, + file.as_deref(), + &fetched.id.name, + ) { + Ok(p) => p, + Err(e) => { + tracing::warn!( + crate_name = %name, + error = %e, + "failed to build crate plugin manifest" + ); + return None; + } + }; + + // 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); + + Some(ParsedPlugin { + plugin, + workspace_member: false, + canonical: fetched.id, + }) + } +} + +/// 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. +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 matches!( + crate::crate_metadata::symposium_metadata(&dir.join("Cargo.toml")), + Ok(Some(_)) + ) { + return Some("embedded plugin ([package.metadata.symposium])"); + } + contains_skill_md(&dir.join(crate::plugins::CRATE_DEFAULT_SKILLS_PATH)) + .then_some("embedded skills (skills/)") +} + +/// Is there a `SKILL.md` anywhere under `dir`? +fn contains_skill_md(dir: &std::path::Path) -> bool { + let Ok(entries) = std::fs::read_dir(dir) else { + return false; + }; + entries.flatten().any(|entry| { + let path = entry.path(); + if path.is_dir() { + contains_skill_md(&path) + } else { + path.file_name().is_some_and(|f| f == "SKILL.md") + } + }) +} + +#[async_trait::async_trait] +impl PackageManager for CargoPm { + fn name(&self) -> &str { + CARGO_PM + } + + /// The plugins embedded in the workspace's dependencies: every dep in + /// `deps` whose source tree embeds plugin content, built into a full + /// `ParsedPlugin`. Whether each is *trusted* (may activate without consent) + /// is the caller's decision — the cargo transport is marked untrusted. + /// + /// Each dependency is fetched cache-only ([`UpdateLevel::None`]) to locate + /// its source, then inspected. A workspace dependency resolves into the + /// source `cargo metadata` already extracted — no probe, no network — so + /// registry dependencies are surfaced exactly like path ones. A dependency + /// whose source can't be served from cache is skipped. + async fn active_plugins(&self, deps: &[PackageId]) -> Vec { + let mut out = Vec::new(); + for id in deps.iter().filter(|id| id.pm == CARGO_PM) { + // Fetch by name only: the concrete version in `id` would make + // `fetch` treat it as an explicit `--version` and probe, bypassing + // the workspace-source shortcut. + let fetched = match self + .fetch(&Self::id_for(&id.name, None), UpdateLevel::None) + .await + { + Ok(f) => f, + Err(e) => { + tracing::debug!(id = %id, error = %e, "cannot serve dependency source from cache; skipping"); + continue; + } + }; + // Only surface dependencies that actually embed plugin content. + if embedded_plugin_kind(&fetched.root).is_some() { + out.extend(self.build_from_fetched(fetched)); + } + } + out + } + + /// Resolve a specific crate id to its plugin (a chained reference or an + /// enabled crate). Unlike `active_plugins`, this loads the named crate + /// whatever it embeds — any fetchable crate yields at least the default + /// `skills/` plugin. Fetched cache-only. + async fn load_plugin(&self, id: &PackageId) -> Vec { + let fetched = match self.fetch(id, UpdateLevel::None).await { + Ok(f) => f, + Err(e) => { + tracing::warn!(id = %id, error = %e, "failed to fetch crate for plugin"); + return Vec::new(); + } + }; + self.build_from_fetched(fetched).into_iter().collect() + } + + /// Search crates.io for crates matching `query`. + /// + /// Name-based, like `cargo search`: the results are *candidate* crates — + /// whether one actually carries plugin content is only known once it is + /// fetched (any fetchable crate yields at least a default `skills/` plugin). + /// So this lets `cargo agents use ` name a crate the workspace + /// doesn't depend on; the fetch/load step decides what it contributes. + async fn search(&self, query: &str) -> Result> { + let client = crates_io_api::AsyncClient::new( + "symposium (https://github.com/symposium-dev/symposium)", + std::time::Duration::from_millis(1000), + )?; + let cq = crates_io_api::CratesQuery::builder() + .search(query) + .page_size(SEARCH_PAGE_SIZE) + .build(); + let page = client.crates(cq).await?; + Ok(page + .crates + .into_iter() + .map(|c| PluginInfo { + id: PackageId::new(CARGO_PM, c.name, c.max_version), + description: c.description, + }) + .collect()) + } + + async fn fetch(&self, id: &PackageId, _update: UpdateLevel) -> Result { + debug_assert_eq!(id.pm, CARGO_PM); + // `crates()` drives the lazy `cargo metadata` resolution — the cargo PM + // owns the call, resolving against its own workspace. + let mut fetch = RustCrateFetch::new(&id.name, self.workspace.crates()); + if id.version != ANY_VERSION { + fetch = fetch.version(&id.version); + } + let result = fetch.fetch().await?; + Ok(FetchedPackage { + id: PackageId::new(CARGO_PM, result.name, result.version), + root: result.path, + }) + } + + async fn list_deps(&self) -> Result> { + Ok(self + .workspace + .crates() + .iter() + .map(|c| PackageId::new(CARGO_PM, c.name.clone(), c.version.to_string())) + .collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pm::WorkspaceCrate; + use std::path::PathBuf; + + /// A path dependency: `source_dir` defaults to its local path. + fn path_dep(name: &str, dir: PathBuf) -> WorkspaceCrate { + WorkspaceCrate::new(name.to_string(), semver::Version::new(1, 0, 0), Some(dir)) + } + + /// A registry dependency whose extracted source `cargo metadata` located — + /// no local `path`, but a known `source_dir` (as populated in production). + fn registry_dep(name: &str, source_dir: PathBuf) -> WorkspaceCrate { + WorkspaceCrate::new(name.to_string(), semver::Version::new(1, 0, 0), None) + .with_source_dir(Some(source_dir)) + } + + #[tokio::test] + async fn offers_dependencies_whose_sources_embed_plugin_content() { + let tmp = tempfile::tempdir().unwrap(); + + let with_skills = tmp.path().join("with-skills"); + std::fs::create_dir_all(with_skills.join("skills/guidance")).unwrap(); + std::fs::write(with_skills.join("skills/guidance/SKILL.md"), "").unwrap(); + + // A *registry* dependency (no path) with an extracted source that + // embeds a manifest — surfaced now that `active_plugins` fetches. + let registry_embedded = tmp.path().join("registry-embedded"); + std::fs::create_dir_all(®istry_embedded).unwrap(); + std::fs::write(registry_embedded.join("SYMPOSIUM.toml"), "").unwrap(); + + let plain = tmp.path().join("plain"); + std::fs::create_dir_all(plain.join("src")).unwrap(); + + let crates = vec![ + path_dep("with-skills", with_skills), + registry_dep("registry-embedded", registry_embedded), + path_dep("plain", plain), + ]; + let deps: Vec = crates + .iter() + .map(|c| PackageId::new(CARGO_PM, &c.name, c.version.to_string())) + .collect(); + let pm = CargoPm::new(crate::pm::WorkspaceDeps::fixture( + tmp.path().to_path_buf(), + crates, + )); + + let active = pm.active_plugins(&deps).await; + let got: Vec<&str> = active.iter().map(|p| p.canonical.name.as_str()).collect(); + // `plain` embeds nothing, so it is not surfaced. + assert_eq!(got, vec!["with-skills", "registry-embedded"]); + assert!(active.iter().all(|p| p.canonical.pm == CARGO_PM)); + // The default `skills/` group resolved to an absolute directory. + assert!(active[0].plugin.skills.iter().any(|g| matches!( + &g.source, + crate::plugins::PluginSource::Path(d) if d.is_absolute() + ))); + } +} diff --git a/symposium-sdk/src/workspace.rs b/src/pm/cargo/workspace.rs similarity index 58% rename from symposium-sdk/src/workspace.rs rename to src/pm/cargo/workspace.rs index df9bf411..20cdc9a1 100644 --- a/symposium-sdk/src/workspace.rs +++ b/src/pm/cargo/workspace.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use std::time::SystemTime; use std::{fmt::Write as _, fs}; @@ -21,20 +21,37 @@ pub struct WorkspaceCrate { pub name: String, /// The resolved version. pub version: semver::Version, - /// Local source path for path dependencies. - /// `None` for registry crates. + /// Local source path for path dependencies (unpublished, so `fetch` must + /// resolve them locally). `None` for registry crates. #[serde(default, skip_serializing_if = "Option::is_none")] pub path: Option, + /// The crate's extracted source directory, from `cargo metadata`'s + /// `manifest_path`. Populated for *every* resolved dependency — registry + /// crates included, since `cargo metadata` already extracted them — so the + /// source can be inspected or fetched without a fresh cargo probe. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_dir: Option, } impl WorkspaceCrate { + /// A crate whose source lives at `path` (a path dependency), or a registry + /// crate when `path` is `None`. `source_dir` defaults to `path`; use + /// [`with_source_dir`](Self::with_source_dir) for a registry crate whose + /// extracted source is known. pub fn new(name: String, version: semver::Version, path: Option) -> Self { Self { name, version, + source_dir: path.clone(), path, } } + + /// Set the extracted source directory (from `cargo metadata`). + pub fn with_source_dir(mut self, source_dir: Option) -> Self { + self.source_dir = source_dir; + self + } } /// The resolved workspace: root path + dependency list + member directories. @@ -60,18 +77,19 @@ struct DiskCache { members: Vec, } -/// In-process cache for workspace dependency resolution. +/// Lazy, cached workspace dependency resolver. /// -/// First call to `load()` checks the disk cache (keyed on `Cargo.lock` mtime); -/// on miss it runs `cargo metadata` (expensive) and writes through to disk. -/// Subsequent in-process calls return the cached Arc directly. +/// The first `load()` checks the disk cache (keyed on `Cargo.lock` mtime); on +/// miss it runs `cargo metadata` (expensive) and writes through to disk. The +/// result is memoized in a [`OnceLock`], so resolution happens at most once per +/// instance and every method reads through a shared `&self` — which lets a +/// single resolver be shared (held as an [`Arc`] by a +/// [`CargoPm`](crate::pm::CargoPm), and read directly by core code that needs +/// the workspace root or members) rather than each caller resolving its own. pub struct WorkspaceDeps { cwd: PathBuf, dirs: crate::dirs::SymposiumDirs, - /// Lazily resolved workspace-specific cache dir. `Some(Some(..))` = resolved, - /// `Some(None)` = resolved to "not in a workspace", `None` = not yet resolved. - resolved_cache_dir: Option>, - cached: Option>, + cached: OnceLock>>, } impl WorkspaceDeps { @@ -79,8 +97,39 @@ impl WorkspaceDeps { Self { cwd: cwd.into(), dirs: dirs.clone(), - resolved_cache_dir: None, - cached: None, + cached: OnceLock::new(), + } + } + + /// A pre-resolved resolver for tests: skips `cargo metadata` and returns + /// exactly `crates` (rooted at `root`, no members). + #[cfg(test)] + pub(crate) fn fixture(root: impl Into, crates: Vec) -> Arc { + let root = root.into(); + let cached = OnceLock::new(); + let _ = cached.set(Some(Arc::new(LoadedWorkspace { + root: root.clone(), + crates, + members: Vec::new(), + }))); + Arc::new(Self { + cwd: root, + dirs: crate::dirs::SymposiumDirs::new(PathBuf::new(), PathBuf::new(), None), + cached, + }) + } + + /// A resolver pre-set to "no workspace" — it never runs `cargo metadata`. + /// Backs [`detached_managers`](crate::config::Symposium::detached_managers) + /// for workspace-independent operations (registry listing, crates.io + /// search). + pub fn detached() -> Self { + let cached = OnceLock::new(); + let _ = cached.set(None); + Self { + cwd: PathBuf::new(), + dirs: crate::dirs::SymposiumDirs::new(PathBuf::new(), PathBuf::new(), None), + cached, } } @@ -91,50 +140,45 @@ impl WorkspaceDeps { /// Load (or return cached) workspace metadata. /// Returns `None` if not inside a Cargo workspace. - pub fn load(&mut self) -> Option<&Arc> { - if self.cached.is_some() { - return self.cached.as_ref(); - } - - // Phase 2: try disk cache first. - if let Some(loaded) = self.try_disk_cache() { - self.cached = Some(Arc::new(loaded)); - return self.cached.as_ref(); - } - - // Cache miss: run cargo metadata. - let loaded = load_workspace(&self.cwd, self.dirs.cargo_override.as_deref())?; - - // Write through to disk cache. - self.write_disk_cache(&loaded); - - self.cached = Some(Arc::new(loaded)); - self.cached.as_ref() + pub fn load(&self) -> Option<&Arc> { + self.cached.get_or_init(|| self.resolve()).as_ref() } /// Convenience: workspace root, or `None` if not in a workspace. - pub fn workspace_root(&mut self) -> Option<&Path> { + pub fn workspace_root(&self) -> Option<&Path> { self.load().map(|w| w.root.as_path()) } /// Convenience: crate list (empty slice if not in a workspace). - pub fn crates(&mut self) -> &[WorkspaceCrate] { + pub fn crates(&self) -> &[WorkspaceCrate] { match self.load() { Some(w) => &w.crates, None => &[], } } - /// Resolve the workspace-specific cache directory (at most once per instance). - /// Uses `cargo locate-project --workspace` (~10ms) on first call. - fn resolve_workspace_cache_dir(&mut self) -> Option<&Path> { - if self.resolved_cache_dir.is_none() { - self.resolved_cache_dir = Some(self.compute_workspace_cache_dir()); + /// The one-time resolution: disk cache, then `cargo metadata` on miss. + fn resolve(&self) -> Option> { + let cache_dir = self.workspace_cache_dir(); + + if let Some(dir) = &cache_dir + && let Some(loaded) = try_disk_cache(dir) + { + return Some(Arc::new(loaded)); + } + + let loaded = load_workspace(&self.cwd, self.dirs.cargo_override.as_deref())?; + + if let Some(dir) = &cache_dir { + write_disk_cache(dir, &loaded); } - self.resolved_cache_dir.as_ref().unwrap().as_deref() + + Some(Arc::new(loaded)) } - fn compute_workspace_cache_dir(&self) -> Option { + /// The workspace-specific cache directory, via `cargo locate-project + /// --workspace` (~10ms, no dep resolution). `None` when not in a workspace. + fn workspace_cache_dir(&self) -> Option { let root = locate_workspace_root(&self.cwd, self.dirs.cargo_override.as_deref())?; let canonical = fs::canonicalize(&root).unwrap_or(root); Some( @@ -144,49 +188,45 @@ impl WorkspaceDeps { .join(workspace_dir_name(&canonical)), ) } +} - fn try_disk_cache(&mut self) -> Option { - let ws_cache_dir = self.resolve_workspace_cache_dir()?.to_path_buf(); - let cache_file = ws_cache_dir.join("workspace-deps.json"); - let contents = fs::read_to_string(&cache_file).ok()?; - let cached: DiskCache = serde_json::from_str(&contents).ok()?; - - // Validate: Cargo.lock mtime must match. - let lock_path = cached.root.join("Cargo.lock"); - let current_mtime = file_mtime(&lock_path)?; - if current_mtime != cached.lock_mtime { - return None; - } +fn try_disk_cache(ws_cache_dir: &Path) -> Option { + let cache_file = ws_cache_dir.join("workspace-deps.json"); + let contents = fs::read_to_string(&cache_file).ok()?; + let cached: DiskCache = serde_json::from_str(&contents).ok()?; - Some(LoadedWorkspace { - root: cached.root, - crates: cached.crates, - members: cached.members, - }) + // Validate: Cargo.lock mtime must match. + let lock_path = cached.root.join("Cargo.lock"); + let current_mtime = file_mtime(&lock_path)?; + if current_mtime != cached.lock_mtime { + return None; } - fn write_disk_cache(&self, loaded: &LoadedWorkspace) { - let Some(Some(ws_cache_dir)) = &self.resolved_cache_dir else { - return; - }; - let lock_path = loaded.root.join("Cargo.lock"); - let Some(mtime) = file_mtime(&lock_path) else { - return; - }; - - let disk = DiskCache { - lock_mtime: mtime, - root: loaded.root.clone(), - crates: loaded.crates.clone(), - members: loaded.members.clone(), - }; - - let _ = fs::create_dir_all(ws_cache_dir); - let _ = fs::write( - ws_cache_dir.join("workspace-deps.json"), - serde_json::to_string_pretty(&disk).unwrap_or_default(), - ); - } + Some(LoadedWorkspace { + root: cached.root, + crates: cached.crates, + members: cached.members, + }) +} + +fn write_disk_cache(ws_cache_dir: &Path, loaded: &LoadedWorkspace) { + let lock_path = loaded.root.join("Cargo.lock"); + let Some(mtime) = file_mtime(&lock_path) else { + return; + }; + + let disk = DiskCache { + lock_mtime: mtime, + root: loaded.root.clone(), + crates: loaded.crates.clone(), + members: loaded.members.clone(), + }; + + let _ = fs::create_dir_all(ws_cache_dir); + let _ = fs::write( + ws_cache_dir.join("workspace-deps.json"), + serde_json::to_string_pretty(&disk).unwrap_or_default(), + ); } /// Find workspace root via `cargo locate-project --workspace`. @@ -287,6 +327,7 @@ fn load_workspace(cwd: &Path, cargo_path: Option<&Path>) -> Option, + git_url: impl Into, + auto_update: bool, + ctx: symposium_install::InstallContext, + ) -> Option { + let name = name.into(); + let git_url = git_url.into(); + let content_dir = + GitCacheManager::new(&ctx, REGISTRY_CACHE_SUBDIR).cache_path_for_url(&git_url)?; + let inner = PathPm::new(name.clone(), content_dir); + Some(Self { + name, + git_url, + auto_update, + ctx, + inner, + }) + } +} + +#[async_trait::async_trait] +impl PackageManager for GitPm { + fn name(&self) -> &str { + &self.name + } + + async fn active_plugins(&self, deps: &[PackageId]) -> Vec { + self.inner.active_plugins(deps).await + } + + async fn load_plugin(&self, id: &PackageId) -> Vec { + self.inner.load_plugin(id).await + } + + async fn list_deps(&self) -> Result> { + self.inner.list_deps().await + } + + async fn search(&self, query: &str) -> Result> { + self.inner.search(query).await + } + + async fn fetch(&self, id: &PackageId, update: UpdateLevel) -> Result { + self.inner.fetch(id, update).await + } + + /// Pull the repository. Skipped (returns `false`) when auto-update is off + /// and the caller did not `force`; otherwise fetches at `update` and + /// returns `true`. + async fn refresh(&self, update: UpdateLevel, force: bool) -> Result { + if !force && !self.auto_update { + tracing::debug!(registry = %self.name, "skipping refresh (auto-update disabled)"); + return Ok(false); + } + GitCacheManager::new(&self.ctx, REGISTRY_CACHE_SUBDIR) + .fetch_url(&self.git_url, update) + .await?; + Ok(true) + } + + fn registry_source(&self) -> Option { + Some(RegistrySource::Git { + url: self.git_url.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_resolves_a_cache_dir_and_reports_git_source() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = symposium_install::InstallContext::new(tmp.path().to_path_buf()); + let pm = GitPm::new( + "symposium-recommendations", + "https://github.com/symposium-dev/recommendations", + true, + ctx, + ) + .expect("a well-formed URL resolves to a cache path"); + assert_eq!(pm.name(), "symposium-recommendations"); + match pm.registry_source() { + Some(RegistrySource::Git { url }) => { + assert_eq!(url, "https://github.com/symposium-dev/recommendations"); + } + _ => panic!("expected a git registry source"), + } + } +} diff --git a/src/pm/layout.rs b/src/pm/layout.rs new file mode 100644 index 00000000..85a93ce0 --- /dev/null +++ b/src/pm/layout.rs @@ -0,0 +1,141 @@ +//! Registry directory layout: how a registry source's directory tree maps to +//! plugin-bearing entries. +//! +//! A registry is a collection of *entries* — one per plugin or standalone +//! skill directory. Which directories constitute entries is packaging +//! convention and lives here, in the package-manager layer; *interpreting* +//! an entry's manifest (the TOML schema, predicates, gating) stays in +//! [`crate::plugins`]. +//! +//! The flat layout defined here — every directory containing a +//! [`MANIFEST_FILE`] or [`SKILL_FILE`] is an entry, discovered recursively, +//! and a claimed directory is not recursed into — backs [`PathPm`](super::PathPm), +//! the single registry-instance PM. An entry declares which dependencies +//! activate it through its own manifest `depends-on`, evaluated when the +//! plugin is loaded; the layout carries no dependency information itself. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +/// Plugin manifest filename that marks a directory as a plugin entry. +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"; + +/// What kind of entry a directory is. +#[derive(Debug)] +pub enum EntryKind { + /// A plugin entry; carries the path to its `SYMPOSIUM.toml`. + Plugin(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`]. +pub fn classify(dir: &Path) -> Option { + let manifest = dir.join(MANIFEST_FILE); + if manifest.is_file() { + return Some(EntryKind::Plugin(manifest)); + } + let skill_md = dir.join(SKILL_FILE); + if skill_md.is_file() { + return Some(EntryKind::Skill(skill_md)); + } + None +} + +/// One entry in a registry source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RegistryEntry { + /// The entry directory, relative to the source root. + pub subpath: PathBuf, +} + +/// Enumerate the entries in the flat-layout registry source rooted at `root`, +/// sorted by subpath. A missing root yields no entries; a root that is itself +/// an entry is an error (a source should *contain* plugins, not be one). +pub fn enumerate(root: &Path) -> Result> { + match classify(root) { + Some(EntryKind::Plugin(_)) => anyhow::bail!( + "plugin source root contains SYMPOSIUM.toml — 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() + ), + None => {} + } + let mut entries = Vec::new(); + walk(root, Path::new(""), &mut entries); + entries.sort_by(|a, b| a.subpath.cmp(&b.subpath)); + Ok(entries) +} + +/// Recursively collect entry directories under `dir`, unsorted and without +/// the root guard. Subpaths are relative to `rel`. +pub(crate) fn walk(dir: &Path, rel: &Path, entries: &mut Vec) { + let Ok(read) = std::fs::read_dir(dir) else { + return; + }; + for entry in read.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let sub = rel.join(entry.file_name()); + if classify(&path).is_some() { + entries.push(RegistryEntry { subpath: sub }); + } else { + walk(&path, &sub, entries); + } + } +} + +/// A subpath as it appears in a package id: slash-separated, so ids are +/// stable across platforms. +pub(crate) fn subpath_key(subpath: &Path) -> String { + subpath + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") +} + +#[cfg(test)] +mod tests { + use super::*; + + pub(crate) fn touch(path: &Path) { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "").unwrap(); + } + + #[test] + fn flat_layout_finds_nested_entries_with_pruning() { + let tmp = tempfile::tempdir().unwrap(); + touch(&tmp.path().join("plug/SYMPOSIUM.toml")); + // Claimed as a plugin — the nested skill is not a separate entry. + touch(&tmp.path().join("plug/inner/SKILL.md")); + touch(&tmp.path().join("group/deep/skill/SKILL.md")); + + let entries = enumerate(tmp.path()).unwrap(); + let subpaths: Vec<_> = entries.iter().map(|e| e.subpath.clone()).collect(); + assert_eq!( + subpaths, + vec![PathBuf::from("group/deep/skill"), PathBuf::from("plug")] + ); + + // Missing root: no entries, no error. + assert!(enumerate(&tmp.path().join("nope")).unwrap().is_empty()); + } + + #[test] + fn flat_layout_rejects_root_that_is_an_entry() { + let tmp = tempfile::tempdir().unwrap(); + touch(&tmp.path().join("SKILL.md")); + let err = enumerate(tmp.path()).unwrap_err(); + assert!(err.to_string().contains("not be a skill itself")); + } +} diff --git a/src/pm/mod.rs b/src/pm/mod.rs index b8ce79aa..a7426815 100644 --- a/src/pm/mod.rs +++ b/src/pm/mod.rs @@ -3,19 +3,43 @@ //! //! A [`PackageId`] names a package as a `(pm, name, version)` tuple, and a //! [`PackageManager`] resolves ids of its ecosystem to content on disk. -//! Cargo is the only package manager today, and both of its existing -//! operations route through the seam: `fetch` (callers that used to -//! construct a [`RustCrateFetch`](crate::crate_sources::RustCrateFetch) -//! directly go through [`CargoPm`] instead) and `list_deps` (the workspace -//! dependency list that predicate evaluation consumes). +//! +//! A `PackageManager` value is an *instance*, not just an ecosystem. A +//! **transport** ([`CargoPm`]) can `fetch` any id of its ecosystem, because the +//! id carries the source; a **registry instance** ([`PathPm`]) fronts one +//! configured source and enumerates the packages it contains. [`PmRegistry`] +//! holds them as one flat set: an id is dispatched to the instance whose +//! [`PackageManager::name`] matches its [`PackageId::pm`], and plugin loading +//! ([`active_plugins`](PackageManager::active_plugins) / +//! [`load_plugin`](PackageManager::load_plugin) / `search`) iterates every +//! instance. +//! +//! A registry instance's [`PackageManager::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. Registry instances resolve their own ids, so those ids are never routed +//! through the ecosystem transports. +//! +//! In-process for now — when PMs move out of process, [`PmRegistry`] becomes +//! the seam that spawns and talks to them. use std::path::PathBuf; +use std::sync::Arc; use anyhow::Result; -use symposium_sdk::workspace::WorkspaceCrate; +use symposium_install::UpdateLevel; + +use crate::plugins::ParsedPlugin; mod cargo; -pub use cargo::CargoPm; +mod git; +pub mod layout; +mod path; +pub use cargo::{ + CargoPm, LoadedWorkspace, WorkspaceCrate, WorkspaceDeps, file_mtime, workspace_dir_name, +}; +pub use git::GitPm; +pub use path::PathPm; /// The `pm` component of cargo package ids. pub const CARGO_PM: &str = "cargo"; @@ -45,6 +69,11 @@ impl PackageId { version: version.into(), } } + + /// An id with no version requirement — the PM resolves it at fetch. + pub fn any_version(pm: impl Into, name: impl Into) -> Self { + Self::new(pm, name, ANY_VERSION) + } } impl std::fmt::Display for PackageId { @@ -53,6 +82,27 @@ impl std::fmt::Display for PackageId { } } +/// What [`search`](PackageManager::search) knows about a candidate package +/// before its content is on disk: its identity and an optional description. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginInfo { + /// Canonical identity. The version component may still be a requirement + /// that fetch canonicalizes. + pub id: PackageId, + /// Human-oriented description when the PM's registry provides one. + pub description: Option, +} + +impl PluginInfo { + /// An info with just the identity. + pub fn from_id(id: PackageId) -> Self { + Self { + id, + description: None, + } + } +} + /// A fetched package: the exact id it resolved to, plus the directory /// holding its content. #[derive(Debug, Clone)] @@ -61,18 +111,197 @@ pub struct FetchedPackage { pub root: PathBuf, } -/// The package-manager interface. -// Auto trait bounds can't be named on an `async fn` trait method; fine here -// because nothing holds the future across threads, and a `Send` bound would -// be premature with a single in-process implementation. -#[allow(async_fn_in_trait)] +/// The operations every package manager implements (per the registry-centric +/// plugin distribution RFD). +/// +/// A PM is self-contained: it holds whatever it needs to resolve its own +/// ecosystem ([`CargoPm`] owns an [`Arc`], [`PathPm`] owns its +/// directory), so operations take no ambient context. This mirrors the +/// out-of-process shape — a PM spawned for a workspace answers RPC calls from +/// its own state, with nothing workspace-shaped threaded per call. +/// +/// Loading has two forms. [`active_plugins`](Self::active_plugins) is what the +/// PM activates for the workspace's dependency set (a registry lists its +/// entries; the cargo transport surfaces dependency-embedded plugins); +/// [`load_plugin`](Self::load_plugin) resolves a *specific* id named elsewhere +/// (a `[[plugins]]` chained reference, an explicitly enabled crate). Both return +/// fully-resolved [`ParsedPlugin`]s (absolute skill dirs) and are best-effort — +/// failures are logged and dropped, not surfaced, so one bad plugin never +/// aborts a sync or hook. +#[async_trait::async_trait] pub trait PackageManager { - /// Resolve `id` and return its content directory. - /// - /// `workspace` supplies the active workspace's dependency resolution: - /// path-dependency overrides and version pins. - async fn fetch(&self, id: &PackageId, workspace: &[WorkspaceCrate]) -> Result; - - /// The workspace's dependencies, as ids of this PM's ecosystem. - fn list_deps(&self, workspace: &[WorkspaceCrate]) -> Vec; + /// The PM's registry name — the `pm` component of every id it owns. For + /// an ecosystem transport this is the ecosystem (`cargo`); for a registry + /// instance it is the configured registry's name. + fn name(&self) -> &str; + + /// The plugins this PM activates for the workspace's dependency set. A + /// registry lists its own entries (deps ignored); the cargo transport + /// surfaces the plugins its dependencies embed. Whether a dependency-embedded + /// plugin is *trusted* is the caller's decision — see [`PmInstance::trusted`]. + async fn active_plugins(&self, deps: &[PackageId]) -> Vec; + + /// The plugin(s) a specific id maps to — zero, one, or many. Used for + /// `[[plugins]]` chained references and explicitly enabled crates. + async fn load_plugin(&self, id: &PackageId) -> Vec; + + /// The package ids the current workspace depends on. Empty for PMs with no + /// workspace notion. + async fn list_deps(&self) -> Result>; + + /// Find packages matching a partial query (backs `use` / `search`). PMs + /// without a searchable registry return an empty list. + async fn search(&self, query: &str) -> Result>; + + /// Acquire a package's source content, canonicalizing the id's version. + /// `update` controls how aggressively an already-cached package is refreshed. + async fn fetch(&self, id: &PackageId, update: UpdateLevel) -> Result; + + /// Refresh this PM's backing source — for a registry, pull the latest + /// content onto disk. `force` ignores the source's auto-update opt-out + /// (used by an explicit `plugin sync `). Returns whether a remote + /// source was actually refreshed, so callers can report what synced. The + /// default is a no-op: a PM whose content is already local (the cargo + /// transport, a path registry) has nothing to pull. + async fn refresh(&self, _update: UpdateLevel, _force: bool) -> Result { + Ok(false) + } + + /// How a registry instance's content is sourced, for `plugin list` + /// display. `None` for the cargo transport, which is not a configured + /// registry. + fn registry_source(&self) -> Option { + None + } +} + +/// Where a registry instance's content comes from — the git-vs-path +/// distinction, made visible on the PM itself rather than inferred elsewhere. +pub enum RegistrySource { + /// A git repository, fetched into the plugin-source cache. + Git { url: String }, + /// A local directory. + Path { dir: PathBuf }, +} + +/// A package-manager instance: its attribution name (the config registry name, +/// or `cargo` for the transport — the `pm` component of every id it owns), a +/// trust marker, and the PM itself. +pub struct PmInstance { + pub name: String, + /// Whether this instance's [`active_plugins`](PackageManager::active_plugins) + /// are trust roots. Registries and the workspace are trusted; the cargo + /// transport is not, since its `active_plugins` are the plugins *embedded in + /// dependencies*, which run only with the user's consent. + pub trusted: bool, + pub pm: Box, +} + +/// The active set of package-manager instances — one flat collection, the cargo +/// transport alongside one instance per configured registry. Ids are dispatched +/// by their `pm` component ([`PackageId::pm`]) to the instance that owns them. +pub struct PmRegistry { + instances: Vec, +} + +impl PmRegistry { + pub fn new(instances: Vec) -> Self { + Self { instances } + } + + /// Every instance, in order (cargo transport first, then registries). + pub fn instances(&self) -> impl Iterator { + self.instances.iter() + } + + /// The instance owning the named ecosystem/registry. + fn owner(&self, pm: &str, id: &PackageId) -> Result<&(dyn PackageManager + Send + Sync)> { + self.instances + .iter() + .find(|inst| inst.pm.name() == pm) + .map(|inst| inst.pm.as_ref()) + .ok_or_else(|| anyhow::anyhow!("unknown package manager `{pm}` in package id `{id}`")) + } + + /// Fetch a package via the instance named in its id. + pub async fn fetch(&self, id: &PackageId, update: UpdateLevel) -> Result { + self.owner(&id.pm, id)?.fetch(id, update).await + } + + /// Union of `list_deps` across the instances — the workspace's full + /// dependency set for discovery and `depends-on` predicate evaluation. + pub async fn list_deps(&self) -> Result> { + let mut deps = Vec::new(); + for inst in &self.instances { + deps.extend(inst.pm.list_deps().await?); + } + Ok(deps) + } + + /// Load the plugin(s) an id maps to, asking every instance. Any instance may + /// contribute a plugin relevant to the id, so this can return several. + pub async fn load_plugin(&self, id: &PackageId) -> Vec { + let mut out = Vec::new(); + for inst in &self.instances { + out.extend(inst.pm.load_plugin(id).await); + } + out + } + + /// Search every instance for packages matching `query`, tagged with the + /// instance's display name. A failing instance is skipped with a debug log + /// rather than failing the union. + pub async fn search(&self, query: &str) -> Vec<(String, PluginInfo)> { + let mut out = Vec::new(); + for inst in self.instances() { + match inst.pm.search(query).await { + Ok(infos) => out.extend(infos.into_iter().map(|i| (inst.name.clone(), i))), + Err(e) => { + tracing::debug!(instance = %inst.name, error = %e, "search failed, skipping"); + } + } + } + out + } +} + +/// The workspace's dependency set as package ids — every PM's `list_deps` +/// unioned. This is what `depends-on` predicates evaluate against +/// ([`crate::predicate::PredicateContext`]). Failures are logged and yield an +/// empty list so predicate evaluation degrades to "no deps" rather than +/// aborting the caller. +pub async fn workspace_dep_ids( + sym: &crate::config::Symposium, + deps: &Arc, +) -> Vec { + match sym.package_managers(deps).list_deps().await { + Ok(deps) => deps, + Err(e) => { + tracing::warn!(error = %e, "failed to list workspace dependencies"); + Vec::new() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn package_id_display_is_colon_tuple() { + let id = PackageId::new("cargo", "serde", "1.0.210"); + assert_eq!(id.to_string(), "cargo:serde:1.0.210"); + } + + #[tokio::test] + async fn registry_rejects_unknown_pm() { + let tmp = tempfile::tempdir().unwrap(); + let _ = tmp; + let id = PackageId::any_version("npm", "leftpad"); + let err = PmRegistry::new(vec![]) + .fetch(&id, UpdateLevel::None) + .await + .unwrap_err(); + assert!(err.to_string().contains("unknown package manager `npm`")); + } } diff --git a/src/pm/path.rs b/src/pm/path.rs new file mode 100644 index 00000000..b0fd4ab7 --- /dev/null +++ b/src/pm/path.rs @@ -0,0 +1,171 @@ +//! The path package manager: a registry instance fronting one local +//! directory. +//! +//! This is the ordinary plugin-source case — `~/.symposium/plugins/`, a +//! `[[registry]]` entry with a `path`, or the git cache directory a +//! [`GitPm`](crate::pm::GitPm) fetched into (a `GitPm` delegates its reads to +//! an inner `PathPm` over that cache dir, so once the content is on disk a git +//! registry is just a directory). +//! +//! Ids look like `(, , *)`: the `pm` component +//! is the configured registry's name — the same name plugins from it are +//! attributed to — and the name component locates the entry within the +//! source. The instance resolves its own ids against its directory. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use symposium_install::UpdateLevel; + +use super::{ + ANY_VERSION, FetchedPackage, PackageId, PackageManager, PluginInfo, RegistrySource, layout, +}; +use crate::plugins::ParsedPlugin; +use crate::report::ReportEvent; + +/// A configured path registry: one local directory whose tree is a +/// collection of plugin entries. +pub struct PathPm { + name: String, + dir: PathBuf, +} + +impl PathPm { + /// An instance named `name` fronting the registry in `dir`. + pub fn new(name: impl Into, dir: impl Into) -> Self { + Self { + name: name.into(), + dir: dir.into(), + } + } +} + +#[async_trait::async_trait] +impl PackageManager for PathPm { + fn name(&self) -> &str { + &self.name + } + + /// Every entry in the registry, loaded as a plugin. `deps` is ignored — a + /// local registry's contents don't vary with the workspace; whether each + /// plugin applies is decided later by its own predicates. A registry is a + /// trust root, so these activate without consent. + async fn active_plugins(&self, _deps: &[PackageId]) -> Vec { + let entries = match layout::enumerate(&self.dir) { + Ok(e) => e, + Err(e) => { + tracing::warn!(registry = %self.name, error = %e, "cannot list registry"); + return Vec::new(); + } + }; + let mut out = Vec::new(); + for entry in entries { + match crate::plugins::load_entry(&self.dir, &entry.subpath, &self.name) { + Some(Ok(p)) => out.push(p), + Some(Err(e)) => tracing::warn!( + report = %ReportEvent::Warning { + message: format!( + "skipping {}: {e:#}", + crate::output::display_path(&self.dir.join(&entry.subpath)) + ), + }, + ), + None => {} + } + } + out + } + + /// The entry an id names (`id.name` is the entry's subpath key). + async fn load_plugin(&self, id: &PackageId) -> Vec { + if id.pm != self.name { + return Vec::new(); + } + match crate::plugins::load_entry(&self.dir, Path::new(&id.name), &self.name) { + Some(Ok(p)) => vec![p], + Some(Err(e)) => { + tracing::warn!(registry = %self.name, id = %id, error = %e, "failed to load plugin"); + Vec::new() + } + None => Vec::new(), + } + } + + /// A local directory contributes no workspace dependencies. + async fn list_deps(&self) -> Result> { + Ok(Vec::new()) + } + + /// Substring match over the entries' subpath keys. Manifest names are the + /// plugin layer's to interpret, so this only sees directory names. + async fn search(&self, query: &str) -> Result> { + let entries = layout::enumerate(&self.dir).unwrap_or_default(); + Ok(entries + .into_iter() + .map(|entry| layout::subpath_key(&entry.subpath)) + .filter(|key| key.contains(query)) + .map(|key| PluginInfo::from_id(PackageId::new(&self.name, key, ANY_VERSION))) + .collect()) + } + + /// The entry directory an id names — path entries are their own cache. + async fn fetch(&self, id: &PackageId, _update: UpdateLevel) -> Result { + Ok(FetchedPackage { + id: id.clone(), + root: self.dir.join(&id.name), + }) + } + + fn registry_source(&self) -> Option { + Some(RegistrySource::Path { + dir: self.dir.clone(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn loads_one_plugin_per_entry() { + let tmp = tempfile::tempdir().unwrap(); + let source = tmp.path().join("registry"); + std::fs::create_dir_all(source.join("tools")).unwrap(); + std::fs::write(source.join("tools/SYMPOSIUM.toml"), "name = \"tools\"").unwrap(); + std::fs::create_dir_all(source.join("nested/style")).unwrap(); + std::fs::write( + source.join("nested/style/SKILL.md"), + "---\nname: style\ndescription: d\ndepends-on: serde\n---\nbody", + ) + .unwrap(); + + let pm = PathPm::new("user-plugins", &source); + let active = pm.active_plugins(&[]).await; + let mut names: Vec<&str> = active.iter().map(|p| p.plugin.name.as_str()).collect(); + names.sort(); + assert_eq!(names, vec!["style", "tools"]); + assert!(active.iter().all(|p| p.canonical.pm == "user-plugins")); + + // load_plugin by the entry's subpath key. + let one = pm + .load_plugin(&PackageId::new("user-plugins", "tools", ANY_VERSION)) + .await; + assert_eq!(one.len(), 1); + assert_eq!(one[0].plugin.name, "tools"); + + let hits = pm.search("too").await.unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].id.name, "tools"); + } + + #[tokio::test] + async fn fetch_returns_the_local_entry_directory() { + let tmp = tempfile::tempdir().unwrap(); + let pm = PathPm::new("local", tmp.path().join("registry")); + let id = PackageId::new("local", "tools", ANY_VERSION); + let fetched = pm.fetch(&id, UpdateLevel::None).await.unwrap(); + assert_eq!(fetched.root, tmp.path().join("registry/tools")); + assert_eq!(fetched.id, id); + } +} diff --git a/src/predicate.rs b/src/predicate.rs index 4091e34d..a79a73a1 100644 --- a/src/predicate.rs +++ b/src/predicate.rs @@ -58,6 +58,11 @@ pub struct PredicateContext<'a> { /// the loader stamps it per plugin (via `ParsedPlugin::applies`) before /// that plugin's predicate sets are evaluated. workspace_member: bool, + /// Plugin names enabled by the applicable `[plugins] use` entries, + /// normalized. A plugin with no gate of its own + /// ([`Plugin::requires_use`](crate::plugins::Plugin::requires_use)) is + /// dormant unless it is named here. + used_names: std::collections::HashSet, custom_entries: std::collections::HashMap, custom_cache: std::collections::HashMap<(String, String), CustomPredicateResult>, } @@ -67,6 +72,7 @@ impl<'a> PredicateContext<'a> { Self { deps, workspace_member: false, + used_names: std::collections::HashSet::new(), custom_entries: std::collections::HashMap::new(), custom_cache: std::collections::HashMap::new(), } @@ -77,13 +83,29 @@ impl<'a> PredicateContext<'a> { entries: std::collections::HashMap, ) -> Self { Self { - deps, - workspace_member: false, custom_entries: entries, - custom_cache: std::collections::HashMap::new(), + ..Self::new(deps) } } + /// Record the plugin names the applicable `[plugins] use` entries enable. + /// Matching is hyphen/underscore-insensitive, like every other name + /// comparison against user-typed config. + pub fn with_used_names>(mut self, names: &[S]) -> Self { + self.used_names.extend( + names + .iter() + .map(|n| crate::crate_sources::normalize_crate_name(n.as_ref())), + ); + self + } + + /// Is the named plugin enabled by a `use` entry in this context? + pub fn is_used(&self, plugin_name: &str) -> bool { + self.used_names + .contains(&crate::crate_sources::normalize_crate_name(plugin_name)) + } + /// Stamp whether the plugin about to be evaluated arrived via workspace /// membership. Call before evaluating each plugin's predicate sets; the /// value applies to all of that plugin's nested components (groups, diff --git a/src/report.rs b/src/report.rs index 4768478e..ceead0c0 100644 --- a/src/report.rs +++ b/src/report.rs @@ -123,6 +123,37 @@ pub enum ReportEvent { warning: Option, }, + // ── Enablement events (`search` / `use` / `status`) ────────────── + /// One `cargo agents search` hit. + SearchMatch { + /// The instance the hit came from: a configured registry's name, a + /// package-manager transport, or `(workspace)`. + origin: String, + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + }, + + /// A `[plugins] use` entry was recorded by `cargo agents use`. + PluginEnabled { name: String, global: bool }, + + /// A `[plugins] use` entry was dropped by `cargo agents use --remove`. + PluginRemoved { name: String, global: bool }, + + /// One line of the `cargo agents status` enablement report. + PluginStatus { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + version: Option, + /// Why the entry is in the state it is: its enablement root, or the + /// reason it will not load. + root: String, + /// `active`, `dormant`, `candidate`, or `declined`. + state: String, + }, + /// A provider was listed with its plugins. ProviderListed { name: String, @@ -264,6 +295,59 @@ impl ReportEvent { format!(" ✗ {path} ({item_kind}): {e}") } } + // The origin is carried for the JSON form; the human form is + // printed under a per-origin heading, so repeating it would be + // noise. + Self::SearchMatch { + origin: _, + name, + version, + description, + } => { + let mut line = format!(" {name}"); + if let Some(v) = version { + line.push_str(&format!(" {v}")); + } + if let Some(d) = description { + line.push_str(&format!("\n {d}")); + } + line + } + Self::PluginEnabled { name, global } => { + let scope = if *global { + "every workspace" + } else { + "this workspace" + }; + format!("✅ enabled {name} for {scope}") + } + Self::PluginRemoved { name, global } => { + let scope = if *global { + "every workspace" + } else { + "this workspace" + }; + format!("➖ removed the {name} enablement for {scope}") + } + Self::PluginStatus { + name, + version, + root, + state, + } => { + let marker = match state.as_str() { + "active" => "✅", + "dormant" => "💤", + "candidate" => "❓", + _ => "➖", + }; + let version = version + .as_deref() + .map(|v| format!(" {v}")) + .unwrap_or_default(); + format!("{marker} {name}{version} — {root}") + } + Self::ProviderListed { name, source_type, diff --git a/src/search_command.rs b/src/search_command.rs new file mode 100644 index 00000000..ae022d42 --- /dev/null +++ b/src/search_command.rs @@ -0,0 +1,107 @@ +//! `cargo agents search` — find plugins across every configured source. +//! +//! Two arms, in the order a user cares about: +//! +//! 1. **Already loaded** — plugin and standalone-skill names in the +//! [`PluginRegistry`](crate::plugins::PluginRegistry). A configured +//! registry is a trust root, so a hit here is available now, no `use` +//! needed (unless the plugin is dormant). +//! 2. **Offered by a package manager** — [`PmRegistry::search`] unions each +//! instance's search. A PM without a searchable registry returns an empty +//! list rather than an error, and an instance that fails outright is +//! skipped, so an offline crates.io never fails the command. +//! +//! Every hit is tagged with the instance name it came from. +//! +//! [`PmRegistry::search`]: crate::pm::PmRegistry::search + +use anyhow::Result; + +use crate::config::Symposium; +use crate::report::ReportEvent; + +/// One search hit, in display form. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SearchMatch { + /// The instance the hit came from: a configured registry's name, a + /// package-manager transport (`cargo`), or `(workspace)`. + pub origin: String, + pub name: String, + pub version: Option, + pub description: Option, +} + +/// Case-insensitive substring match — the same looseness `cargo search` has. +fn name_matches(name: &str, query: &str) -> bool { + name.to_lowercase().contains(&query.to_lowercase()) +} + +/// Collect matches from the loaded registry and from every package manager. +pub async fn find_matches(sym: &Symposium, query: &str) -> Vec { + let mut matches = Vec::new(); + + let registry = crate::plugins::load_registry(sym).await; + for parsed in ®istry.plugins { + if name_matches(&parsed.plugin.name, query) { + 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()), + }); + } + } + // Search is workspace-independent; a detached resolver stands in. + for (instance, info) in sym.detached_managers().search(query).await { + matches.push(SearchMatch { + origin: instance, + name: info.id.name.clone(), + version: Some(info.id.version.clone()), + description: info.description, + }); + } + + matches +} + +/// The `cargo agents search` entry point: report every match grouped by the +/// instance it came from, or a nothing-found message. +pub async fn search(sym: &Symposium, query: &str) -> Result<()> { + let matches = find_matches(sym, query).await; + if matches.is_empty() { + tracing::info!( + report = %ReportEvent::Info { + message: format!("no plugins matching `{query}` found"), + }, + ); + return Ok(()); + } + + // Group by origin, preserving the order origins were first seen (loaded + // registry first, then package managers in config order). + let mut origins: Vec<&str> = Vec::new(); + for m in &matches { + if !origins.contains(&m.origin.as_str()) { + origins.push(&m.origin); + } + } + for origin in origins { + tracing::info!( + report = %ReportEvent::Info { message: format!("from {origin}:") }, + ); + for m in matches.iter().filter(|m| m.origin == origin) { + tracing::info!( + report = %ReportEvent::SearchMatch { + origin: m.origin.clone(), + name: m.name.clone(), + version: m.version.clone(), + description: m.description.clone(), + }, + ); + } + } + Ok(()) +} diff --git a/src/skills.rs b/src/skills.rs index c98792fe..f07500a4 100644 --- a/src/skills.rs +++ b/src/skills.rs @@ -10,9 +10,8 @@ use anyhow::{Context, Result, bail}; use symposium_install::UpdateLevel; use crate::config::Symposium; -use crate::plugins::{ParsedPlugin, PluginRegistry, PluginSource, SkillGroup}; -use crate::pm::PackageManager as _; -use crate::predicate::{self, PredicateContext, PredicateSet}; +use crate::plugins::{ParsedPlugin, PluginSource, SkillGroup}; +use crate::predicate::{PredicateContext, PredicateSet}; fn source_display(source: &PluginSource) -> String { match source { @@ -21,6 +20,15 @@ fn source_display(source: &PluginSource) -> String { } } +/// The group's resolved source label: the string the package manager attached +/// to a `Path` source, or the derived form for a `Git` source. +fn group_source_label(group: &crate::plugins::SkillGroup) -> String { + group + .source_label + .clone() + .unwrap_or_else(|| source_display(&group.source)) +} + /// A parsed skill from a SKILL.md file. #[derive(Debug, Clone)] pub struct Skill { @@ -30,8 +38,6 @@ pub struct Skill { /// `any(depends-on(...))`) merged with `predicates`. ANDed with the plugin- and /// group-level sets. pub predicates: PredicateSet, - /// The body content (everything after frontmatter). - pub body: String, /// Path to the SKILL.md file on disk. pub path: PathBuf, } @@ -102,102 +108,67 @@ pub struct SkillWithGroupContext { /// `for_crates` is the set of crate name/version pairs to match against. /// For `crate --list`, this is the full workspace deps. /// For `crate `, this is a single-element slice with the resolved crate. +/// +/// `workspace_root` scopes the `[plugins] use` entries that apply, which +/// decide both which dormant plugins wake up and which dependency-embedded +/// plugins load. `None` (no workspace) means no entry applies. +/// Convenience wrapper over [`crate::plugins::active_plugins`] + [`collect_skills`] that builds +/// the predicate context itself. Production sync shares one context across the +/// skill and MCP passes, so it calls the two primitives directly; this keeps the +/// combined form for tests that only care about the resulting skills. +#[cfg(test)] pub async fn skills_applicable_to( sym: &Symposium, - registry: &PluginRegistry, - workspace_crates: &[symposium_sdk::workspace::WorkspaceCrate], - custom_predicate_entries: std::collections::HashMap, + registry: &crate::plugins::PluginRegistry, + deps: &std::sync::Arc, + workspace_root: Option<&Path>, + custom_predicate_entries: std::collections::HashMap< + String, + crate::predicate::ResolvedPredicateEntry, + >, update: UpdateLevel, ) -> Vec { - let mut results = Vec::new(); - - let for_crates = crate::pm::CargoPm.list_deps(workspace_crates); - let mut ctx = PredicateContext::with_custom_predicates(&for_crates, custom_predicate_entries); - - // Skills from plugin manifests. We iterate these separately - // because we lazily load skill groups, so there - // is extra logic. - for parsed in ®istry.plugins { - let plugin = &parsed.plugin; - // Plugin-level predicates gate everything below. Evaluated before - // group fetching to avoid wasted work. Goes through the ParsedPlugin - // so the plugin's provenance is stamped for `workspace-member()`. - if !parsed.applies(&mut ctx) { - tracing::debug!( - report = %crate::report::ReportEvent::PluginConsidered { - plugin: plugin.name.clone(), - matched: false, - reason: Some("plugin-level predicates not satisfied".into()), - }, - ); - continue; - } - - tracing::debug!( - report = %crate::report::ReportEvent::PluginConsidered { - plugin: plugin.name.clone(), - matched: true, - reason: None, - }, - ); + let for_crates = crate::pm::workspace_dep_ids(sym, deps).await; + let used_names = workspace_root + .map(|root| sym.config.plugins.used_names_in(root)) + .unwrap_or_default(); + let mut ctx = PredicateContext::with_custom_predicates(&for_crates, custom_predicate_entries) + .with_used_names(&used_names); + + let pms = sym.package_managers(deps); + let active = + crate::plugins::active_plugins(sym, registry, &pms, workspace_root, &mut ctx).await; + collect_skills(sym, &active, &mut ctx, update).await +} - for group in &plugin.skills { - let skills = load_skills_for_group(sym, parsed, group, &mut ctx, update).await; +/// Extract the applicable skills from an already-resolved active plugin set. +/// +/// Provenance is re-stamped per plugin so a `source.path` group's +/// `workspace-member()` predicate sees the plugin it belongs to. Skill git +/// sources are fetched here (hence `update`), which is why this is separate +/// from [`crate::plugins::active_plugins`] — the plugin walk never touches the network. +pub(crate) async fn collect_skills( + sym: &Symposium, + active: &[ParsedPlugin], + ctx: &mut PredicateContext<'_>, + update: UpdateLevel, +) -> Vec { + let mut results = Vec::new(); + for parsed in active { + ctx.set_workspace_member(parsed.workspace_member); + 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, - &plugin.name, - &mut ctx, + &parsed.plugin.name, + ctx, &mut results, ); } } - - // `[[plugins]]` chained references: whenever this plugin is active and - // an edge's own predicates hold, the referenced crate is loaded as a - // first-class plugin and its skills contributed. Expansion recurses - // into the loaded crate's own chained edges — a crate that names - // another crate (the reschema'd `[package.metadata.symposium]` - // redirect) is followed transitively — with per-plugin cycle detection. - let mut visited = std::collections::HashSet::new(); - expand_chained_plugins( - sym, - parsed, - workspace_crates, - &mut ctx, - update, - &mut visited, - 0, - &mut results, - ) - .await; } - - // Standalone skills already carry their own origin hash (computed - // from the SKILL.md's on-disk path, like every other skill). - if !registry.standalone_skills.is_empty() { - tracing::debug!( - report = %crate::report::ReportEvent::PluginConsidered { - plugin: "(standalone skills)".into(), - matched: true, - reason: None, - }, - ); - } - // Standalone skills have no defining plugin; they never count as - // workspace members (clear any stamp left by the plugin loop). - ctx.set_workspace_member(false); - for entry in ®istry.standalone_skills { - collect_skill_applicable_to( - entry.skill.clone(), - entry.origin_hash.clone(), - "(standalone skills)", - &mut ctx, - &mut results, - ); - } - results } @@ -216,7 +187,6 @@ async fn load_skills_for_group( update: UpdateLevel, ) -> Vec<(Skill, String)> { let plugin = &parsed.plugin; - let plugin_path = parsed.path.as_path(); // Pre-fetch filtering: skip groups whose predicates don't hold (crate // matching and runtime checks alike). Done before any git/crates fetch so @@ -231,12 +201,12 @@ async fn load_skills_for_group( let predicates_display = (!predicates_display.is_empty()).then_some(predicates_display); if !group.predicates.evaluate(ctx) { - tracing::debug!(plugin = %plugin_path.display(), "skill group predicates failed, skipping"); + tracing::debug!(plugin = %plugin.name, "skill group predicates failed, skipping"); tracing::debug!( report = %crate::report::ReportEvent::SkillGroupConsidered { plugin: plugin.name.clone(), group_crates: predicates_display, - source: Some(source_display(&group.source)), + source: Some(group_source_label(group)), matched: false, skills_found: None, reason: Some("group predicates not satisfied".into()), @@ -252,7 +222,7 @@ async fn load_skills_for_group( report = %crate::report::ReportEvent::SkillGroupConsidered { plugin: plugin.name.clone(), group_crates: predicates_display, - source: Some(source_display(&group.source)), + source: Some(group_source_label(group)), matched: true, skills_found: Some(skills.len()), reason: None, @@ -287,23 +257,15 @@ async fn resolve_group_dirs( update: UpdateLevel, ) -> Vec { let plugin = &parsed.plugin; - let plugin_path = parsed.path.as_path(); match &group.source { - PluginSource::Path(p) => { - let plugin_dir = plugin_path.parent().unwrap_or(plugin_path); - let dir = plugin_dir.join(p); - let dir = dir.canonicalize().unwrap_or(dir); - let rel = dir - .strip_prefix(&parsed.source_dir) - .unwrap_or(&dir) - .display() - .to_string(); - + // Resolved to an absolute directory by the package manager, so there is + // nothing to join here. + PluginSource::Path(dir) => { vec![ResolvedSkillDir { - dir, + dir: dir.clone(), plugin_label: plugin.name.clone(), - source_label: format!("path:{rel}"), + source_label: group_source_label(group), }] } PluginSource::Git(url) => { @@ -321,136 +283,6 @@ async fn resolve_group_dirs( } } -/// Depth limit for `[[plugins]]` chained-reference expansion, bounding both -/// intentional chains and redirect loops that slip past cycle detection. -const MAX_CHAIN_DEPTH: usize = 10; - -/// Warn when a crate-embedded plugin declares extension types the chained -/// path doesn't dispatch yet. A crate plugin's *skills* and its own further -/// `[[plugins]]` edges are wired in; its hooks, MCP servers, subcommands, and -/// custom predicates are parsed and carried but not yet routed into their -/// dispatch paths. -fn warn_undispatched_crate_features(parsed: &ParsedPlugin) { - let p = &parsed.plugin; - let mut kinds = Vec::new(); - if !p.hooks.is_empty() { - kinds.push("hooks"); - } - if !p.mcp_servers.is_empty() { - kinds.push("mcp_servers"); - } - if !p.subcommands.is_empty() { - kinds.push("subcommands"); - } - if !p.custom_predicates.is_empty() { - kinds.push("predicates"); - } - if !kinds.is_empty() { - tracing::warn!( - plugin = %p.name, - features = %kinds.join(", "), - "crate-embedded plugin declares extension types that are not yet dispatched \ - (only its skills and chained references are loaded today)" - ); - } -} - -/// Expand an active plugin's `[[plugins]]` chained references, recursively. -/// -/// For each edge whose predicates hold (evaluated against the *owning* plugin's -/// provenance), the referenced crate is loaded as a first-class plugin via -/// [`CargoPm::load_plugin`], its own plugin-level predicates are honored, and -/// its skills are contributed with crate-origin identity. The loaded -/// crate's own chained edges are then expanded in turn — this is how a crate -/// that names another crate (a reschema'd `[package.metadata.symposium]` -/// redirect) is followed. -/// -/// `visited` holds the normalized crate names already loaded on this owning -/// plugin's chain; it collapses diamonds (a crate reached two ways loads once) -/// and breaks cycles. It is scoped per top-level plugin — cross-plugin dedup -/// stays the sync layer's job (via the origin hash). `depth`/[`MAX_CHAIN_DEPTH`] -/// is a backstop. -#[allow(clippy::too_many_arguments)] -async fn expand_chained_plugins( - sym: &Symposium, - owner: &ParsedPlugin, - workspace_crates: &[symposium_sdk::workspace::WorkspaceCrate], - ctx: &mut PredicateContext<'_>, - update: UpdateLevel, - visited: &mut std::collections::HashSet, - depth: usize, - results: &mut Vec, -) { - if depth >= MAX_CHAIN_DEPTH { - tracing::warn!( - plugin = %owner.plugin.name, - "chained plugin expansion exceeded depth limit ({MAX_CHAIN_DEPTH}); stopping" - ); - return; - } - - for chained in &owner.plugin.chained { - // Edge predicates evaluate against the owning plugin's provenance; the - // crate plugin's own `applies` (below) restamps its own — never a - // workspace member — so reset before each edge's gate. - ctx.set_workspace_member(owner.workspace_member); - if !chained.predicates.evaluate(ctx) { - continue; - } - - let Some(crate_plugin) = crate::pm::CargoPm - .load_plugin(&chained.name, workspace_crates) - .await - else { - continue; - }; - - // Cycle / diamond detection on the resolved crate identity, normalized - // so hyphen/underscore spellings of one crate collapse. - let key = crate::crate_sources::normalize_crate_name(&crate_plugin.canonical.name); - if !visited.insert(key) { - tracing::debug!( - crate_name = %chained.name, - "chained plugin already loaded on this chain; skipping (cycle or diamond)" - ); - continue; - } - - // Honor the crate plugin's own plugin-level predicates (which stamp its - // provenance: never a workspace member) before doing anything with it — - // an inactive crate plugin shouldn't warn about undispatched features. - if !crate_plugin.applies(ctx) { - continue; - } - warn_undispatched_crate_features(&crate_plugin); - - for group in &crate_plugin.plugin.skills { - let skills = load_skills_for_group(sym, &crate_plugin, group, ctx, update).await; - for (skill, origin_hash) in skills { - collect_skill_applicable_to( - skill, - origin_hash, - &crate_plugin.plugin.name, - ctx, - results, - ); - } - } - - Box::pin(expand_chained_plugins( - sym, - &crate_plugin, - workspace_crates, - ctx, - update, - visited, - depth + 1, - results, - )) - .await; - } -} - /// Discover skills in each resolved base dir and stamp origins. The single /// path all group sources funnel through, replacing the former per-source /// `load_*_skills` functions. @@ -579,22 +411,42 @@ pub(crate) fn prune_nested_skills(paths: &mut Vec) { *paths = kept; } -/// Load a standalone skill from a SKILL.md file (no plugin group context). -/// -/// Standalone skills must be self-contained: all metadata (`depends-on`) -/// comes from the SKILL.md frontmatter. -/// Returns an error if `depends-on` is missing (standalone skills have -/// no group to inherit from). -pub fn load_standalone_skill(skill_md_path: &Path) -> Result { - let skill = load_skill(skill_md_path, false, &PredicateSet::default())?; - if !skill.predicates.mentions_dep() { - bail!( - "standalone skill `{}` is missing `depends-on` in frontmatter \ - (standalone skills have no plugin group to inherit from)", - skill.name() - ); - } - Ok(skill) +/// Merge a SKILL.md's frontmatter `depends-on` (dependency atoms) and +/// `predicates` (function-call syntax) into one predicate set. +fn frontmatter_predicates( + depends_on: Option<&str>, + predicates: Option<&str>, +) -> Result { + let depends_on = match depends_on { + Some(s) => Some(crate::predicate::DependsOnList::parse(s)?), + None => None, + }; + let extra = match predicates { + Some(s) => PredicateSet::parse(s)?, + None => PredicateSet::default(), + }; + Ok(PredicateSet::merged(depends_on, extra)) +} + +/// The frontmatter a synthesized plugin needs from a bare `SKILL.md` entry: +/// its declared `name` (the skill's identity, used as the plugin name) and +/// its `depends-on`/`predicates` hoisted into one set (so the plugin is gated +/// by them and the ordinary dormancy rule applies — a bare skill with no +/// dependency is dormant until `use`d). See +/// [`plugins::load_standalone_skill_plugin`](crate::plugins). +pub(crate) fn standalone_skill_meta(skill_md: &Path) -> Result<(Option, PredicateSet)> { + let content = std::fs::read_to_string(skill_md) + .with_context(|| format!("failed to read {}", skill_md.display()))?; + let fm = parse_frontmatter(&content) + .with_context(|| format!("failed to parse frontmatter in {}", skill_md.display()))?; + let name = fm.fields.get("name").map(|n| { + n.strip_prefix('"') + .and_then(|s| s.strip_suffix('"')) + .unwrap_or(n) + .to_string() + }); + let predicates = frontmatter_predicates(fm.depends_on.as_deref(), fm.predicates.as_deref())?; + Ok((name, predicates)) } /// Load a single skill from a SKILL.md file. @@ -620,7 +472,6 @@ fn load_skill( fields: BTreeMap::new(), depends_on: None, predicates: None, - body: content, } } else { parse_frontmatter(&content).with_context(|| { @@ -673,15 +524,7 @@ fn load_skill( // Merge the skill-level `depends-on` (dependency atoms, OR-combined) with // the frontmatter `predicates` (function-call syntax) into one set, ANDed // with the plugin- and group-level sets at match time. - let depends_on = match fm.depends_on.as_deref() { - Some(s) => Some(crate::predicate::DependsOnList::parse(s)?), - None => None, - }; - let extra = match fm.predicates.as_deref() { - Some(s) => PredicateSet::parse(s)?, - None => PredicateSet::default(), - }; - let predicates = PredicateSet::merged(depends_on, extra); + let predicates = frontmatter_predicates(fm.depends_on.as_deref(), fm.predicates.as_deref())?; // Warn if no dependency is referenced at either level — the skill won't // match any dependency query, but we don't fail so a misconfigured plugin @@ -696,7 +539,6 @@ fn load_skill( let skill = Skill { frontmatter, predicates, - body: fm.body, path: skill_md_path.to_path_buf(), }; tracing::debug!(name = %skill.name(), path = %skill_md_path.display(), "skill loaded"); @@ -746,10 +588,11 @@ struct RawFrontmatter { depends_on: Option, /// Raw `predicates` value (comma-separated predicate expressions). predicates: Option, - body: String, } -/// Parse SKILL.md content: extract `---`-fenced frontmatter and body. +/// Parse SKILL.md content: extract the `---`-fenced frontmatter. The body +/// after the frontmatter is not retained — skills install by copying the +/// file, not by re-emitting parsed content. fn parse_frontmatter(content: &str) -> Result { let trimmed = content.trim_start(); if !trimmed.starts_with("---") { @@ -766,12 +609,6 @@ fn parse_frontmatter(content: &str) -> Result { .context("no closing --- fence in frontmatter")?; let frontmatter_text = &after_first_fence[..end]; - let body_start = end + 4; // "\n---".len() - let body = after_first_fence - .get(body_start..) - .unwrap_or("") - .strip_prefix('\n') - .unwrap_or(after_first_fence.get(body_start..).unwrap_or("")); let yaml: serde_yaml_ng::Value = serde_yaml_ng::from_str(frontmatter_text).context("frontmatter is not valid YAML")?; @@ -808,7 +645,6 @@ fn parse_frontmatter(content: &str) -> Result { fields, depends_on, predicates, - body: body.to_string(), }) } @@ -883,8 +719,6 @@ mod tests { assert_eq!(fm.fields.get("name").unwrap(), "my-skill"); assert_eq!(fm.fields.get("description").unwrap(), "A test skill"); assert_eq!(fm.depends_on.as_deref(), Some("serde")); - assert!(fm.body.contains("# Body content")); - assert!(fm.body.contains("Some instructions here.")); } #[test] @@ -995,7 +829,6 @@ mod tests { assert_eq!(skill.frontmatter.get("name").unwrap(), "test-skill"); assert!(skill.predicates.references_dep("serde")); - assert!(skill.body.contains("Use serde like this.")); } #[test] @@ -1115,10 +948,11 @@ mod tests { assert_eq!(skill.frontmatter.get("name").unwrap(), "no-own-crates"); } - // --- Standalone skills --- + // --- Bare SKILL.md loading (see plugins::load_standalone_skill_plugin + // for how a bare skill becomes a plugin) --- #[test] - fn load_standalone_skill_self_contained() { + fn bare_skill_is_self_contained() { let tmp = tempfile::tempdir().unwrap(); let skill_dir = tmp.path().join("my-skill"); fs::create_dir_all(&skill_dir).unwrap(); @@ -1136,10 +970,10 @@ mod tests { ) .unwrap(); - let skill = load_standalone_skill(&skill_dir.join("SKILL.md")).unwrap(); + let skill = + load_skill(&skill_dir.join("SKILL.md"), false, &PredicateSet::default()).unwrap(); assert_eq!(skill.name(), "my-standalone"); assert!(skill.predicates.references_dep("serde")); - assert!(skill.body.contains("Standalone body.")); } #[test] @@ -1153,7 +987,6 @@ mod tests { load_skill(&skill_dir.join("SKILL.md"), true, &PredicateSet::default()).unwrap(); assert_eq!(skill.name(), "release-notes"); assert!(!skill.frontmatter.contains_key("description")); - assert_eq!(skill.body, "Plain maintainer notes.\n"); // Registry groups keep the agentskills.io contract. let err = @@ -1221,7 +1054,8 @@ mod tests { ) .unwrap(); - let err = load_standalone_skill(&skill_dir.join("SKILL.md")).unwrap_err(); + let err = + load_skill(&skill_dir.join("SKILL.md"), false, &PredicateSet::default()).unwrap_err(); assert!( err.to_string().contains("depends-on predicate"), "expected parse error, got: {err}" @@ -1246,6 +1080,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group targets serde source: PluginSource::Path(PathBuf::from("skills")), + source_label: None, workspace_member: false, }], mcp_servers: vec![], @@ -1253,23 +1088,21 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { canonical: PackageId::new("test", &plugin.name, ANY_VERSION), - path: tmp.path().join("plugin.toml"), plugin, - source_dir: tmp.path().to_path_buf(), workspace_member: false, }], - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; // Query for serde - should find no skills because plugin doesn't apply - let workspace_crates = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace_crates = vec![crate::pm::WorkspaceCrate::new( "serde".to_string(), semver::Version::new(1, 0, 0), None, @@ -1277,7 +1110,8 @@ mod tests { let skills = skills_applicable_to( &sym, ®istry, - &workspace_crates, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace_crates), + None, std::collections::HashMap::new(), UpdateLevel::None, ) @@ -1305,6 +1139,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("other-crate"), // But group targets other-crate source: PluginSource::Path(PathBuf::from("skills")), + source_label: None, workspace_member: false, }], mcp_servers: vec![], @@ -1312,23 +1147,21 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { canonical: PackageId::new("test", &plugin.name, ANY_VERSION), - path: tmp.path().join("plugin.toml"), plugin, - source_dir: tmp.path().to_path_buf(), workspace_member: false, }], - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; // Query for serde - should find no skills because group doesn't match - let workspace_crates = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace_crates = vec![crate::pm::WorkspaceCrate::new( "serde".to_string(), semver::Version::new(1, 0, 0), None, @@ -1336,7 +1169,8 @@ mod tests { let skills = skills_applicable_to( &sym, ®istry, - &workspace_crates, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace_crates), + None, std::collections::HashMap::new(), UpdateLevel::None, ) @@ -1382,6 +1216,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), // Group also targets serde source: PluginSource::Path(skill_dir.to_path_buf()), + source_label: None, workspace_member: false, }], mcp_servers: vec![], @@ -1389,22 +1224,20 @@ mod tests { subcommands: BTreeMap::new(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { canonical: PackageId::new("test", &plugin.name, ANY_VERSION), - path: tmp.path().join("plugin.toml"), plugin, - source_dir: tmp.path().to_path_buf(), workspace_member: false, }], - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; - let workspace_crates = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace_crates = vec![crate::pm::WorkspaceCrate::new( "serde".to_string(), semver::Version::new(1, 0, 0), None, @@ -1412,7 +1245,8 @@ mod tests { let skills = skills_applicable_to( &sym, ®istry, - &workspace_crates, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace_crates), + None, std::collections::HashMap::new(), UpdateLevel::None, ) @@ -1464,6 +1298,7 @@ mod tests { skills: vec![SkillGroup { predicates: pred_set("serde"), source: PluginSource::Path(skill_dir.to_path_buf()), + source_label: None, workspace_member: false, }], mcp_servers: vec![], @@ -1471,22 +1306,20 @@ mod tests { subcommands: Default::default(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { canonical: PackageId::new("test", &plugin.name, ANY_VERSION), - path: tmp.path().join("plugin.toml"), plugin, - source_dir: PathBuf::from(".".to_string()), workspace_member: false, }], - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; - let workspace = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace = vec![crate::pm::WorkspaceCrate::new( "serde".into(), semver::Version::new(1, 0, 0), None, @@ -1494,7 +1327,8 @@ mod tests { let skills = skills_applicable_to( &sym, ®istry, - &workspace, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace), + None, std::collections::HashMap::new(), UpdateLevel::None, ) @@ -1547,6 +1381,7 @@ mod tests { ], }, source: PluginSource::Path(skill_dir.to_path_buf()), + source_label: None, workspace_member: false, }], mcp_servers: vec![], @@ -1554,22 +1389,20 @@ mod tests { subcommands: Default::default(), custom_predicates: vec![], chained: vec![], + requires_use: false, }; let registry = PluginRegistry { plugins: vec![ParsedPlugin { canonical: PackageId::new("test", &plugin.name, ANY_VERSION), - path: tmp.path().join("plugin.toml"), plugin, - source_dir: PathBuf::from(".".to_string()), workspace_member: false, }], - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; - let workspace = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace = vec![crate::pm::WorkspaceCrate::new( "serde".into(), semver::Version::new(1, 0, 0), None, @@ -1577,7 +1410,8 @@ mod tests { let skills = skills_applicable_to( &sym, ®istry, - &workspace, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace), + None, std::collections::HashMap::new(), UpdateLevel::None, ) @@ -1634,41 +1468,21 @@ mod tests { ) .unwrap(); - let err = load_standalone_skill(&skill_dir.join("SKILL.md")).unwrap_err(); + let err = + load_skill(&skill_dir.join("SKILL.md"), false, &PredicateSet::default()).unwrap_err(); assert!( err.to_string().contains("missing required `name` field"), "expected missing name error, got: {err}" ); } - #[test] - fn standalone_skill_requires_depends_on() { - let tmp = tempfile::tempdir().unwrap(); - let skill_dir = tmp.path().join("no-depends-on"); - fs::create_dir_all(&skill_dir).unwrap(); - fs::write( - skill_dir.join("SKILL.md"), - indoc! {" - --- - name: no-depends-on - description: Missing depends-on - --- - - Body. - "}, - ) - .unwrap(); - - let err = load_standalone_skill(&skill_dir.join("SKILL.md")).unwrap_err(); - assert!( - err.to_string().contains("missing `depends-on`"), - "expected depends-on error, got: {err}" - ); - } - + /// A bare `SKILL.md` directory, modelled as its synthesized plugin would be + /// (name from the directory, one `source.path = "."` group gated on the + /// skill's own `depends-on`), contributes its skill through the ordinary + /// plugin path. #[tokio::test] - async fn list_includes_standalone_skills() { - use crate::plugins::PluginRegistry; + async fn bare_skill_plugin_contributes_its_skill() { + use crate::plugins::{ParsedPlugin, Plugin, PluginRegistry, PluginSource, SkillGroup}; let tmp = tempfile::tempdir().unwrap(); let skill_dir = tmp.path().join("my-skill"); @@ -1687,19 +1501,37 @@ mod tests { ) .unwrap(); - let skill = load_standalone_skill(&skill_dir.join("SKILL.md")).unwrap(); + 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, + }], + mcp_servers: vec![], + subcommands: Default::default(), + custom_predicates: vec![], + chained: vec![], + requires_use: false, + }; let registry = PluginRegistry { - plugins: Vec::new(), - standalone_skills: vec![crate::plugins::StandaloneSkill { - skill, - origin_hash: "test-myskill".to_string(), + plugins: vec![ParsedPlugin { + canonical: crate::pm::PackageId::any_version("recs", "my-skill"), + plugin, + workspace_member: false, }], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), }; let sym = crate::config::Symposium::from_dir(tmp.path()); - let workspace = vec![symposium_sdk::workspace::WorkspaceCrate::new( + let workspace = vec![crate::pm::WorkspaceCrate::new( "serde".to_string(), semver::Version::new(1, 0, 0), None, @@ -1707,7 +1539,8 @@ mod tests { let results = skills_applicable_to( &sym, ®istry, - &workspace, + &crate::pm::WorkspaceDeps::fixture(std::path::PathBuf::new(), workspace), + None, std::collections::HashMap::new(), UpdateLevel::None, ) diff --git a/src/status_command.rs b/src/status_command.rs new file mode 100644 index 00000000..9ceb66f0 --- /dev/null +++ b/src/status_command.rs @@ -0,0 +1,229 @@ +//! `cargo agents status` — the enablement report. +//! +//! Enablement answers *whether a plugin may run at all*; activation +//! predicates answer *when it applies*. This command reports both, one line +//! per plugin, each naming its enablement root — so it answers "why is +//! serde-skills here?" with "enabled via serde". +//! +//! Four states, matching the axis: +//! +//! - **active** — enabled and its predicates hold for this workspace. The +//! root names the trust root: workspace membership, a configured registry, +//! `[plugins] auto-enable`, or a `[plugins] use` entry. +//! - **dormant** — loaded but waiting: a registry plugin that names no +//! dependency ([`requires_use`](crate::plugins::Plugin::requires_use)), or +//! one whose predicates don't currently hold. +//! - **candidate** — discovered in a dependency and awaiting consent. These +//! are exactly what the [consent prompt](crate::discovery::prompt_for_consent) +//! asks about. +//! - **declined** — recorded in `[plugins] disable`, the record of pruned +//! nodes and declined discoveries. + +use std::path::Path; + +use crate::pm::WorkspaceDeps; +use anyhow::Result; +use std::sync::Arc; + +use crate::config::Symposium; +use crate::discovery::{DiscoveredPlugin, Enablement}; +use crate::report::ReportEvent; + +/// What enablement decided about one plugin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StatusState { + /// Enabled, and its predicates hold here. + Active, + /// Loaded but not contributing: awaiting `use`, or predicates unmet. + Dormant, + /// Discovered in a dependency, awaiting consent. + Candidate, + /// Declined, via `[plugins] disable`. + Declined, +} + +impl StatusState { + /// The wire/report spelling. + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Dormant => "dormant", + Self::Candidate => "candidate", + Self::Declined => "declined", + } + } +} + +/// One line of the status report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StatusEntry { + pub name: String, + /// Resolved version for a discovered dependency plugin; `None` for + /// manifest plugins, whose identity is their source, not a version. + pub version: Option, + /// The enablement root, or — for the states that don't have one — why + /// the plugin will not load. + pub root: String, + pub state: StatusState, +} + +/// Compute the enablement report for the workspace `deps` points at. +pub async fn workspace_status( + sym: &Symposium, + deps: &Arc, +) -> Result> { + let ws = deps + .load() + .cloned() + .ok_or_else(|| anyhow::anyhow!("not in a Rust workspace"))?; + + let mut entries = Vec::new(); + + // Manifest plugins: workspace members and registry offerings. Both are + // trust roots, so the only questions are the `use` gate for dormant + // plugins and whether the predicates hold. + let registry = crate::plugins::load_registry_with_workspace(sym, Some(&ws)).await; + let dep_ids = crate::pm::workspace_dep_ids(sym, deps).await; + let used_names = sym.config.plugins.used_names_in(&ws.root); + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used_names); + for parsed in ®istry.plugins { + let root = if parsed.workspace_member { + "workspace member".to_string() + } else if parsed.plugin.requires_use && ctx.is_used(&parsed.plugin.name) { + "`[plugins] use`".to_string() + } else { + format!("registry `{}`", parsed.canonical.pm) + }; + let active = parsed.applies(&mut ctx); + entries.push(StatusEntry { + name: parsed.plugin.name.clone(), + version: None, + root: if active || !parsed.plugin.requires_use { + root + } else { + format!("{root} (dormant: awaiting `cargo agents use`)") + }, + state: if active { + StatusState::Active + } else { + StatusState::Dormant + }, + }); + } + + // Dependency-embedded plugins, with the decision the config made about + // each. This is the same view the consent prompt works from. + let discovery = crate::discovery::discover(sym, deps).await; + let mut declined_names = Vec::new(); + for found in discovery + .active + .iter() + .chain(&discovery.auto_enabled) + .chain(&discovery.candidates) + .chain(&discovery.declined) + { + if found.enablement == Enablement::Declined { + declined_names.push(found.name().to_string()); + } + entries.push(entry_for(found)); + } + + // Crates enabled by `use` that are *not* dependency-embedded offers — e.g. + // `cargo agents use ` for a crate the workspace doesn't depend on. + // Discovery only sees dependencies, so these are invisible above; surface + // them from the config the same way sync loads them + // ([`enabled_dependencies`](crate::discovery::enabled_dependencies)). + let normalize = crate::crate_sources::normalize_crate_name; + let shown: std::collections::HashSet = registry + .plugins + .iter() + .map(|p| normalize(&p.plugin.name)) + .chain( + discovery + .active + .iter() + .chain(&discovery.auto_enabled) + .chain(&discovery.candidates) + .chain(&discovery.declined) + .map(|d| normalize(d.name())), + ) + .collect(); + for name in crate::discovery::enabled_dependencies(sym, &dep_ids, &ws.root) { + if shown.contains(&normalize(&name)) { + continue; + } + entries.push(StatusEntry { + name, + version: None, + root: "`[plugins] use` (not a dependency)".to_string(), + state: StatusState::Active, + }); + } + + // Names declined without ever being discovered (a `disable` entry for a + // dependency whose source isn't on disk, or one added by hand). + for name in &sym.config.plugins.disable { + if declined_names.iter().any(|n| n == name) { + continue; + } + entries.push(StatusEntry { + name: name.clone(), + version: None, + root: "declined (`[plugins] disable`)".to_string(), + state: StatusState::Declined, + }); + } + + Ok(entries) +} + +/// Render one discovered dependency plugin as a status line. +fn entry_for(found: &DiscoveredPlugin) -> StatusEntry { + let (state, root) = match found.enablement { + Enablement::Used => (StatusState::Active, "`[plugins] use`".to_string()), + Enablement::AutoEnabled => (StatusState::Active, "`[plugins] auto-enable`".to_string()), + Enablement::Declined => ( + StatusState::Declined, + "declined (`[plugins] disable`)".to_string(), + ), + Enablement::Candidate => ( + StatusState::Candidate, + format!( + "found via dependency `{}`, awaiting consent (`cargo agents use {}`)", + found.recommends, + found.name() + ), + ), + }; + StatusEntry { + name: found.name().to_string(), + version: Some(found.id.version.clone()), + root, + state, + } +} + +/// The `cargo agents status` entry point. +pub async fn status(sym: &Symposium, cwd: &Path) -> Result<()> { + let deps = sym.workspace_deps(cwd); + let entries = workspace_status(sym, &deps).await?; + if entries.is_empty() { + tracing::info!( + report = %ReportEvent::Info { + message: "no plugins enabled for this workspace".to_string(), + }, + ); + return Ok(()); + } + for entry in entries { + tracing::info!( + report = %ReportEvent::PluginStatus { + name: entry.name, + version: entry.version, + root: entry.root, + state: entry.state.as_str().to_string(), + }, + ); + } + Ok(()) +} diff --git a/src/subcommand_dispatch.rs b/src/subcommand_dispatch.rs index 242de3b3..046dc9f1 100644 --- a/src/subcommand_dispatch.rs +++ b/src/subcommand_dispatch.rs @@ -8,27 +8,33 @@ use std::{ffi::OsString, path::Path, process::ExitStatus}; -use symposium_sdk::workspace::WorkspaceCrate; - use crate::{ config::Symposium, installation::{acquire_installation, resolve_runnable}, - plugins::{self, Plugin, PluginRegistry, Subcommand}, - pm::{CargoPm, PackageId, PackageManager as _}, + plugins::{self, ParsedPlugin, Plugin, Subcommand}, + pm::PackageId, }; use anyhow::{Context, Result, bail}; use symposium_install::{Runnable, UpdateLevel}; use tokio::process::Command; /// Collect every plugin subcommand whose plugin-level and subcommand-level predicates -/// apply to `deps`. Shared between dispatch (name lookup) and help rendering (audience grouping). +/// apply to `deps`. `used` names the plugins the applicable `[plugins] use` +/// entries enable, which is what wakes a dormant plugin. Shared between +/// dispatch (name lookup) and help rendering (audience grouping). +/// +/// `plugins` is the resolved active set from +/// [`plugins::active_plugins`](crate::plugins::active_plugins) — registry plugins +/// plus crate-sourced ones — so a crate plugin's subcommands are dispatchable +/// exactly like a registry plugin's. pub fn applicable_subcommands<'a>( - registry: &'a PluginRegistry, + plugins: &'a [ParsedPlugin], deps: &[PackageId], + used: &[&str], ) -> Vec<(&'a Plugin, &'a str, &'a Subcommand)> { - let mut ctx = crate::predicate::PredicateContext::new(deps); + let mut ctx = crate::predicate::PredicateContext::new(deps).with_used_names(used); let mut results = Vec::new(); - for parsed in ®istry.plugins { + for parsed in plugins { let plugin = &parsed.plugin; if !parsed.applies(&mut ctx) { continue; @@ -42,20 +48,19 @@ pub fn applicable_subcommands<'a>( results } -/// Look up a subcommand by name across all plugins, filtered by workspace crates at a plugin -/// and subcommand levels. +/// Look up a subcommand by name across the active plugin set, filtered by +/// workspace crates at the plugin and subcommand levels. /// /// - `Ok(None)` - no plugin claims the name, or every claim was filtered out. /// - `Ok(Some(..))` - exactly one plugin claims the name and applies. /// - `Err(..)` - two or more plugins claim the name and all apply. pub fn find_subcommand<'a>( - registry: &'a PluginRegistry, + plugins: &'a [ParsedPlugin], name: &str, - workspace: &[WorkspaceCrate], + deps: &[PackageId], + used: &[&str], ) -> Result> { - let deps = CargoPm.list_deps(workspace); - - let matches: Vec<_> = applicable_subcommands(registry, &deps) + let matches: Vec<_> = applicable_subcommands(plugins, deps, used) .into_iter() .filter(|(_, n, _)| *n == name) .map(|(plugin, _, subcmd)| (plugin, subcmd)) @@ -96,11 +101,29 @@ pub async fn dispatch_external( .context("subcommand name must be valid UTF-8")?; let forwarded = argv.collect::>(); - let mut deps = sym.workspace_deps(cwd); + let deps = sym.workspace_deps(cwd); let workspace = deps.load().cloned(); - let registry = plugins::load_registry_with_workspace(sym, workspace.as_deref()); - - let (plugin, subcommand) = find_subcommand(®istry, name, deps.crates())? + let registry = plugins::load_registry_with_workspace(sym, workspace.as_deref()).await; + + let dep_ids = crate::pm::workspace_dep_ids(sym, &deps).await; + let used = workspace + .as_ref() + .map(|ws| sym.config.plugins.used_names_in(&ws.root)) + .unwrap_or_default(); + + // Resolve the active plugin set so crate-sourced subcommands are dispatchable. + let mut ctx = crate::predicate::PredicateContext::new(&dep_ids).with_used_names(&used); + let pms = sym.package_managers(&deps); + let active = crate::plugins::active_plugins( + sym, + ®istry, + &pms, + workspace.as_ref().map(|ws| ws.root.as_path()), + &mut ctx, + ) + .await; + + let (plugin, subcommand) = find_subcommand(&active, name, &dep_ids, &used)? .with_context(|| format!("no plugin defines subcommand `{name}`"))?; let installation = plugin @@ -159,13 +182,13 @@ fn exit_byte_from(status: ExitStatus) -> u8 { #[cfg(test)] mod tests { use super::*; - use crate::plugins::ParsedPlugin; + use crate::plugins::PluginRegistry; use crate::pm::ANY_VERSION; use crate::{plugins::Audience, predicate::PredicateSet}; - use std::{collections::BTreeMap, path::PathBuf}; + use std::collections::BTreeMap; - fn ws_crate(name: &str, version: &str) -> WorkspaceCrate { - WorkspaceCrate::new(name.into(), semver::Version::parse(version).unwrap(), None) + fn ws_crate(name: &str, version: &str) -> PackageId { + PackageId::new(crate::pm::CARGO_PM, name, version) } fn crate_set(spec: &str) -> PredicateSet { @@ -179,7 +202,6 @@ mod tests { ) -> ParsedPlugin { ParsedPlugin { canonical: PackageId::new("test", name, ANY_VERSION), - path: PathBuf::from(format!("/test/{name}.toml")), plugin: Plugin { name: name.into(), predicates: crate_set(depends_on), @@ -190,8 +212,8 @@ mod tests { subcommands, custom_predicates: vec![], chained: vec![], + requires_use: false, }, - source_dir: PathBuf::from("/test"), workspace_member: false, } } @@ -208,7 +230,6 @@ mod tests { fn registry(plugins: Vec) -> PluginRegistry { PluginRegistry { plugins, - standalone_skills: vec![], warnings: vec![], custom_predicates: crate::plugins::CustomPredicateRegistry::default(), } @@ -222,7 +243,9 @@ mod tests { let ws = [ws_crate("skill-tree", "1.0.0")]; - let (plugin, sub) = find_subcommand(®, "greet", &ws).unwrap().unwrap(); + let (plugin, sub) = find_subcommand(®.plugins, "greet", &ws, &[]) + .unwrap() + .unwrap(); assert_eq!(plugin.name, "example-plugin"); assert_eq!(sub.command, "greet-install"); } @@ -234,7 +257,11 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = [ws_crate("skill-tree", "1.0.0")]; - assert!(find_subcommand(®, "nope", &ws).unwrap().is_none()); + assert!( + find_subcommand(®.plugins, "nope", &ws, &[]) + .unwrap() + .is_none() + ); } #[test] @@ -244,7 +271,11 @@ mod tests { let reg = registry(vec![plugin_with("example-plugin", "*", subs)]); let ws = [ws_crate("skill-tree", "1.0.0")]; - assert!(find_subcommand(®, "greet", &ws).unwrap().is_none()); + assert!( + find_subcommand(®.plugins, "greet", &ws, &[]) + .unwrap() + .is_none() + ); } #[test] @@ -261,7 +292,9 @@ mod tests { ]); let ws = [ws_crate("skill-tree", "1.0.0")]; - let err = find_subcommand(®, "greet", &ws).unwrap_err().to_string(); + let err = find_subcommand(®.plugins, "greet", &ws, &[]) + .unwrap_err() + .to_string(); assert!(err.contains("plugin-a"), "expected `plugin-a` in {err}"); assert!(err.contains("plugin-b"), "expected `plugin-b` in {err}"); diff --git a/src/sync.rs b/src/sync.rs index 628fefd2..3502eab2 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -16,9 +16,9 @@ use crate::agents::Agent; use crate::config::Symposium; use crate::output::{Output, display_path}; use crate::plugins; -use crate::pm::PackageManager as _; +use crate::pm::WorkspaceDeps; use crate::skills; -use symposium_sdk::workspace::WorkspaceDeps; +use std::sync::Arc; /// Marker file written into every skill directory symposium installs. /// @@ -272,7 +272,7 @@ async fn resolve_custom_predicate_entries( /// Run the full sync: discover applicable skills, install into agent dirs, /// clean up stale installations. -pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel) -> Result<()> { +pub async fn sync(sym: &Symposium, deps: &Arc, update: UpdateLevel) -> Result<()> { let out = &Output::quiet(); let loaded = deps .load() @@ -284,7 +284,7 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel tracing::debug!(root = %project_root.display(), "resolved workspace root"); // Load plugin registry (registry sources + workspace plugins) - let registry = plugins::load_registry_with_workspace(sym, Some(&loaded)); + let registry = plugins::load_registry_with_workspace(sym, Some(&loaded)).await; for warning in ®istry.warnings { tracing::info!( @@ -303,9 +303,23 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel // Resolve custom predicate installations. let custom_entries = resolve_custom_predicate_entries(sym, ®istry, update).await; - // Find all applicable skills - let applicable = - skills::skills_applicable_to(sym, ®istry, &workspace, custom_entries, update).await; + // Resolve the workspace once and build the predicate context shared by + // skill resolution and MCP-server filtering. + let dep_ids = crate::pm::workspace_dep_ids(sym, deps).await; + let used_names = sym.config.plugins.used_names_in(&project_root); + let mut ctx = + crate::predicate::PredicateContext::with_custom_predicates(&dep_ids, custom_entries) + .with_used_names(&used_names); + + // 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; + + // Find all applicable skills. + let applicable = skills::collect_skills(sym, &active, &mut ctx, update).await; // Dedup by `(skill_name, origin_hash)`: two crate origins with the same // (name, version, skill-path-within-crate) collapse (the same skill bytes @@ -327,11 +341,9 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel } } - // Collect MCP servers from applicable plugins, filtered by workspace deps - let dep_ids = crate::pm::CargoPm.list_deps(&workspace); - let mut ctx = crate::predicate::PredicateContext::new(&dep_ids); + // Collect MCP servers from the same active plugin set. let mut mcp_servers: Vec = Vec::new(); - for p in ®istry.plugins { + for p in &active { if p.applies(&mut ctx) { mcp_servers.extend(p.plugin.applicable_mcp_servers(&mut ctx)); } @@ -529,8 +541,8 @@ pub async fn sync(sym: &Symposium, deps: &mut WorkspaceDeps, update: UpdateLevel /// Register global hooks for all configured agents. /// Register hooks for all configured agents. Uses `home_dir` (global scope). /// Called from `init` after writing the user config. -pub fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { - let registry = plugins::load_registry(sym); +pub async fn register_hooks(sym: &Symposium, out: &Output) -> Result<()> { + let registry = plugins::load_registry(sym).await; let mcp_servers: Vec = registry .plugins .iter() diff --git a/src/use_command.rs b/src/use_command.rs new file mode 100644 index 00000000..1e66522f --- /dev/null +++ b/src/use_command.rs @@ -0,0 +1,178 @@ +//! `cargo agents use` — explicit plugin enablement. +//! +//! Enablement is the consent axis: the workspace and the configured +//! registries are trust roots, but a dependency is not, so a plugin embedded +//! in a dependency runs only once the user says so. `use` is the durable, +//! by-name form of that decision — it writes a [`UseEntry`] into `[plugins] +//! use`, scoped to the current workspace by default or to every workspace +//! with `--global`. +//! +//! It is also what wakes a *dormant* registry plugin (one whose manifest +//! names no dependency, so nothing else would ever gate it on — +//! [`Plugin::requires_use`](crate::plugins::Plugin::requires_use)). +//! +//! `use` only adds to what *may* run; activation predicates still decide +//! when it applies. `--remove` is the inverse, and re-syncs so the plugin's +//! skills are reaped straight away. + +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use symposium_install::UpdateLevel; + +use crate::config::{Symposium, UseEntry}; +use crate::crate_sources::normalize_crate_name; +use crate::report::ReportEvent; + +/// Record an enablement for `name` and sync so its skills install now. +pub async fn use_plugin( + sym: &mut Symposium, + cwd: &Path, + name: &str, + global: bool, + update: UpdateLevel, +) -> Result<()> { + // A configured registry is a trust root: what it offers is already + // enabled by configuration, so there is nothing to record. The exception + // is a dormant plugin, for which `use` is exactly the wake-up call. + let registry = crate::plugins::load_registry(sym).await; + let normalized = normalize_crate_name(name); + let registry_plugin = registry + .plugins + .iter() + .find(|p| normalize_crate_name(&p.plugin.name) == normalized); + let dormant = registry_plugin.is_some_and(|p| p.plugin.requires_use); + let already_trusted = registry_plugin.is_some() && !dormant; + if already_trusted { + tracing::info!( + report = %ReportEvent::Info { + message: format!( + "`{name}` is already available from a configured registry; nothing to enable" + ), + }, + ); + return Ok(()); + } + + let deps = sym.workspace_deps(cwd); + let workspace_root = deps.load().map(|ws| ws.root.clone()); + if !global && workspace_root.is_none() { + bail!("not in a Rust workspace; pass --global to enable `{name}` everywhere"); + } + + if !dormant { + resolve_name(sym, &deps, name).await?; + } + + let entry = match &workspace_root { + _ if global => UseEntry::Global(name.to_string()), + Some(root) => UseEntry::Workspace { + name: name.to_string(), + workspace: root.clone(), + }, + None => unreachable!("checked above"), + }; + + if sym.config.plugins.used.contains(&entry) { + tracing::info!( + report = %ReportEvent::Info { + message: format!("`{name}` is already enabled; nothing changed"), + }, + ); + } else { + sym.config.plugins.used.push(entry); + sym.save_config().context("failed to write user config")?; + tracing::info!( + report = %ReportEvent::PluginEnabled { + name: name.to_string(), + global, + }, + ); + } + + // Install now rather than waiting for the next sync. + if workspace_root.is_some() { + crate::sync::sync(sym, &deps, update).await?; + } + Ok(()) +} + +/// Drop a previously recorded enablement and re-sync so the plugin's skills +/// are reaped now. +/// +/// The scope must match: without `--global` this removes the entry recorded +/// for the current workspace, with it the unscoped entry. A scope mismatch is +/// an error rather than a silent success. +pub async fn remove_plugin( + sym: &mut Symposium, + cwd: &Path, + name: &str, + global: bool, + update: UpdateLevel, +) -> Result<()> { + let deps = sym.workspace_deps(cwd); + let workspace_root = deps.load().map(|ws| ws.root.clone()); + + let used = &mut sym.config.plugins.used; + let before = used.len(); + used.retain(|entry| { + if normalize_crate_name(entry.name()) != normalize_crate_name(name) { + return true; + } + let in_scope = match entry { + UseEntry::Global(_) => global, + UseEntry::Workspace { .. } => { + !global + && workspace_root + .as_deref() + .is_some_and(|root| entry.applies_in(root)) + } + }; + !in_scope + }); + if used.len() == before { + let scope = if global { "--global" } else { "this workspace" }; + bail!("no `use` entry for `{name}` ({scope}); see `cargo agents status`"); + } + sym.save_config().context("failed to write user config")?; + tracing::info!( + report = %ReportEvent::PluginRemoved { + name: name.to_string(), + global, + }, + ); + + if workspace_root.is_some() { + crate::sync::sync(sym, &deps, update).await?; + } + Ok(()) +} + +/// Check that `name` resolves to something before recording it: a workspace +/// dependency (offline-friendly) or a registry search hit. +async fn resolve_name( + sym: &Symposium, + deps: &std::sync::Arc, + name: &str, +) -> Result<()> { + let normalized = normalize_crate_name(name); + let is_workspace_dep = deps.load().is_some_and(|ws| { + ws.crates + .iter() + .any(|c| normalize_crate_name(&c.name) == normalized) + }); + if is_workspace_dep { + return Ok(()); + } + + let found = sym + .package_managers(deps) + .search(name) + .await + .iter() + .any(|(_, info)| normalize_crate_name(&info.id.name) == normalized); + if found { + return Ok(()); + } + bail!("no crate or plugin named `{name}` found (try `cargo agents search {name}`)") +} diff --git a/src/workspace_state.rs b/src/workspace_state.rs index 70ea3c6e..a7bde1c4 100644 --- a/src/workspace_state.rs +++ b/src/workspace_state.rs @@ -65,10 +65,9 @@ impl WorkspaceState { } pub fn record_sync(&mut self, workspace_root: &Path) { - self.last_sync_lock_mtime = - symposium_sdk::workspace::file_mtime(&workspace_root.join("Cargo.lock")); + self.last_sync_lock_mtime = crate::pm::file_mtime(&workspace_root.join("Cargo.lock")); self.last_sync_battery_pack_mtime = - symposium_sdk::workspace::file_mtime(&workspace_root.join("battery-pack.toml")); + crate::pm::file_mtime(&workspace_root.join("battery-pack.toml")); } } @@ -96,7 +95,7 @@ pub fn find_workspace_root(sym: &Symposium, cwd: &Path) -> Option { /// A missing file matches a `None` cached mtime (both absent = fresh). /// A missing file with a `Some` cached mtime means the file was deleted = stale. fn mtime_matches(cached: Option, path: &Path) -> bool { - let current = symposium_sdk::workspace::file_mtime(path); + let current = crate::pm::file_mtime(path); cached == current } @@ -107,14 +106,14 @@ fn state_file_path(sym: &Symposium, workspace_root: &Path) -> PathBuf { fs::canonicalize(workspace_root).unwrap_or_else(|_| workspace_root.to_path_buf()); sym.cache_dir() .join("workspaces") - .join(symposium_sdk::workspace::workspace_dir_name(&canonical)) + .join(crate::pm::workspace_dir_name(&canonical)) .join("state.json") } #[cfg(test)] mod tests { use super::*; - use symposium_sdk::workspace::{file_mtime, workspace_dir_name}; + use crate::pm::{file_mtime, workspace_dir_name}; #[test] fn workspace_dir_name_uses_tail_and_hash() { diff --git a/symposium-sdk/Cargo.toml b/symposium-sdk/Cargo.toml index e50e6add..3ddf8eac 100644 --- a/symposium-sdk/Cargo.toml +++ b/symposium-sdk/Cargo.toml @@ -11,12 +11,9 @@ clap = ["dep:clap"] [dependencies] anyhow = "1" -cargo_metadata = "0.18" clap = { version = "4", features = ["derive"], optional = true } -dirs = "6.0.0" regex = "1" semver = { version = "1.0", features = ["serde"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.10" tokio = { version = "1", features = ["macros", "rt"] } diff --git a/symposium-sdk/src/lib.rs b/symposium-sdk/src/lib.rs index a6e434e0..aab2eec9 100644 --- a/symposium-sdk/src/lib.rs +++ b/symposium-sdk/src/lib.rs @@ -36,7 +36,5 @@ //! originally built to name crates for the retired `source = "crate"` //! resolution and is currently ignored; see the [`predicate`] module docs. -pub mod dirs; pub mod hook; pub mod predicate; -pub mod workspace; diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 0cd4ef7a..c345c876 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -239,7 +239,7 @@ impl TestContext { let parse = Cli::try_parse_from(&full_args); if let Some(text) = - symposium::help_render::help_text(parse.as_ref(), &args_str, &self.sym, &cwd) + symposium::help_render::help_text(parse.as_ref(), &args_str, &self.sym, &cwd).await { out.println(text); return Ok(out.captured().join("\n")); @@ -276,7 +276,7 @@ impl TestContext { symposium::sync::sync( &self.sym, - &mut self.sym.workspace_deps(&cwd), + &self.sym.workspace_deps(&cwd), symposium::UpdateLevel::None, ) .await?; diff --git a/tests/enablement.rs b/tests/enablement.rs new file mode 100644 index 00000000..45979d1d --- /dev/null +++ b/tests/enablement.rs @@ -0,0 +1,590 @@ +//! The enablement commands: `cargo agents use`, `search`, and `status`, +//! plus the discovery consent prompt they share a config section with. + +use std::path::{Path, PathBuf}; + +use symposium::output::Output; +use symposium::status_command::StatusState; +use symposium_testlib::{HookStep, TestContext, TestMode, with_fixture}; + +/// Every installed skill directory under `parent` named `` or +/// `-`. +fn find_installed_skills(parent: &Path, skill_name: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(parent) else { + return Vec::new(); + }; + let mut out = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + let Some(name) = path.file_name().and_then(|n| n.to_str()) else { + continue; + }; + let matches = name == skill_name + || (name.starts_with(skill_name) + && name.as_bytes().get(skill_name.len()) == Some(&b'-')); + if matches && path.join("SKILL.md").is_file() { + out.push(path); + } + } + out.sort(); + out +} + +/// 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); + assert_eq!( + hits.len(), + 1, + "expected exactly one installed skill named `{skill_name}` under {}, found {hits:?}", + parent.display(), + ); + hits.pop().unwrap() +} + +fn read_config(ctx: &TestContext) -> String { + std::fs::read_to_string(ctx.sym.config_dir().join("config.toml")).unwrap() +} + +/// Run `f` with a JSON report layer installed and return the drained events. +async fn with_report(f: F) -> Vec +where + F: AsyncFnOnce(), +{ + use symposium::report::{ReportLayer, ReportMode}; + use tracing_subscriber::layer::SubscriberExt; + + let (layer, handle) = ReportLayer::new(ReportMode::Json, tracing::Level::INFO); + let subscriber = tracing_subscriber::registry().with(layer); + let guard = tracing::subscriber::set_default(subscriber); + f().await; + drop(guard); + handle.drain() +} + +// ── use ────────────────────────────────────────────────────────────── + +/// `use ` records a workspace-scoped entry and installs the +/// dependency's embedded skills right away; running it again changes +/// nothing. +#[tokio::test] +async fn use_records_workspace_entry_and_installs() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + + // Unconsented, the dependency's skills stay out. + ctx.symposium(&["sync"]).await?; + assert!(find_installed_skills(&skills_dir, "a-guidance").is_empty()); + + ctx.symposium(&["use", "crate-a"]).await?; + find_installed_skill(&skills_dir, "a-guidance"); + + let config = read_config(&ctx); + assert!(config.contains("crate-a"), "entry recorded: {config}"); + assert!( + config.contains("workspace"), + "entry is workspace-scoped: {config}" + ); + + ctx.symposium(&["use", "crate-a"]).await?; + assert_eq!(config, read_config(&ctx), "re-using must not duplicate"); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `--global` records a plain-string entry with no workspace scope. +#[tokio::test] +async fn use_global_records_unscoped_entry() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["use", "--global", "crate-a"]).await?; + + let config = read_config(&ctx); + assert!( + config.contains(r#"use = ["crate-a"]"#), + "global entry is a plain string: {config}" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A name that is neither a workspace dependency nor a registry hit is +/// rejected instead of recorded. +#[tokio::test] +async fn use_unknown_name_errors() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let err = ctx + .symposium(&["use", "no-such-plugin"]) + .await + .expect_err("unknown name should be rejected"); + assert!( + err.to_string().contains("no crate or plugin named"), + "{err}" + ); + assert!( + !read_config(&ctx).contains("no-such-plugin"), + "nothing recorded" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A registry is a trust root, so `use`-ing something it already offers is a +/// no-op rather than a recorded entry. +#[tokio::test] +async fn use_registry_content_is_a_noop() { + with_fixture(TestMode::SimulationOnly, &["plugins0"], async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["use", "serde-guidance"]).await?; + assert!( + !read_config(&ctx).contains("serde-guidance"), + "{}", + read_config(&ctx) + ); + Ok(()) + }) + .await + .unwrap(); +} + +/// `use` wakes a dormant registry plugin (one no dependency gates), and +/// `--remove` puts it back to sleep, reaping its skills. +#[tokio::test] +async fn use_wakes_and_remove_sleeps_a_dormant_plugin() { + with_fixture( + TestMode::SimulationOnly, + &["dormant-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + + ctx.symposium(&["sync"]).await?; + assert!(find_installed_skills(&skills_dir, "gateless-guidance").is_empty()); + + ctx.symposium(&["use", "gateless-plugin"]).await?; + find_installed_skill(&skills_dir, "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()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `--remove` drops the entry, reaps the installed skills, and errors when +/// there is nothing left to remove. +#[tokio::test] +async fn use_remove_reaps_and_then_errors() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let skills_dir = ctx.workspace_root.clone().unwrap().join(".claude/skills"); + + ctx.symposium(&["use", "crate-a"]).await?; + find_installed_skill(&skills_dir, "a-guidance"); + + ctx.symposium(&["use", "--remove", "crate-a"]).await?; + assert!( + !read_config(&ctx).contains("crate-a"), + "entry removed: {}", + read_config(&ctx) + ); + assert!( + find_installed_skills(&skills_dir, "a-guidance").is_empty(), + "skills reaped after removal" + ); + + let err = ctx + .symposium(&["use", "--remove", "crate-a"]) + .await + .expect_err("nothing left to remove"); + assert!(err.to_string().contains("no `use` entry"), "{err}"); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `--remove --global` targets only the global entry, leaving a +/// workspace-scoped one alone. +#[tokio::test] +async fn use_remove_respects_scope() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["use", "crate-a"]).await?; + + let err = ctx + .symposium(&["use", "--remove", "--global", "crate-a"]) + .await + .expect_err("no global entry to remove"); + assert!(err.to_string().contains("no `use` entry"), "{err}"); + assert!( + read_config(&ctx).contains("crate-a"), + "workspace entry untouched" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +// ── search ─────────────────────────────────────────────────────────── + +/// Search finds both a registry's standalone skills and its plugin entries, +/// tagged with the instance they came from. +#[tokio::test] +async fn search_finds_registry_content_tagged_by_origin() { + with_fixture(TestMode::SimulationOnly, &["plugins0"], async |mut ctx| { + let matches = symposium::search_command::find_matches(&ctx.sym, "SERDE-gui").await; + assert!( + matches.iter().any(|m| m.name == "serde-guidance"), + "case-insensitive substring match: {matches:?}" + ); + + let matches = symposium::search_command::find_matches(&ctx.sym, "my-skill").await; + assert!( + matches + .iter() + .any(|m| m.origin == "user-plugins" && m.name == "my-skill"), + "registry entry tagged with its instance name: {matches:?}" + ); + + // A query nothing matches finds nothing (and does not fail). + assert!( + symposium::search_command::find_matches(&ctx.sym, "zzz-nothing") + .await + .is_empty() + ); + + ctx.symposium(&["search", "serde"]).await?; + Ok(()) + }) + .await + .unwrap(); +} + +/// The rendered report groups hits under their origin and carries the +/// per-hit detail. +#[tokio::test] +async fn search_renders_grouped_by_origin() { + with_fixture(TestMode::SimulationOnly, &["plugins0"], async |ctx| { + let events = with_report(async || { + symposium::search_command::search(&ctx.sym, "serde") + .await + .unwrap(); + }) + .await; + + let kinds: Vec<&str> = events + .iter() + .filter_map(|e| e["kind"].as_str()) + .collect::>(); + assert!( + kinds.contains(&"info") && kinds.contains(&"search_match"), + "a group heading plus at least one hit: {events:?}" + ); + + // The bare skill is searched as the plugin it now is: matched by its + // name, tagged with its registry origin. Plugins carry no per-skill + // description in search (only a dormancy hint, and this one is active). + let hit = events + .iter() + .find(|e| e["kind"] == "search_match") + .expect("a search_match event"); + assert_eq!(hit["name"], "serde-guidance"); + assert_eq!(hit["origin"], "user-plugins"); + + let events = with_report(async || { + symposium::search_command::search(&ctx.sym, "zzz-nothing") + .await + .unwrap(); + }) + .await; + assert_eq!(events.len(), 1, "just the nothing-found notice: {events:?}"); + assert_eq!(events[0]["kind"], "info"); + Ok(()) + }) + .await + .unwrap(); +} + +// ── status ─────────────────────────────────────────────────────────── + +/// `status` reports an undecided dependency plugin as a candidate, then as +/// active with its `use` root once enabled — and names declined entries. +#[tokio::test] +async fn status_reports_candidate_then_used() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + 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 candidate = entries + .iter() + .find(|e| e.name == "crate-a") + .expect("crate-a discovered"); + assert_eq!(candidate.state, StatusState::Candidate); + assert!(candidate.root.contains("awaiting consent"), "{candidate:?}"); + + ctx.symposium(&["use", "crate-a"]).await?; + symposium::discovery::apply_consent(&mut ctx.sym, &[], &["noisy-crate".to_string()])?; + + let deps = ctx.sym.workspace_deps(&workspace_root); + let entries = symposium::status_command::workspace_status(&ctx.sym, &deps).await?; + let used = entries + .iter() + .find(|e| e.name == "crate-a") + .expect("crate-a present"); + assert_eq!(used.state, StatusState::Active); + assert_eq!(used.root, "`[plugins] use`"); + assert_eq!(used.version.as_deref(), Some("0.1.0")); + + let declined = entries + .iter() + .find(|e| e.name == "noisy-crate") + .expect("declined entry present"); + assert_eq!(declined.state, StatusState::Declined); + + // CLI wiring, and the rendered report. + let events = with_report(async || { + symposium::status_command::status(&ctx.sym, &workspace_root) + .await + .unwrap(); + }) + .await; + assert!( + events + .iter() + .any(|e| e["kind"] == "plugin_status" && e["state"] == "active"), + "{events:?}" + ); + ctx.symposium(&["status"]).await?; + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A registry plugin nothing gates is reported dormant until `use` names it. +#[tokio::test] +async fn status_reports_dormant_registry_plugin() { + with_fixture( + TestMode::SimulationOnly, + &["dormant-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + 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 dormant = entries + .iter() + .find(|e| e.name == "gateless-plugin") + .expect("gateless-plugin present"); + assert_eq!(dormant.state, StatusState::Dormant); + assert!(dormant.root.contains("awaiting `cargo agents use`")); + + // And `search` finds it, tagged with the registry it came from + // and flagged as needing `use`. + let hit = symposium::search_command::find_matches(&ctx.sym, "gateless") + .await + .into_iter() + .find(|m| m.name == "gateless-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:?}" + ); + + ctx.symposium(&["use", "gateless-plugin"]).await?; + let deps = ctx.sym.workspace_deps(&workspace_root); + let entries = symposium::status_command::workspace_status(&ctx.sym, &deps).await?; + let awake = entries + .iter() + .find(|e| e.name == "gateless-plugin") + .expect("gateless-plugin present"); + assert_eq!(awake.state, StatusState::Active); + assert_eq!(awake.root, "`[plugins] use`"); + Ok(()) + }, + ) + .await + .unwrap(); +} + +// ── consent ────────────────────────────────────────────────────────── + +/// The consent prompt must never block on stdin outside a terminal session. +/// +/// The gate is [`Output::is_interactive`], not a bare TTY check: `cargo test` +/// inherits the developer's terminal, so a stdin-only check would make this +/// test hang on an interactive machine. Quiet and capturing outputs — the +/// ones hook dispatch and the library harness use — are never interactive. +#[tokio::test] +async fn consent_prompt_never_fires_non_interactively() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + assert!(!Output::quiet().is_interactive()); + assert!(!Output::capturing().is_interactive()); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let workspace_root = ctx.workspace_root.clone().unwrap(); + + // There *is* something to ask about — so nothing below is + // vacuous. + let deps = ctx.sym.workspace_deps(&workspace_root); + assert_eq!( + symposium::discovery::pending_candidates(&ctx.sym, &deps).await, + vec!["crate-a".to_string()] + ); + + // The prompt returns without reading stdin, recording nothing. + let deps = ctx.sym.workspace_deps(&workspace_root); + let out = Output::quiet(); + symposium::discovery::prompt_for_consent(&mut ctx.sym, &deps, &out).await?; + assert!(ctx.sym.config.plugins.auto_enable.is_empty()); + assert!(ctx.sym.config.plugins.disable.is_empty()); + + // Nor does the `sync` command, whose harness output captures. + ctx.symposium(&["sync"]).await?; + let config = read_config(&ctx); + assert!(!config.contains("auto-enable"), "{config}"); + assert!(!config.contains("disable"), "{config}"); + + // Still undecided, and still not installed. + let deps = ctx.sym.workspace_deps(&workspace_root); + assert_eq!( + 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() + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Recorded consent is durable in both directions: approval enables and the +/// candidate stops being offered; a decline is remembered too. +#[tokio::test] +async fn apply_consent_records_both_answers() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + let workspace_root = ctx.workspace_root.clone().unwrap(); + + symposium::discovery::apply_consent(&mut ctx.sym, &["crate-a".to_string()], &[])?; + assert!(read_config(&ctx).contains("auto-enable")); + + ctx.symposium(&["sync"]).await?; + find_installed_skill(&workspace_root.join(".claude/skills"), "a-guidance"); + + let deps = ctx.sym.workspace_deps(&workspace_root); + assert!( + symposium::discovery::pending_candidates(&ctx.sym, &deps) + .await + .is_empty(), + "a decided dependency is not offered again" + ); + + symposium::discovery::apply_consent(&mut ctx.sym, &[], &["other-dep".to_string()])?; + assert!(read_config(&ctx).contains("disable")); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// Non-interactively, pending candidates surface as `SessionStart` context +/// rather than a prompt — and the agent is told not to act on them itself. +#[tokio::test] +async fn session_start_hints_pending_candidates() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.sym.config.auto_update = symposium::config::AutoUpdate::Off; + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + + let result = ctx + .prompt_or_hook( + "hello", + &[HookStep::session_start()], + symposium::hook_schema::HookAgent::Claude, + ) + .await?; + + let context = result + .hooks + .iter() + .filter_map(|h| { + h.output + .get("additionalContext") + .and_then(|v| v.as_str()) + .or_else(|| { + h.output + .get("hookSpecificOutput") + .and_then(|o| o.get("additionalContext")) + .and_then(|v| v.as_str()) + }) + }) + .next() + .expect("session-start should produce additionalContext"); + + assert!(context.contains("crate-a"), "{context}"); + assert!(context.contains("cargo agents use"), "{context}"); + assert!(context.contains("Do not enable them yourself"), "{context}"); + Ok(()) + }, + ) + .await + .unwrap(); +} diff --git a/tests/fixtures/auto-enable0/Cargo.toml b/tests/fixtures/auto-enable0/Cargo.toml new file mode 100644 index 00000000..bdf56c16 --- /dev/null +++ b/tests/fixtures/auto-enable0/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "auto-enable-host" +version = "0.1.0" +edition = "2021" + +[dependencies] +crate-a = { path = "crate-a" } diff --git a/tests/fixtures/auto-enable0/crate-a/Cargo.toml b/tests/fixtures/auto-enable0/crate-a/Cargo.toml new file mode 100644 index 00000000..5a2fa815 --- /dev/null +++ b/tests/fixtures/auto-enable0/crate-a/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate-a" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/auto-enable0/crate-a/skills/a-guidance/SKILL.md b/tests/fixtures/auto-enable0/crate-a/skills/a-guidance/SKILL.md new file mode 100644 index 00000000..bd9b2471 --- /dev/null +++ b/tests/fixtures/auto-enable0/crate-a/skills/a-guidance/SKILL.md @@ -0,0 +1,7 @@ +--- +name: a-guidance +description: Guidance for using crate-a +depends-on: crate-a +--- + +Use crate-a like this. diff --git a/tests/fixtures/auto-enable0/crate-a/src/lib.rs b/tests/fixtures/auto-enable0/crate-a/src/lib.rs new file mode 100644 index 00000000..421e195a --- /dev/null +++ b/tests/fixtures/auto-enable0/crate-a/src/lib.rs @@ -0,0 +1 @@ +pub fn hello() {} diff --git a/tests/fixtures/auto-enable0/dot-symposium/config.toml b/tests/fixtures/auto-enable0/dot-symposium/config.toml new file mode 100644 index 00000000..1d8e60af --- /dev/null +++ b/tests/fixtures/auto-enable0/dot-symposium/config.toml @@ -0,0 +1,5 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = false diff --git a/tests/fixtures/auto-enable0/src/lib.rs b/tests/fixtures/auto-enable0/src/lib.rs new file mode 100644 index 00000000..87cc76dd --- /dev/null +++ b/tests/fixtures/auto-enable0/src/lib.rs @@ -0,0 +1,2 @@ +// auto-enable-host depends on crate-a, whose source embeds skills. No plugin +// manifest points at it: it loads only once the user consents. diff --git a/tests/fixtures/crate-facets0/Cargo.toml b/tests/fixtures/crate-facets0/Cargo.toml new file mode 100644 index 00000000..28b5cd39 --- /dev/null +++ b/tests/fixtures/crate-facets0/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "facet-host" +version = "0.1.0" +edition = "2021" + +[dependencies] +crate-f = { path = "crate-f" } diff --git a/tests/fixtures/crate-facets0/crate-f/Cargo.toml b/tests/fixtures/crate-facets0/crate-f/Cargo.toml new file mode 100644 index 00000000..2434d42f --- /dev/null +++ b/tests/fixtures/crate-facets0/crate-f/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "crate-f" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml b/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml new file mode 100644 index 00000000..834fc281 --- /dev/null +++ b/tests/fixtures/crate-facets0/crate-f/SYMPOSIUM.toml @@ -0,0 +1,30 @@ +# crate-f ships its own plugin manifest declaring non-skill extensions — an +# MCP server and a subcommand — reached only through the chained reference from +# vouch-f. Their presence after sync/dispatch proves crate-sourced MCP servers +# and subcommands now flow through the active plugin set, not just skills. +# `name` defaults to the crate and `depends-on` is waived, since the chained +# reference that reaches this manifest is the gate. + +[[mcp_servers]] +name = "facet-server" +depends-on = ["*"] +command = "/usr/bin/true" +args = ["--stdio"] +env = [] + +[[installations]] +name = "facet-install" +executable = "rustc" + +[subcommand.facet-tool] +description = "Print rustc version (from crate-f)" +command = "facet-install" + +# A hook whose script emits additional context — proves crate-sourced hooks +# dispatch through the active plugin set. +[[hooks]] +name = "facet-hook" +event = "PreToolUse" +matcher = "Task" +command = { script = "$TEST_DIR/crate-f/scripts/facet-hook.sh" } +format = "symposium" diff --git a/tests/fixtures/crate-facets0/crate-f/scripts/facet-hook.sh b/tests/fixtures/crate-facets0/crate-f/scripts/facet-hook.sh new file mode 100644 index 00000000..62df2fe4 --- /dev/null +++ b/tests/fixtures/crate-facets0/crate-f/scripts/facet-hook.sh @@ -0,0 +1,2 @@ +#!/bin/sh +echo '{"PreToolUse":{"additionalContext":"facet-hook-output"}}' diff --git a/tests/fixtures/crate-facets0/crate-f/src/lib.rs b/tests/fixtures/crate-facets0/crate-f/src/lib.rs new file mode 100644 index 00000000..421e195a --- /dev/null +++ b/tests/fixtures/crate-facets0/crate-f/src/lib.rs @@ -0,0 +1 @@ +pub fn hello() {} diff --git a/tests/fixtures/crate-facets0/dot-symposium/config.toml b/tests/fixtures/crate-facets0/dot-symposium/config.toml new file mode 100644 index 00000000..777c5d87 --- /dev/null +++ b/tests/fixtures/crate-facets0/dot-symposium/config.toml @@ -0,0 +1,5 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true diff --git a/tests/fixtures/crate-facets0/dot-symposium/plugins/vouch-f/SYMPOSIUM.toml b/tests/fixtures/crate-facets0/dot-symposium/plugins/vouch-f/SYMPOSIUM.toml new file mode 100644 index 00000000..80fc7198 --- /dev/null +++ b/tests/fixtures/crate-facets0/dot-symposium/plugins/vouch-f/SYMPOSIUM.toml @@ -0,0 +1,7 @@ +name = "vouch-f" +depends-on = ["crate-f"] + +# No extensions of its own — it vouches for crate-f's plugin, which ships its +# own SYMPOSIUM.toml and is loaded through this chained reference. +[[plugins]] +source.cargo = "crate-f" diff --git a/tests/fixtures/crate-facets0/src/lib.rs b/tests/fixtures/crate-facets0/src/lib.rs new file mode 100644 index 00000000..cd495652 --- /dev/null +++ b/tests/fixtures/crate-facets0/src/lib.rs @@ -0,0 +1,4 @@ +// facet-host depends on crate-f; the vouch-f plugin loads crate-f's plugin +// through a `[[plugins]]` chained reference. crate-f's manifest declares +// non-skill extensions (an MCP server and a subcommand), which now dispatch +// through the active plugin set exactly like a registry plugin's. diff --git a/tests/fixtures/dormant-plugin0/Cargo.toml b/tests/fixtures/dormant-plugin0/Cargo.toml new file mode 100644 index 00000000..37b217de --- /dev/null +++ b/tests/fixtures/dormant-plugin0/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "dormant-host" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/dormant-plugin0/dot-symposium/config.toml b/tests/fixtures/dormant-plugin0/dot-symposium/config.toml new file mode 100644 index 00000000..777c5d87 --- /dev/null +++ b/tests/fixtures/dormant-plugin0/dot-symposium/config.toml @@ -0,0 +1,5 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true diff --git a/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/SYMPOSIUM.toml b/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/SYMPOSIUM.toml new file mode 100644 index 00000000..62095e4f --- /dev/null +++ b/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/SYMPOSIUM.toml @@ -0,0 +1,6 @@ +name = "gateless-plugin" + +# No `depends-on` anywhere: nothing to infer a gate from, so this plugin +# loads dormant and activates only when a `[plugins] use` entry names it. +[[skills]] +source.path = "skills" diff --git a/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/skills/gateless-guidance/SKILL.md b/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/skills/gateless-guidance/SKILL.md new file mode 100644 index 00000000..d5d8aed4 --- /dev/null +++ b/tests/fixtures/dormant-plugin0/dot-symposium/plugins/gateless-plugin/skills/gateless-guidance/SKILL.md @@ -0,0 +1,7 @@ +--- +name: gateless-guidance +description: Guidance vended by a plugin with no dependency gate +depends-on: "*" +--- + +Guidance from a dormant plugin. diff --git a/tests/fixtures/dormant-plugin0/src/lib.rs b/tests/fixtures/dormant-plugin0/src/lib.rs new file mode 100644 index 00000000..de056a6f --- /dev/null +++ b/tests/fixtures/dormant-plugin0/src/lib.rs @@ -0,0 +1,2 @@ +// A workspace with a registry plugin that names no dependency: it is known +// but dormant until `[plugins] use` enables it. diff --git a/tests/fixtures/project-plugins0/.symposium/config.toml b/tests/fixtures/project-plugins0/.symposium/config.toml index 6c90412a..7be5f3bd 100644 --- a/tests/fixtures/project-plugins0/.symposium/config.toml +++ b/tests/fixtures/project-plugins0/.symposium/config.toml @@ -1,5 +1,5 @@ hook-scope = "project" -[[plugin-source]] +[[registry]] name = "project-local" path = "project-plugins" diff --git a/tests/help_render.rs b/tests/help_render.rs index 4eb4b020..046309d1 100644 --- a/tests/help_render.rs +++ b/tests/help_render.rs @@ -24,9 +24,12 @@ async fn cargo_agents_help_lists_plugin_vended() { Commands for humans: init Set up user-wide configuration plugin Manage plugins + search Search configured registries for plugins self-update Update symposium to the latest version + status Show which plugins are enabled for this workspace, and why sync Synchronize skills with workspace dependencies telemetry Manage opt-in usage telemetry (status, enable, disable, show) + use Enable a plugin by name and sync it into the workspace Commands for agents: crate-info Find crate sources diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 357fc5d8..21576512 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -223,17 +223,21 @@ async fn sync_skips_invalid_skill_frontmatter() { &["invalid-skill0", "workspace0"], async |mut ctx| { ctx.symposium(&["init", "--add-agent", "codex"]).await?; - let registry = symposium::plugins::load_registry(&ctx.sym); + + // The bad skill is synthesized into a plugin; loading it fails, and + // the package manager surfaces that as a warning during sync — + // naming the SKILL.md and the parse error — rather than installing + // anything. + let events = ctx.sync_with_report(tracing::Level::INFO).await?; assert!( - registry.warnings.iter().any(|warning| { - warning.path.ends_with("bad-skill/SKILL.md") - && warning.message.contains("failed to parse frontmatter") - }), - "registry should record a warning for skipped invalid skill" + events.iter().any(|e| e + .get("message") + .and_then(|m| m.as_str()) + .is_some_and(|m| m.contains("bad-skill/SKILL.md") + && m.contains("failed to parse frontmatter"))), + "sync should warn about the skipped invalid skill: {events:?}" ); - ctx.symposium(&["sync"]).await?; - let workspace_root = ctx.workspace_root.as_ref().unwrap(); let installed = find_installed_skills(&workspace_root.join(".agents/skills"), "rust-best-practice"); @@ -558,6 +562,84 @@ async fn sync_installs_skill_from_crate_path() { .unwrap(); } +/// A dependency's embedded skills stay out until the user consents, and load +/// as soon as `[plugins] auto-enable` names the dependency. +/// +/// Fixture layout: `auto-enable-host` depends on `crate-a` (path dep), which +/// ships `skills/a-guidance/SKILL.md`. No plugin manifest anywhere points at +/// it — dependencies are not a trust root, so consent is the only way in. +#[tokio::test] +async fn auto_enable_admits_a_dependencys_embedded_skills() { + with_fixture( + TestMode::SimulationOnly, + &["auto-enable0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.clone().unwrap(); + let skills_dir = workspace_root.join(".claude/skills"); + assert!( + find_installed_skills(&skills_dir, "a-guidance").is_empty(), + "an unconsented dependency plugin must not install anything" + ); + + ctx.sym.config.plugins.auto_enable.push("crate-a".into()); + ctx.sym.save_config()?; + ctx.symposium(&["sync"]).await?; + + 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()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// A registry plugin that names no dependency is installed but dormant: it +/// contributes nothing until a `[plugins] use` entry enables it by name. +#[tokio::test] +async fn dormant_plugin_activates_only_once_used() { + with_fixture( + TestMode::SimulationOnly, + &["dormant-plugin0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.clone().unwrap(); + let skills_dir = workspace_root.join(".claude/skills"); + assert!( + find_installed_skills(&skills_dir, "gateless-guidance").is_empty(), + "a dormant plugin must not install anything" + ); + + // It is nonetheless loaded and known — dormant, not invalid. + let found = symposium::plugins::find_plugin(&ctx.sym, "gateless-plugin").await; + assert!(found.is_some_and(|p| p.plugin.requires_use)); + + ctx.sym + .config + .plugins + .used + .push(symposium::config::UseEntry::Global( + "gateless-plugin".into(), + )); + ctx.sym.save_config()?; + ctx.symposium(&["sync"]).await?; + + let dir = find_installed_skill(&skills_dir, "gateless-guidance"); + assert!(dir.join(".symposium").exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `sync` loads a crate's skills through a `[[plugins]]` chained reference. /// /// Fixture layout: @@ -627,6 +709,37 @@ async fn sync_installs_skill_via_crate_manifest() { .unwrap(); } +/// `sync` registers an MCP server declared by a crate reached through a +/// `[[plugins]]` chained reference — a crate-sourced plugin's MCP servers flow +/// through the active plugin set, not just its skills. +/// +/// Fixture layout: +/// - `facet-host` depends on `crate-f` (path dep) +/// - `vouch-f` gates on `crate-f` and carries `[[plugins]] source.cargo = +/// "crate-f"` but declares nothing of its own +/// - `crate-f` ships a `SYMPOSIUM.toml` declaring the `facet-server` MCP server +#[tokio::test] +async fn sync_registers_mcp_server_from_chained_crate() { + with_fixture( + TestMode::SimulationOnly, + &["crate-facets0"], + async |mut ctx| { + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let settings = std::fs::read_to_string(workspace_root.join(".claude/settings.json"))?; + assert!( + settings.contains("facet-server"), + "chained crate's MCP server should be registered:\n{settings}" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `crate-info` resolves a `[patch.crates-io]` crate to its local path. /// /// Fixture layout: @@ -2432,15 +2545,17 @@ async fn report_json_shows_skipped_skills() { ctx.symposium(&["init", "--add-agent", "claude"]).await?; let events = ctx.sync_with_report(tracing::Level::DEBUG).await?; - // The plugins0 fixture has a serde-guidance skill that requires `serde`. - // workspace-empty0 has no deps, so it should be skipped. + // The plugins0 fixture's serde-guidance is a bare skill, now a + // plugin gated on `serde`. workspace-empty0 has no deps, so the + // plugin is skipped at the plugin level (before its skills are + // even considered). let skipped: Vec<&Value> = events .iter() - .filter(|e| e["kind"] == "skill_considered" && e["matched"] == false) + .filter(|e| e["kind"] == "plugin_considered" && e["matched"] == false) .collect(); assert!( !skipped.is_empty(), - "expected at least one skill to be skipped when workspace has no deps" + "expected the serde-guidance plugin to be skipped when workspace has no deps" ); // No skill_installed events should appear diff --git a/tests/plugin_dispatch.rs b/tests/plugin_dispatch.rs index dbad3ff8..155797da 100644 --- a/tests/plugin_dispatch.rs +++ b/tests/plugin_dispatch.rs @@ -36,6 +36,38 @@ async fn inline_shell_hook_emits_context() { .unwrap(); } +/// A hook declared by a crate reached through a `[[plugins]]` chained +/// reference fires — crate-sourced hooks dispatch through the active plugin +/// set, not just skills. `crate-f`'s `facet-hook` emits `facet-hook-output`. +#[tokio::test(flavor = "multi_thread")] +async fn hook_fires_from_chained_crate() { + with_fixture( + TestMode::SimulationOnly, + &["crate-facets0"], + async |mut ctx| { + let result = ctx + .prompt_or_hook( + "ignored", + &[HookStep::PreToolUse { + tool_name: "Task".to_string(), + tool_input: json!({}), + }], + HookAgent::Claude, + ) + .await?; + + assert!( + result.has_context_containing("facet-hook-output"), + "expected `facet-hook-output` from the chained crate's hook, got: {:#?}", + result.outputs_for(HookEvent::PreToolUse), + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + /// `command = "named-shell"` resolves the named installation at dispatch time. #[tokio::test(flavor = "multi_thread")] async fn named_installation_resolves_at_dispatch() { diff --git a/tests/subcommand_dispatch.rs b/tests/subcommand_dispatch.rs index 958592c2..f7d61019 100644 --- a/tests/subcommand_dispatch.rs +++ b/tests/subcommand_dispatch.rs @@ -47,9 +47,12 @@ async fn help_shows_plugin_subcommand() { Commands for humans: init Set up user-wide configuration plugin Manage plugins + search Search configured registries for plugins self-update Update symposium to the latest version + status Show which plugins are enabled for this workspace, and why sync Synchronize skills with workspace dependencies telemetry Manage opt-in usage telemetry (status, enable, disable, show) + use Enable a plugin by name and sync it into the workspace Commands for agents: crate-info Find crate sources @@ -71,6 +74,48 @@ async fn help_shows_plugin_subcommand() { .unwrap(); } +/// A subcommand declared by a crate reached through a `[[plugins]]` chained +/// reference is dispatchable — crate-sourced subcommands flow through the active +/// plugin set, not just skills. `crate-f` vends `facet-tool` (→ `rustc`); the +/// child's stdout must contain "rustc". +#[tokio::test] +async fn dispatches_subcommand_from_chained_crate() { + symposium_testlib::with_fixture( + TestMode::SimulationOnly, + &["crate-facets0"], + async |mut ctx| { + let out = ctx.symposium(&["facet-tool", "--version"]).await?; + assert!( + out.contains("rustc"), + "expected rustc version output from the chained crate's subcommand, got: {out}" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// The same crate-sourced subcommand appears in `--help`, under the agents +/// section, proving help discovery walks the active plugin set. +#[tokio::test] +async fn help_shows_chained_crate_subcommand() { + symposium_testlib::with_fixture( + TestMode::SimulationOnly, + &["crate-facets0"], + async |mut ctx| { + let out = redact(ctx.symposium(&["--help"]).await?); + assert!( + out.contains("facet-tool"), + "chained crate's subcommand should be listed in help:\n{out}" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} + #[tokio::test] async fn unknown_subcommand_errors() { symposium_testlib::with_fixture(