From 76ee5f5c19e783ac50f7601503f89c4b796543fa Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Sun, 31 May 2026 19:49:21 +0000 Subject: [PATCH 1/5] Add battery pack integration for skill matching Battery packs are crate bundles managed by `cargo bp` rather than declared as normal Cargo dependencies. Plugins can now reference them in predicates (e.g. `crates = ["cli-battery-pack"]`) and symposium will discover installed packs by acquiring the `cargo-bp` binary into its cache and running `cargo bp status --json`. The discovery is gracefully skipped when `cargo-bp` is unavailable, so projects without battery packs see no change in behavior. Co-authored-by: Claude --- Cargo.lock | 13 ++ Cargo.toml | 1 + md/design/module-structure.md | 4 + src/config.rs | 15 ++ src/crate_sources/battery_pack.rs | 150 ++++++++++++++++++ src/crate_sources/list.rs | 1 + src/crate_sources/mod.rs | 2 + src/sync.rs | 12 +- symposium-testlib/Cargo.toml | 1 + symposium-testlib/src/lib.rs | 26 +++ tests/fixtures/battery-pack0/Cargo.toml | 4 + .../battery-pack0/dot-symposium/config.toml | 5 + .../plugins/bp-plugin/SYMPOSIUM.toml | 5 + .../bp-plugin/cli-bp-guidance/SKILL.md | 6 + tests/fixtures/battery-pack0/src/lib.rs | 0 tests/init_sync.rs | 67 ++++++++ 16 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 src/crate_sources/battery_pack.rs create mode 100644 tests/fixtures/battery-pack0/Cargo.toml create mode 100644 tests/fixtures/battery-pack0/dot-symposium/config.toml create mode 100644 tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml create mode 100644 tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md create mode 100644 tests/fixtures/battery-pack0/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 93ad2d1f..52acc031 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -238,6 +238,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "cargo-bp-script" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "270ddcb68d819bdb04ec316aaa5ff82b95b798323fb8598c8d9a6142350ae944" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "cargo-platform" version = "0.1.9" @@ -2429,6 +2440,7 @@ dependencies = [ "anyhow", "assert_matches", "bytes", + "cargo-bp-script", "cargo_metadata", "chrono", "clap", @@ -2498,6 +2510,7 @@ version = "0.1.0" dependencies = [ "acpr", "anyhow", + "cargo-bp-script", "clap", "sacp", "sacp-tokio", diff --git a/Cargo.toml b/Cargo.toml index 16679fba..85485188 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ dialoguer = "0.12.0" toml_edit = "0.25.11" url = "2.5.8" symposium-install = { version = "0.1.0", path = "symposium-install", features = ["clap"] } +cargo-bp-script = "0.1.0" [dev-dependencies] diff --git a/md/design/module-structure.md b/md/design/module-structure.md index 5bbd37f5..5cd3f301 100644 --- a/md/design/module-structure.md +++ b/md/design/module-structure.md @@ -73,6 +73,10 @@ Manages `state.toml` in the config directory. Tracks the semver of the binary th Implements `cargo agents self-update`. Queries the registry for the latest published version via `cargo search`, then installs it via `cargo install symposium --force`. Also provides `re_exec()` which replaces the current process with the newly installed binary (Unix `exec`, spawn-and-exit on Windows) — used by the `auto-update = "on"` startup path. Contains `maybe_warn_for_update()` (sync, for the `warn` library path) and `maybe_check_for_update()` (async, for the binary `on` + re-exec path). +### `crate_sources/` — workspace dependency discovery and fetching + +Houses `WorkspaceCrate` (the representation of a crate in the workspace graph) and the `RustCrateFetch` builder for downloading crate sources. `workspace_crates()` runs `cargo metadata` to collect direct dependencies. `discover_battery_packs()` is the async companion: it acquires the `cargo-bp` binary into symposium's binary cache (via `acquire_source`), then runs `cargo bp status --json` (using the `cargo-bp-script` crate) to find installed battery packs. Battery pack crates (names ending in `-battery-pack`) aren't normal Cargo dependencies — they're managed by the `cargo bp` tool — but plugin predicates reference them by name, so `sync` merges them into the workspace crate list for matching purposes. If acquisition or execution fails, battery pack discovery is silently skipped. + ### `crate_command.rs` — crate source lookup Contains `dispatch_crate()`, which resolves a crate's version and fetches its source code. Called by the CLI's `crate-info` command. Path dependencies are resolved to their local source directory via `WorkspaceCrate.path`. diff --git a/src/config.rs b/src/config.rs index a5ef7bff..5794dad9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -202,6 +202,7 @@ pub struct Symposium { cache_dir: PathBuf, home_dir: PathBuf, cargo_override: Option, + cargo_bp_override: Option, } impl Symposium { @@ -225,6 +226,7 @@ impl Symposium { // init_logging() is called after construction. let cargo_override = env::var("SYMPOSIUM_CARGO").ok().map(PathBuf::from); + let cargo_bp_override = env::var("SYMPOSIUM_CARGO_BP").ok().map(PathBuf::from); Self { config, @@ -232,6 +234,7 @@ impl Symposium { cache_dir, home_dir, cargo_override, + cargo_bp_override, } } @@ -257,6 +260,7 @@ impl Symposium { cache_dir, home_dir, cargo_override: None, + cargo_bp_override: None, } } @@ -285,6 +289,17 @@ impl Symposium { self.cargo_override = Some(path); } + /// Return the `cargo-bp` binary override path, if configured. + pub fn cargo_bp_override(&self) -> Option<&Path> { + self.cargo_bp_override.as_deref() + } + + /// Override the `cargo-bp` binary path (test-only). + #[doc(hidden)] + pub fn set_cargo_bp_override(&mut self, path: PathBuf) { + self.cargo_bp_override = Some(path); + } + /// Initialize logging. Call once at startup. pub fn init_logging(&self) { use std::fs::OpenOptions; diff --git a/src/crate_sources/battery_pack.rs b/src/crate_sources/battery_pack.rs new file mode 100644 index 00000000..13b80fba --- /dev/null +++ b/src/crate_sources/battery_pack.rs @@ -0,0 +1,150 @@ +//! Battery pack discovery via `cargo bp status --json`. +//! +//! Battery packs are crate bundles managed by the `cargo bp` tool rather than +//! declared as normal Cargo dependencies. To let plugin predicates reference +//! them by name (e.g. `crates: cli-battery-pack`), we run `cargo bp status` +//! and inject the results into the workspace crate list. + +use std::path::Path; + +use crate::config::Symposium; +use crate::installation::{self, CargoSource}; + +use super::list::WorkspaceCrate; + +/// Acquire the `cargo-bp` binary (installing if needed) and run +/// `cargo bp status --json` to discover installed battery packs. +/// +/// Returns one `WorkspaceCrate` per installed battery pack. These are +/// virtual entries — battery packs aren't normal Cargo dependencies, but +/// plugin predicates reference them by crate name so they must appear in +/// the workspace crate list for matching to work. +/// +/// If acquisition or execution fails, returns an empty list (logged at +/// debug level) so the rest of sync proceeds unaffected. +pub async fn discover_battery_packs(sym: &Symposium, cwd: &Path) -> Vec { + let binary_path = if let Some(override_path) = sym.cargo_bp_override() { + override_path.to_path_buf() + } else { + match acquire_cargo_bp(sym).await { + Ok(path) => path, + Err(e) => { + tracing::debug!(error = %e, "failed to acquire cargo-bp, skipping battery pack discovery"); + return Vec::new(); + } + } + }; + + let report = match cargo_bp_script::StatusCommand::new() + .program(&binary_path) + .cwd(cwd) + .run() + { + Ok(r) => r, + Err(e) => { + tracing::debug!(error = %e, "cargo bp status failed, skipping battery pack discovery"); + return Vec::new(); + } + }; + + battery_packs_from_report(report) +} + +/// Ensure `cargo-bp` is installed in symposium's binary cache and return the +/// path to the binary. +async fn acquire_cargo_bp(sym: &Symposium) -> anyhow::Result { + let source = CargoSource { + crate_name: "cargo-bp".to_string(), + version: None, + git: None, + }; + + let (cache_dir, resolved) = + installation::acquire_source(sym, &installation::Source::Cargo(source), Some("cargo-bp")) + .await + .map(|acquired| (acquired.base, acquired.resolved_executable))?; + + let binary_name = resolved.unwrap_or_else(|| "cargo-bp".to_string()); + Ok(cache_dir + .join("bin") + .join(installation::platform_binary_exe(&binary_name))) +} + +fn battery_packs_from_report(report: cargo_bp_script::StatusReport) -> Vec { + report + .packs + .into_iter() + .filter_map(|pack| { + semver::Version::parse(&pack.version) + .ok() + .map(|version| WorkspaceCrate { + name: pack.name, + version, + path: None, + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn battery_packs_from_report_converts_installed_packs() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")) + .with_pack(InstalledPackStatus::new( + "cli", + "cli-battery-pack", + "0.3.0", + )) + .with_pack(InstalledPackStatus::new( + "error", + "error-battery-pack", + "0.2.0", + )); + + let crates = battery_packs_from_report(report); + assert_eq!(crates.len(), 2); + + assert_eq!(crates[0].name, "cli-battery-pack"); + assert_eq!(crates[0].version, semver::Version::new(0, 3, 0)); + assert!(crates[0].path.is_none()); + + assert_eq!(crates[1].name, "error-battery-pack"); + assert_eq!(crates[1].version, semver::Version::new(0, 2, 0)); + assert!(crates[1].path.is_none()); + } + + #[test] + fn battery_packs_from_report_skips_unparseable_versions() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")) + .with_pack(InstalledPackStatus::new( + "bad", + "bad-battery-pack", + "not-a-version", + )) + .with_pack(InstalledPackStatus::new( + "good", + "good-battery-pack", + "1.0.0", + )); + + let crates = battery_packs_from_report(report); + assert_eq!(crates.len(), 1); + assert_eq!(crates[0].name, "good-battery-pack"); + } + + #[test] + fn battery_packs_from_report_empty_report() { + use cargo_bp_script::{ProjectInfo, StatusReport}; + + let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")); + let crates = battery_packs_from_report(report); + assert!(crates.is_empty()); + } +} diff --git a/src/crate_sources/list.rs b/src/crate_sources/list.rs index 62621a02..4ed2354a 100644 --- a/src/crate_sources/list.rs +++ b/src/crate_sources/list.rs @@ -82,3 +82,4 @@ fn load_workspace_crates(cwd: &Path) -> Result> { Ok(crates) } + diff --git a/src/crate_sources/mod.rs b/src/crate_sources/mod.rs index 48dc5981..1714ec65 100644 --- a/src/crate_sources/mod.rs +++ b/src/crate_sources/mod.rs @@ -9,9 +9,11 @@ use std::path::PathBuf; use anyhow::Result; +mod battery_pack; mod list; mod probe; +pub use battery_pack::discover_battery_packs; pub use list::{WorkspaceCrate, workspace_crates}; /// Normalize a crate name for hyphen/underscore-insensitive comparison. diff --git a/src/sync.rs b/src/sync.rs index 3984e428..35698ca0 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -174,7 +174,17 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { // Load plugin registry and workspace deps let registry = plugins::load_registry(sym); - let workspace = crate::crate_sources::workspace_crates(&project_root); + let mut workspace = crate::crate_sources::workspace_crates(&project_root); + + // Augment with installed battery packs. + let battery_packs = + crate::crate_sources::discover_battery_packs(sym, &project_root).await; + for bp in battery_packs { + if !workspace.iter().any(|c| c.name == bp.name) { + workspace.push(bp); + } + } + workspace.sort_by(|a, b| a.name.cmp(&b.name)); for warning in ®istry.warnings { out.warn(format!( diff --git a/symposium-testlib/Cargo.toml b/symposium-testlib/Cargo.toml index e4527655..8731d6bd 100644 --- a/symposium-testlib/Cargo.toml +++ b/symposium-testlib/Cargo.toml @@ -8,6 +8,7 @@ repository = "https://github.com/symposium-dev/symposium" publish = false [dependencies] +cargo-bp-script = "0.1.0" clap = { version = "4", features = ["derive"] } serde = "1" serde_json = "1" diff --git a/symposium-testlib/src/lib.rs b/symposium-testlib/src/lib.rs index 05e1a583..f12743ab 100644 --- a/symposium-testlib/src/lib.rs +++ b/symposium-testlib/src/lib.rs @@ -452,6 +452,32 @@ impl TestContext { self.sym.set_cargo_override(script_path); } + /// Point `cargo-bp` invocations at a mock script for the duration of this test. + /// + /// Writes the given shell script into the tempdir and configures `Symposium` + /// to use it instead of acquiring the real `cargo-bp`. + pub fn set_mock_cargo_bp(&mut self, script: &str) { + let script_path = self.tempdir.join("mock-cargo-bp"); + std::fs::write(&script_path, script).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + self.sym.set_cargo_bp_override(script_path); + } + + /// Configure a typed mock for `cargo bp status --json`. + /// + /// Serializes the given `StatusReport` to JSON and writes a shell script + /// that outputs it. This ensures the mock produces output that matches + /// the `cargo-bp-script` serialization format exactly. + pub fn set_mock_cargo_bp_status(&mut self, report: &cargo_bp_script::StatusReport) { + let json = serde_json::to_string(report).expect("StatusReport serializes"); + let script = format!("#!/bin/sh\nprintf '%s' '{}'\n", json.replace('\'', "'\\''")); + self.set_mock_cargo_bp(&script); + } + /// Replace variable content with stable placeholders for snapshot tests. pub fn normalize_paths(&self, output: &str) -> String { let config_dir = self.sym.config_dir().to_string_lossy().to_string(); diff --git a/tests/fixtures/battery-pack0/Cargo.toml b/tests/fixtures/battery-pack0/Cargo.toml new file mode 100644 index 00000000..889f5ba4 --- /dev/null +++ b/tests/fixtures/battery-pack0/Cargo.toml @@ -0,0 +1,4 @@ +[package] +name = "bp-test-project" +version = "0.1.0" +edition = "2021" diff --git a/tests/fixtures/battery-pack0/dot-symposium/config.toml b/tests/fixtures/battery-pack0/dot-symposium/config.toml new file mode 100644 index 00000000..777c5d87 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/config.toml @@ -0,0 +1,5 @@ +hook-scope = "project" + +[defaults] +symposium-recommendations = false +user-plugins = true diff --git a/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml new file mode 100644 index 00000000..032eb372 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/SYMPOSIUM.toml @@ -0,0 +1,5 @@ +name = "bp-plugin" +crates = ["cli-battery-pack"] + +[[skills]] +source.path = "." diff --git a/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md new file mode 100644 index 00000000..69cf09f5 --- /dev/null +++ b/tests/fixtures/battery-pack0/dot-symposium/plugins/bp-plugin/cli-bp-guidance/SKILL.md @@ -0,0 +1,6 @@ +--- +name: cli-bp-guidance +description: Guidance for cli-battery-pack users +--- + +Use the CLI battery pack for building command-line tools. diff --git a/tests/fixtures/battery-pack0/src/lib.rs b/tests/fixtures/battery-pack0/src/lib.rs new file mode 100644 index 00000000..e69de29b diff --git a/tests/init_sync.rs b/tests/init_sync.rs index c02e56e4..11cf91b6 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -1687,6 +1687,7 @@ async fn session_start_hook_warns_about_update_in_context() { .unwrap(); } + #[tokio::test] async fn auto_sync_skips_when_cargo_lock_unchanged() { with_fixture( @@ -2095,3 +2096,69 @@ async fn sync_crate_metadata_missing_path_dir() { .await .unwrap(); } + +/// `sync` installs skills for battery packs discovered via `cargo bp status`. +/// +/// The battery pack crate is not a normal Cargo dependency — it's reported +/// by a mock `cargo-bp` binary, and the plugin's `crates` predicate matches +/// against that. +#[tokio::test] +async fn sync_installs_skill_for_battery_pack() { + use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; + + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + let report = StatusReport::new(ProjectInfo::new("Cargo.toml")).with_pack( + InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0"), + ); + ctx.set_mock_cargo_bp_status(&report); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skill_dir = + find_installed_skill(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); + let content = std::fs::read_to_string(skill_dir.join("SKILL.md"))?; + assert!(content.contains("CLI battery pack")); + assert!(skill_dir.join(".symposium").exists()); + Ok(()) + }, + ) + .await + .unwrap(); +} + +/// `sync` does NOT install battery-pack skills when `cargo bp` reports +/// the pack is not installed. +#[tokio::test] +async fn sync_skips_skill_when_battery_pack_not_installed() { + use cargo_bp_script::{ProjectInfo, StatusReport}; + + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + let report = StatusReport::new(ProjectInfo::new("Cargo.toml")); + ctx.set_mock_cargo_bp_status(&report); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skills = find_installed_skills( + &workspace_root.join(".claude/skills"), + "cli-bp-guidance", + ); + assert!( + skills.is_empty(), + "skill should NOT be installed when battery pack is not present" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} From 210fa31ba944436a6f29990a87483bea2db8e883 Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Sun, 31 May 2026 19:56:56 +0000 Subject: [PATCH 2/5] chore: pacify the merciless cargo-fmt --- src/crate_sources/battery_pack.rs | 6 +----- src/crate_sources/list.rs | 1 - src/sync.rs | 3 +-- tests/init_sync.rs | 11 ++++------- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/crate_sources/battery_pack.rs b/src/crate_sources/battery_pack.rs index 13b80fba..e5a64e07 100644 --- a/src/crate_sources/battery_pack.rs +++ b/src/crate_sources/battery_pack.rs @@ -95,11 +95,7 @@ mod tests { use cargo_bp_script::{InstalledPackStatus, ProjectInfo, StatusReport}; let report = StatusReport::new(ProjectInfo::new("/tmp/Cargo.toml")) - .with_pack(InstalledPackStatus::new( - "cli", - "cli-battery-pack", - "0.3.0", - )) + .with_pack(InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0")) .with_pack(InstalledPackStatus::new( "error", "error-battery-pack", diff --git a/src/crate_sources/list.rs b/src/crate_sources/list.rs index 4ed2354a..62621a02 100644 --- a/src/crate_sources/list.rs +++ b/src/crate_sources/list.rs @@ -82,4 +82,3 @@ fn load_workspace_crates(cwd: &Path) -> Result> { Ok(crates) } - diff --git a/src/sync.rs b/src/sync.rs index 35698ca0..3b3c5189 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -177,8 +177,7 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { let mut workspace = crate::crate_sources::workspace_crates(&project_root); // Augment with installed battery packs. - let battery_packs = - crate::crate_sources::discover_battery_packs(sym, &project_root).await; + let battery_packs = crate::crate_sources::discover_battery_packs(sym, &project_root).await; for bp in battery_packs { if !workspace.iter().any(|c| c.name == bp.name) { workspace.push(bp); diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 11cf91b6..1b4372e1 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2110,9 +2110,8 @@ async fn sync_installs_skill_for_battery_pack() { TestMode::SimulationOnly, &["battery-pack0"], async |mut ctx| { - let report = StatusReport::new(ProjectInfo::new("Cargo.toml")).with_pack( - InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0"), - ); + let report = StatusReport::new(ProjectInfo::new("Cargo.toml")) + .with_pack(InstalledPackStatus::new("cli", "cli-battery-pack", "0.3.0")); ctx.set_mock_cargo_bp_status(&report); ctx.symposium(&["init", "--add-agent", "claude"]).await?; @@ -2148,10 +2147,8 @@ async fn sync_skips_skill_when_battery_pack_not_installed() { ctx.symposium(&["sync"]).await?; let workspace_root = ctx.workspace_root.as_ref().unwrap(); - let skills = find_installed_skills( - &workspace_root.join(".claude/skills"), - "cli-bp-guidance", - ); + let skills = + find_installed_skills(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); assert!( skills.is_empty(), "skill should NOT be installed when battery pack is not present" From aad9024df73112fb8696dfe40f1bbf443b7907ea Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Mon, 1 Jun 2026 09:07:24 +0000 Subject: [PATCH 3/5] Address review feedback on battery pack integration - Use `normalize_crate_name` when deduplicating battery packs against workspace crates (hyphen/underscore insensitivity) - Destructure `AcquiredSource` after await instead of `.map()` on Result - Wrap `StatusCommand::run()` in `spawn_blocking` to avoid blocking the async runtime - Add integration test for graceful degradation when cargo-bp is unavailable Co-authored-by: Claude --- src/crate_sources/battery_pack.rs | 32 ++++++++++++++++++++----------- src/sync.rs | 6 +++++- tests/init_sync.rs | 28 +++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 12 deletions(-) diff --git a/src/crate_sources/battery_pack.rs b/src/crate_sources/battery_pack.rs index e5a64e07..34432bbd 100644 --- a/src/crate_sources/battery_pack.rs +++ b/src/crate_sources/battery_pack.rs @@ -35,16 +35,24 @@ pub async fn discover_battery_packs(sym: &Symposium, cwd: &Path) -> Vec r, - Err(e) => { + Ok(Ok(r)) => r, + Ok(Err(e)) => { tracing::debug!(error = %e, "cargo bp status failed, skipping battery pack discovery"); return Vec::new(); } + Err(e) => { + tracing::debug!(error = %e, "cargo bp status task panicked, skipping battery pack discovery"); + return Vec::new(); + } }; battery_packs_from_report(report) @@ -59,13 +67,15 @@ async fn acquire_cargo_bp(sym: &Symposium) -> anyhow::Result git: None, }; - let (cache_dir, resolved) = + let acquired = installation::acquire_source(sym, &installation::Source::Cargo(source), Some("cargo-bp")) - .await - .map(|acquired| (acquired.base, acquired.resolved_executable))?; + .await?; - let binary_name = resolved.unwrap_or_else(|| "cargo-bp".to_string()); - Ok(cache_dir + let binary_name = acquired + .resolved_executable + .unwrap_or_else(|| "cargo-bp".to_string()); + Ok(acquired + .base .join("bin") .join(installation::platform_binary_exe(&binary_name))) } diff --git a/src/sync.rs b/src/sync.rs index 3b3c5189..1802d28d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -179,7 +179,11 @@ pub async fn sync(sym: &Symposium, cwd: &Path, out: &Output) -> Result<()> { // Augment with installed battery packs. let battery_packs = crate::crate_sources::discover_battery_packs(sym, &project_root).await; for bp in battery_packs { - if !workspace.iter().any(|c| c.name == bp.name) { + let normalized = crate::crate_sources::normalize_crate_name(&bp.name); + if !workspace + .iter() + .any(|c| crate::crate_sources::normalize_crate_name(&c.name) == normalized) + { workspace.push(bp); } } diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 1b4372e1..7c50f1b1 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -2159,3 +2159,31 @@ async fn sync_skips_skill_when_battery_pack_not_installed() { .await .unwrap(); } + +/// `sync` proceeds gracefully when `cargo-bp` is unavailable (e.g. network +/// down, binary not found). Battery-pack skills are simply skipped. +#[tokio::test] +async fn sync_graceful_when_cargo_bp_unavailable() { + with_fixture( + TestMode::SimulationOnly, + &["battery-pack0"], + async |mut ctx| { + // Point at a nonexistent binary to simulate acquisition failure. + ctx.set_mock_cargo_bp("#!/bin/sh\nexit 1\n"); + + ctx.symposium(&["init", "--add-agent", "claude"]).await?; + ctx.symposium(&["sync"]).await?; + + let workspace_root = ctx.workspace_root.as_ref().unwrap(); + let skills = + find_installed_skills(&workspace_root.join(".claude/skills"), "cli-bp-guidance"); + assert!( + skills.is_empty(), + "skill should NOT be installed when cargo-bp fails" + ); + Ok(()) + }, + ) + .await + .unwrap(); +} From 22baa0cc52ba9bb3eccf72a74abbb33936a522e6 Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Wed, 3 Jun 2026 23:16:04 +0000 Subject: [PATCH 4/5] chore: apply fix --- src/crate_sources/battery_pack.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/crate_sources/battery_pack.rs b/src/crate_sources/battery_pack.rs index 34432bbd..9475fd09 100644 --- a/src/crate_sources/battery_pack.rs +++ b/src/crate_sources/battery_pack.rs @@ -8,7 +8,6 @@ use std::path::Path; use crate::config::Symposium; -use crate::installation::{self, CargoSource}; use super::list::WorkspaceCrate; @@ -61,23 +60,24 @@ pub async fn discover_battery_packs(sym: &Symposium, cwd: &Path) -> Vec anyhow::Result { - let source = CargoSource { - crate_name: "cargo-bp".to_string(), - version: None, - git: None, - }; + let source = symposium_install::CargoSource::new("cargo-bp"); - let acquired = - installation::acquire_source(sym, &installation::Source::Cargo(source), Some("cargo-bp")) - .await?; + let acquired = symposium_install::acquire_source( + &sym.install_context(), + &symposium_install::Source::Cargo(source), + Some("cargo-bp"), + ) + .await?; let binary_name = acquired .resolved_executable .unwrap_or_else(|| "cargo-bp".to_string()); - Ok(acquired - .base - .join("bin") - .join(installation::platform_binary_exe(&binary_name))) + let exe_name = if cfg!(windows) { + format!("{binary_name}.exe") + } else { + binary_name + }; + Ok(acquired.base.join("bin").join(exe_name)) } fn battery_packs_from_report(report: cargo_bp_script::StatusReport) -> Vec { From 24a8b21e4094c3cd429c6029b246262d147e391b Mon Sep 17 00:00:00 2001 From: Niko Matsakis Date: Thu, 4 Jun 2026 00:16:40 +0000 Subject: [PATCH 5/5] chore: pacify the merciless fmt --- tests/init_sync.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/init_sync.rs b/tests/init_sync.rs index 7c50f1b1..476389b0 100644 --- a/tests/init_sync.rs +++ b/tests/init_sync.rs @@ -1687,7 +1687,6 @@ async fn session_start_hook_warns_about_update_in_context() { .unwrap(); } - #[tokio::test] async fn auto_sync_skips_when_cargo_lock_unchanged() { with_fixture(