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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ version = "1.0.0"
description = "Web development MCP stack"
authors = ["yourname"]
keywords = ["web", "browser", "git"]
min_tool_version = "0.4.0" # optional: minimum weave version required

[[servers]]
name = "puppeteer"
Expand All @@ -247,6 +248,7 @@ Packs can also declare:
- **Slash commands / skills** — copied into `~/.claude/commands/` or `~/.codex/skills/`
- **System prompt fragments** — appended to `CLAUDE.md` / `GEMINI.md` / `AGENTS.md` between tagged delimiters
- **Settings fragments** — deep-merged into Claude Code and Gemini CLI JSON settings; merged as top-level keys in Codex CLI's TOML config
- **Minimum tool version** — `min_tool_version` rejects install on older weave versions
- **Environment variable declarations** — written as `${VAR}` references, never values

> [!IMPORTANT]
Expand Down
113 changes: 104 additions & 9 deletions src/core/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ use crate::core::resolver::Resolver;
use crate::core::store::Store;
use crate::error::{Result, WeaveError};

/// Check that the current weave version satisfies the pack's `min_tool_version`.
///
/// Returns `Ok(())` if the pack has no `min_tool_version` requirement or if the
/// current version meets or exceeds it. Returns `Err(IncompatibleToolVersion)`
/// otherwise. This check runs in both normal and dry-run paths so users always
/// see version incompatibilities early.
pub fn check_min_tool_version(pack: &Pack) -> Result<()> {
if let Some(ref min_version) = pack.min_tool_version {
let current = semver::Version::parse(env!("CARGO_PKG_VERSION"))
.expect("CARGO_PKG_VERSION is always valid semver");
if current < *min_version {
return Err(WeaveError::IncompatibleToolVersion {
pack_name: pack.name.clone(),
required: min_version.clone(),
current,
});
}
}
Ok(())
}

/// Returns the names of adapters that a pack would target, based on the pack's
/// `targets` flags and which adapters are installed.
pub fn target_adapters(pack: &Pack, adapters: &[Box<dyn CliAdapter>]) -> Vec<String> {
Expand Down Expand Up @@ -124,6 +145,7 @@ pub fn install_from_registry(
reason: "registry release missing pack.toml".to_string(),
})?;
let pack = Pack::from_toml(pack_toml, &std::path::PathBuf::from("pack.toml"))?;
check_min_tool_version(&pack)?;

