Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions md/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 5 additions & 5 deletions md/custom-plugin-source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion md/design/hook-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 21 additions & 8 deletions md/design/important-flows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <crate>` 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

Expand All @@ -31,8 +44,8 @@ clap's auto help flag and help subcommand are disabled in `cli::Cli`; `--help`/`

When the user runs `cargo agents <name>` 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.

Expand Down
Loading
Loading