Skip to content
Merged
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
28 changes: 15 additions & 13 deletions Cargo.lock

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

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ serde_yaml_ng = "0.10"
sha2 = "0.10"
tar = "0.4"
tempfile = "3.6"
symposium-hook = { path = "symposium-hook", features = ["clap"] }
symposium-sdk = { path = "symposium-sdk", features = ["clap"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
toml = "0.8"
tracing = "0.1"
Expand All @@ -62,4 +62,4 @@ indoc = "2.0.7"
symposium-testlib = { path = "symposium-testlib" }

[workspace]
members = [".", "symposium-testlib", "symposium-hook", "symposium-install"]
members = [".", "symposium-testlib", "symposium-install", "symposium-sdk"]
8 changes: 6 additions & 2 deletions md/design/module-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ 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 warn). Provides `plugin_sources()` to resolve the effective list of plugin source directories.
Defines the user-wide `Config` (stored at `~/.symposium/config.toml`) with `[[agent]]` entries, logging, plugin sources, defaults, and `auto-update` (off/warn/on, default warn). 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.

### `agents.rs` — agent abstraction

Expand All @@ -20,6 +20,8 @@ Implements `cargo agents init`. Prompts for agents (or accepts `--add-agent`/`--

Implements `cargo agents sync`. Scans workspace dependencies, finds applicable skills from plugin sources, and synchronizes them into each configured agent's skill directory. The core primitive is `sync_skill_dir(source_dir, dest_dir, project_root)`, shared by both the plugin-skill and user-authored-skill code paths. It copies the entire source directory (not just `SKILL.md`) and is change-aware: it compares source and destination content, only performing the delete-and-recopy when files actually differ, so the disk shows no modifications when nothing changed. A configurable debounce (`sync-debounce-secs`, default 5s, keyed on the `.symposium` marker's mtime) skips even the comparison for recently-synced skills. On each sync, scans every agent's skills parent directory and reaps any marker-bearing subdirectory it didn't install this time, leaving user-managed skills (which lack the marker) untouched. Writes a `.gitignore` with `*` only into individual skill directories (not parent directories like `.claude/` or `.claude/skills/`). Also provides `register_hooks()` for use by `init`, which registers only symposium's own global hook handler — individual plugin hooks are never written into agent configs.

Two entry points: `sync(sym, cwd)` for standalone CLI use (creates its own `WorkspaceDeps`) and `sync_with_deps(sym, deps)` for the hook pipeline (shares the cached workspace resolution with other hook stages).

### `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:
Expand Down Expand Up @@ -72,7 +74,9 @@ Renders `cargo agents --help` as two audience-grouped sections, "Commands for hu

### `hook.rs` — hook handling

Handles the hook pipeline: parse agent wire-format input → auto-sync → builtin dispatch → plugin hook dispatch → serialize output. 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 <path>` for `Script`). Input is delivered in the selected format; output is converted back to the agent's wire format before returning.
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`, and `dispatch_plugin_hooks`. 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.

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 <path>` 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

Expand Down
9 changes: 5 additions & 4 deletions src/bin/cargo-agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,11 @@ async fn main() -> ExitCode {
if self_update::maybe_check_for_update(&sym, &out).await {
self_update::re_exec();
}
} else if is_hook && sym.config.auto_update == config::AutoUpdate::On {
if self_update::maybe_check_for_update(&sym, &Output::quiet()).await {
self_update::re_exec();
}
} else if is_hook
&& sym.config.auto_update == config::AutoUpdate::On
&& self_update::maybe_check_for_update(&sym, &Output::quiet()).await
{
self_update::re_exec();
}

match cli.command {
Expand Down
2 changes: 1 addition & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ pub async fn run(sym: &mut Symposium, cmd: Commands, cwd: &Path, out: &Output) -
init::init(sym, out, &opts).await
}

Commands::Sync => sync::sync(sym, cwd).await,
Commands::Sync => sync::sync(sym, &mut sym.workspace_deps(cwd)).await,

Commands::SelfUpdate => self_update::self_update(sym, out),

Expand Down
98 changes: 38 additions & 60 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,8 @@ const BUILTIN_RECOMMENDATIONS_URL: &str = "https://github.com/symposium-dev/reco
#[derive(Clone)]
pub struct Symposium {
pub config: Config,
config_dir: PathBuf,
cache_dir: PathBuf,
dirs: symposium_sdk::dirs::SymposiumDirs,
home_dir: PathBuf,
cargo_override: Option<PathBuf>,
}

impl Symposium {
Expand All @@ -219,25 +217,19 @@ impl Symposium {
/// 3. `~/.symposium`
pub fn from_environment() -> Self {
let home_dir = dirs::home_dir().expect("could not determine home directory");
let config_dir = resolve_config_dir_from_env();
let _ = fs::create_dir_all(&config_dir);

let config = load_config_from(&config_dir);
let dirs = symposium_sdk::dirs::SymposiumDirs::from_environment();
let _ = fs::create_dir_all(&dirs.config_dir);
let _ = fs::create_dir_all(&dirs.cache_dir);

let cache_dir = resolve_cache_dir(&config_dir);
let _ = fs::create_dir_all(&cache_dir);
let config = load_config_from(&dirs.config_dir);

// Note: can't use tracing here — logging isn't initialized yet.
// init_logging() is called after construction.

let cargo_override = env::var("SYMPOSIUM_CARGO").ok().map(PathBuf::from);

Self {
config,
config_dir,
cache_dir,
dirs,
home_dir,
cargo_override,
}
}

Expand All @@ -257,29 +249,44 @@ 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);

Self {
config,
config_dir,
cache_dir,
dirs,
home_dir,
cargo_override: None,
}
}

/// The resolved directory paths.
pub fn dirs(&self) -> &symposium_sdk::dirs::SymposiumDirs {
&self.dirs
}

/// The cargo binary override, if set via `SYMPOSIUM_CARGO`.
pub fn cargo_override(&self) -> Option<&Path> {
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)
}

/// Build a `Command` for the cargo binary.
///
/// Uses the test override if set, otherwise plain `"cargo"`.
pub fn cargo_command(&self) -> std::process::Command {
match &self.cargo_override {
match &self.dirs.cargo_override {
Some(path) => std::process::Command::new(path),
None => std::process::Command::new("cargo"),
}
}

/// Create an [`InstallContext`] for use with `symposium-install` functions.
pub fn install_context(&self) -> symposium_install::InstallContext {
let ctx = symposium_install::InstallContext::new(self.cache_dir.clone());
match &self.cargo_override {
let ctx = symposium_install::InstallContext::new(self.dirs.cache_dir.clone());
match &self.dirs.cargo_override {
Some(path) => ctx.with_cargo_bin(path.clone()),
None => ctx,
}
Expand All @@ -288,7 +295,7 @@ impl Symposium {
/// Override the cargo binary path (test-only).
#[doc(hidden)]
pub fn set_cargo_override(&mut self, path: PathBuf) {
self.cargo_override = Some(path);
self.dirs.cargo_override = Some(path);
}

/// Initialize logging with an optional report layer. Call once at startup.
Expand Down Expand Up @@ -334,8 +341,8 @@ impl Symposium {
.init();

tracing::debug!(
config_dir = %self.config_dir.display(),
cache_dir = %self.cache_dir.display(),
config_dir = %self.dirs.config_dir.display(),
cache_dir = %self.dirs.cache_dir.display(),
log_level = %level,
log_file = %log_path.display(),
"logging initialized"
Expand All @@ -344,11 +351,11 @@ impl Symposium {
}

pub fn config_dir(&self) -> &Path {
&self.config_dir
&self.dirs.config_dir
}

pub fn cache_dir(&self) -> &Path {
&self.cache_dir
&self.dirs.cache_dir
}

pub fn home_dir(&self) -> &Path {
Expand All @@ -367,7 +374,7 @@ impl Symposium {
path: None,
auto_update: true,
},
base_dir: self.config_dir.clone(),
base_dir: self.dirs.config_dir.clone(),
});
}

Expand All @@ -379,14 +386,14 @@ impl Symposium {
path: Some("plugins".to_string()),
auto_update: true,
},
base_dir: self.config_dir.clone(),
base_dir: self.dirs.config_dir.clone(),
});
}

for s in &self.config.plugin_source {
sources.push(ResolvedPluginSource {
source: s.clone(),
base_dir: self.config_dir.clone(),
base_dir: self.dirs.config_dir.clone(),
});
}

Expand All @@ -395,21 +402,21 @@ impl Symposium {

/// Write the user config to disk.
pub fn save_config(&self) -> anyhow::Result<()> {
let path = self.config_dir.join("config.toml");
let path = self.dirs.config_dir.join("config.toml");
let contents = toml::to_string_pretty(&self.config)?;
fs::write(&path, contents)?;
Ok(())
}

#[cfg(test)]
pub fn plugins_dir(&self) -> PathBuf {
let dir = self.config_dir.join("plugins");
let dir = self.dirs.config_dir.join("plugins");
let _ = fs::create_dir_all(&dir);
dir
}

fn logs_dir(&self) -> PathBuf {
let dir = resolve_logs_dir(&self.config_dir);
let dir = resolve_logs_dir(&self.dirs.config_dir);
let _ = fs::create_dir_all(&dir);
dir
}
Expand All @@ -429,17 +436,6 @@ impl Symposium {
}
}

/// Resolve config dir from environment variables.
fn resolve_config_dir_from_env() -> PathBuf {
if let Ok(home) = env::var("SYMPOSIUM_HOME") {
PathBuf::from(home)
} else if let Ok(xdg) = env::var("XDG_CONFIG_HOME") {
PathBuf::from(xdg).join("symposium")
} else {
default_home()
}
}

/// Resolve logs dir from environment.
///
/// Resolution: SYMPOSIUM_HOME/logs → XDG_STATE_HOME/symposium/logs → config_dir/logs.
Expand All @@ -453,17 +449,6 @@ fn resolve_logs_dir(config_dir: &Path) -> PathBuf {
config_dir.join("logs")
}

/// Resolve cache dir from environment.
fn resolve_cache_dir(config_dir: &Path) -> PathBuf {
if let Ok(home) = env::var("SYMPOSIUM_HOME") {
return PathBuf::from(home).join("cache");
}
if let Ok(xdg) = env::var("XDG_CACHE_HOME") {
return PathBuf::from(xdg).join("symposium");
}
config_dir.join("cache")
}

/// Load config from a config directory.
fn load_config_from(config_dir: &Path) -> Config {
let path = config_dir.join("config.toml");
Expand All @@ -476,13 +461,6 @@ fn load_config_from(config_dir: &Path) -> Config {
}
}

/// Returns the default symposium home directory (~/.symposium).
fn default_home() -> PathBuf {
dirs::home_dir()
.expect("could not determine home directory")
.join(".symposium")
}

fn default_true() -> bool {
true
}
Expand Down
7 changes: 4 additions & 3 deletions src/crate_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ pub enum DispatchResult {
}

pub async fn dispatch_crate(
_sym: &Symposium,
sym: &Symposium,
name: &str,
version: Option<&str>,
cwd: &Path,
) -> DispatchResult {
tracing::debug!(%name, ?version, "crate-info dispatched");
let workspace = crate_sources::workspace_crates(cwd);
let mut fetch = crate_sources::RustCrateFetch::new(name, &workspace);
let mut deps = sym.workspace_deps(cwd);
let workspace = deps.crates();
let mut fetch = crate_sources::RustCrateFetch::new(name, workspace);
if let Some(v) = version {
fetch = fetch.version(v);
}
Expand Down
Loading
Loading