// Validate manifest matches what was resolved, same as the normal path.
if pack.name != *name {
Expand Down Expand Up @@ -177,6 +199,7 @@ pub fn install_from_registry(

// Load the pack manifest
let pack = Pack::load(&pack_dir)?;
check_min_tool_version(&pack)?;

// Validate that the manifest matches what was resolved.
if pack.name != *name {
Expand Down Expand Up @@ -303,6 +326,7 @@ pub fn install_local(
dry_run: bool,
) -> Result<LocalInstallResult> {
let pack = Pack::load(path)?;
check_min_tool_version(&pack)?;

let name = &pack.name;
let version = &pack.version;
Expand Down Expand Up @@ -612,6 +636,86 @@ mod tests {
use std::collections::HashSet;
use std::path::PathBuf;

/// Helper: build a Pack from TOML for testing.
fn pack_from_toml(toml: &str) -> Pack {
Pack::from_toml(toml, &PathBuf::from("test.toml")).unwrap()
}

// ── min_tool_version tests ──────────────────────────────────────────────

#[test]
fn check_min_tool_version_higher_than_current_errors() {
let toml = r#"
[pack]
name = "future-pack"
version = "1.0.0"
description = "Needs a future weave"
authors = ["tester"]
min_tool_version = "99.0.0"
"#;
let pack = pack_from_toml(toml);
let result = check_min_tool_version(&pack);
assert!(
result.is_err(),
"should fail when min_tool_version exceeds current"
);
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("99.0.0"),
"error should mention required version: {err_msg}"
);
assert!(
err_msg.contains("please upgrade"),
"error should tell user to upgrade: {err_msg}"
);
assert!(
err_msg.contains("future-pack"),
"error should mention pack name: {err_msg}"
);
}

#[test]
fn check_min_tool_version_equal_to_current_succeeds() {
let current_version = env!("CARGO_PKG_VERSION");
let toml = format!(
r#"
[pack]
name = "current-pack"
version = "1.0.0"
description = "Needs exactly the current weave"
authors = ["tester"]
min_tool_version = "{current_version}"
"#
);
let pack = pack_from_toml(&toml);
let result = check_min_tool_version(&pack);
assert!(
result.is_ok(),
"should succeed when min_tool_version equals current: {:?}",
result.err()
);
}

#[test]
fn check_min_tool_version_none_succeeds() {
let toml = r#"
[pack]
name = "no-min-pack"
version = "1.0.0"
description = "No min_tool_version specified"
authors = ["tester"]
"#;
let pack = pack_from_toml(toml);
let result = check_min_tool_version(&pack);
assert!(
result.is_ok(),
"should succeed when min_tool_version is absent: {:?}",
result.err()
);
}

// ── apply_to_adapters rollback tests ────────────────────────────────────

/// A mock adapter that succeeds on apply.
struct SucceedAdapter {
adapter_name: &'static str,
Expand Down Expand Up @@ -736,25 +840,18 @@ mod tests {

let (applied, errors) = apply_to_adapters(&resolved, &adapters, &options);

// applied should be empty because rollback occurred
assert!(
applied.is_empty(),
"expected empty applied list after rollback, got: {applied:?}"
);

// Should have errors: one for the failure, one for the rollback
assert!(
errors.len() >= 2,
"expected at least 2 errors (failure + rollback), got: {errors:?}"
);

// The first error should be from the failing adapter
assert!(
errors[0].contains("Gemini CLI"),
"first error should mention failing adapter: {errors:?}"
);

// The second error should mention the rollback
assert!(
errors[1].contains("rolled back"),
"second error should mention rollback: {errors:?}"
Expand All @@ -774,9 +871,7 @@ mod tests {

let (applied, errors) = apply_to_adapters(&resolved, &adapters, &options);

// applied should be empty — the first adapter failed before any succeeded
assert!(applied.is_empty());
// Only one error — the failure itself, no rollbacks needed
assert_eq!(errors.len(), 1);
assert!(errors[0].contains("Gemini CLI"));
}
Expand Down
18 changes: 16 additions & 2 deletions src/core/use_profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,20 @@ pub fn switch(
&installed.version,
Some(&installed.source),
) {
Ok(pack) => crate::core::install::target_adapters(&pack, adapters),
Err(_) => adapters.iter().map(|a| a.name().to_string()).collect(),
Ok(pack) => {
crate::core::install::check_min_tool_version(&pack)?;
crate::core::install::target_adapters(&pack, adapters)
}
Err(_) => {
// Pack not in local cache — version compatibility and target
// adapters cannot be verified in dry-run mode.
log::warn!(
"pack {}@{} not cached locally — dry-run preview may be approximate",
installed.name,
installed.version
);
adapters.iter().map(|a| a.name().to_string()).collect()
}
};
apply_result.applied_adapters = target_names;
} else {
Expand All @@ -265,6 +277,8 @@ pub fn switch(
}
};

crate::core::install::check_min_tool_version(&pack)?;

let resolved = ResolvedPack {
pack,
source: installed.source.clone(),
Expand Down
9 changes: 9 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ pub enum WeaveError {
#[error("pack '{name}' is not installed — {hint}")]
NotInstalled { name: String, hint: String },

#[error(
"pack '{pack_name}' requires weave {required} or later, but this is weave {current} — please upgrade"
)]
IncompatibleToolVersion {
pack_name: String,
required: semver::Version,
current: semver::Version,
},

// Install/update validation errors
#[error(
"pack manifest {field} '{actual}' does not match resolved {field} '{expected}' — the archive may be corrupt or tampered"
Expand Down
33 changes: 33 additions & 0 deletions tests/e2e/cli_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,3 +580,36 @@ async fn remove_http_server_cleans_up() {
"remote-api should be removed from ~/.claude.json after pack removal"
);
}

// ── min_tool_version enforcement ──────────────────────────────────────────────

#[cfg(not(target_os = "windows"))]
#[tokio::test]
async fn install_local_pack_with_incompatible_min_tool_version() {
let env = TestEnv::new().await;

// Create a local pack that requires a far-future weave version.
let pack_dir = env.project_dir.path().join("future-pack");
std::fs::create_dir_all(&pack_dir).unwrap();
std::fs::write(
pack_dir.join("pack.toml"),
r#"[pack]
name = "future-pack"
version = "0.1.0"
description = "requires future weave"
authors = ["tester"]
min_tool_version = "99.0.0"
"#,
)
.unwrap();

env.weave_cmd()
.args(["install", "./future-pack"])
.assert()
.failure()
.stderr(
predicate::str::contains("99.0.0")
.and(predicate::str::contains("please upgrade"))
.from_utf8(),
);
}
Loading