From f6a03a4e156a57fd002cfd3e5a6ad13a259047f4 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 12:25:23 -0700 Subject: [PATCH 1/9] feat(release): add shared agent-skill provenance state and freshness helpers New baml_release::skills module shared by the wrapper and baml-cli: skill repo URL builders (env-overridable for mirrors/tests), [skills] provenance state in state.toml written via read-modify-write so it never clobbers the wrapper-owned [channels.*] sections, a latest-commit freshness cache, and a GitHub API fetch for the skill repo head commit. Co-authored-by: Cursor --- baml_language/Cargo.lock | 2 + baml_language/crates/baml_release/Cargo.toml | 4 + baml_language/crates/baml_release/src/lib.rs | 1 + .../crates/baml_release/src/skills.rs | 280 ++++++++++++++++++ 4 files changed, 287 insertions(+) create mode 100644 baml_language/crates/baml_release/src/skills.rs diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index f6336e8e2d8..81d46542dca 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -1103,7 +1103,9 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", "thiserror 2.0.18", + "toml 0.8.23", "zip", ] diff --git a/baml_language/crates/baml_release/Cargo.toml b/baml_language/crates/baml_release/Cargo.toml index 4510b6c3adb..761cede1924 100644 --- a/baml_language/crates/baml_release/Cargo.toml +++ b/baml_language/crates/baml_release/Cargo.toml @@ -25,7 +25,11 @@ serde_json = { workspace = true } sha2 = { workspace = true } tar = { workspace = true } thiserror = { workspace = true } +toml = { workspace = true } zip = { workspace = true } +[dev-dependencies] +tempfile = { workspace = true } + [package.metadata.ci] wasm_support = false diff --git a/baml_language/crates/baml_release/src/lib.rs b/baml_language/crates/baml_release/src/lib.rs index bc50a1efe1b..405487025d5 100644 --- a/baml_language/crates/baml_release/src/lib.rs +++ b/baml_language/crates/baml_release/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::print_stdout, clippy::print_stderr)] pub mod manifest; +pub mod skills; use std::{ fs::{self, OpenOptions}, diff --git a/baml_language/crates/baml_release/src/skills.rs b/baml_language/crates/baml_release/src/skills.rs new file mode 100644 index 00000000000..64a8b69f4d4 --- /dev/null +++ b/baml_language/crates/baml_release/src/skills.rs @@ -0,0 +1,280 @@ +//! Shared state and network helpers for BAML agent skills. +//! +//! Agent skills are markdown files installed into user projects from the +//! skill repo (`BoundaryML/baml-skill`) head. Unlike toolchains they have a +//! single track (the repo's `main` branch) and are identified by git commit +//! SHA rather than a released version. +//! +//! This module is the single source of truth shared by the `baml` wrapper +//! (which surfaces "skill outdated" warnings) and the `baml-cli` toolchain +//! binary (whose `agent install` command records what it installed). + +use std::{ + fs, + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::baml_home; + +pub const DEFAULT_SKILL_REPO: &str = "BoundaryML/baml-skill"; + +/// The GitHub repo skills are installed from, `BAML_AGENT_SKILLS_REPO` +/// overridable for forks and tests. +pub fn skill_repo() -> String { + std::env::var("BAML_AGENT_SKILLS_REPO") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_SKILL_REPO.to_string()) +} + +/// URL that resolves the current head commit of the skill repo's main branch. +/// `BAML_AGENT_SKILLS_COMMITS_URL` overrides the full URL (for tests and +/// mirrors); the response must be GitHub-commit-shaped JSON: `{"sha": "..."}`. +pub fn skill_commits_url() -> String { + std::env::var("BAML_AGENT_SKILLS_COMMITS_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| format!("https://api.github.com/repos/{}/commits/main", skill_repo())) +} + +/// Tarball URL for the skill repo at a specific commit (or branch name). +/// `BAML_AGENT_SKILLS_ARCHIVE_BASE_URL` overrides the codeload host (for +/// tests and mirrors); the commit ref is appended as the final path segment. +pub fn skill_archive_url(commit_ref: &str) -> String { + if let Some(base) = std::env::var("BAML_AGENT_SKILLS_ARCHIVE_BASE_URL") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + return format!("{}/{commit_ref}", base.trim_end_matches('/')); + } + format!( + "https://codeload.github.com/{}/tar.gz/{commit_ref}", + skill_repo() + ) +} + +/// `~/.baml/state.toml`: the file recording resolved toolchain channel +/// versions (owned by the wrapper) and installed skill provenance (written by +/// `baml agent install`). Skills use read-modify-write on the raw TOML so the +/// two writers never clobber each other's sections. +pub fn state_path() -> PathBuf { + baml_home().join("state.toml") +} + +/// Cache file recording the latest known skill repo commit, refreshed at most +/// once per TTL window by the wrapper's auto-check and by explicit toolchain +/// commands. Lives next to the toolchain manifest cache. +pub fn latest_skill_commit_cache_path() -> PathBuf { + baml_home() + .join("manifest-cache") + .join("skills") + .join("latest-commit.json") +} + +/// Installed skill provenance, stored as the `[skills]` section of +/// `state.toml`, mirroring how `[channels.]` records the resolved +/// toolchain version: +/// +/// ```toml +/// [skills] +/// installed_commit = "abc123..." +/// installed_at = "2026-07-10T18:30:00Z" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillsState { + pub installed_commit: String, + pub installed_at: String, +} + +pub fn read_skills_state(state_path: &Path) -> Option { + let text = fs::read_to_string(state_path).ok()?; + let value = text.parse::().ok()?; + value.get("skills")?.clone().try_into().ok() +} + +/// Write the `[skills]` section of `state.toml`, preserving every other +/// section (notably the wrapper-owned `[channels.*]`). +pub fn write_skills_state(state_path: &Path, skills: &SkillsState) -> Result<()> { + let mut root = fs::read_to_string(state_path) + .ok() + .and_then(|text| text.parse::().ok()) + .unwrap_or_else(|| toml::Value::Table(toml::value::Table::new())); + let table = root + .as_table_mut() + .context("state.toml root is not a table")?; + table.insert( + "skills".to_string(), + toml::Value::try_from(skills).context("failed to serialize skills state")?, + ); + let text = toml::to_string_pretty(&root).context("failed to serialize state.toml")?; + write_text_atomic(state_path, &text) +} + +#[derive(Debug, Serialize, Deserialize)] +struct LatestSkillCommitCache { + sha: String, +} + +pub fn read_cached_latest_skill_commit(cache_path: &Path) -> Option { + let text = fs::read_to_string(cache_path).ok()?; + let cache: LatestSkillCommitCache = serde_json::from_str(&text).ok()?; + Some(cache.sha) +} + +pub fn write_cached_latest_skill_commit(cache_path: &Path, sha: &str) -> Result<()> { + let text = serde_json::to_string(&LatestSkillCommitCache { + sha: sha.to_string(), + })?; + write_text_atomic(cache_path, &text) +} + +/// Resolve the latest commit SHA of the skill repo's main branch over the +/// network. Callers decide the timeout: explicit commands can afford a long +/// one, while the wrapper's passive auto-check should use a short one. +pub fn fetch_latest_skill_commit(timeout: Duration) -> Result { + let url = skill_commits_url(); + let client = reqwest::blocking::Client::builder() + .connect_timeout(timeout.min(Duration::from_secs(10))) + .timeout(timeout) + .user_agent("baml-skill-check/1") + .build() + .context("failed to build HTTP client")?; + let response = client + .get(&url) + .header("Accept", "application/vnd.github+json") + .send() + .with_context(|| format!("failed to fetch {url}"))? + .error_for_status() + .with_context(|| format!("failed to fetch {url}"))?; + let text = response + .text() + .with_context(|| format!("failed to read {url}"))?; + let commit: LatestSkillCommitCache = serde_json::from_str(&text) + .with_context(|| format!("{url} did not return commit JSON with a sha field"))?; + if commit.sha.trim().is_empty() { + anyhow::bail!("{url} returned an empty commit sha"); + } + Ok(commit.sha) +} + +fn write_text_atomic(path: &Path, text: &str) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create {}", parent.display()))?; + } + let tmp = path.with_extension("tmp"); + fs::write(&tmp, text).with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, path).with_context(|| format!("failed to replace {}", path.display()))?; + Ok(()) +} + +/// Current UTC time as an RFC 3339 timestamp (second precision), used for the +/// `installed_at` provenance field without pulling in a datetime dependency. +pub fn utc_now_rfc3339() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let days = (secs / 86_400) as i64; + let time_of_day = secs % 86_400; + let (year, month, day) = civil_from_days(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + time_of_day / 3600, + (time_of_day % 3600) / 60, + time_of_day % 60 + ) +} + +/// Convert days since 1970-01-01 to a (year, month, day) civil date. +/// Howard Hinnant's `civil_from_days` algorithm. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let year = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = (doy - (153 * mp + 2) / 5 + 1) as u32; + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if month <= 2 { year + 1 } else { year }, month, day) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skills_state_roundtrip_preserves_other_sections() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("state.toml"); + fs::write( + &path, + "[channels.canary]\nactive_version = \"0.11.0\"\nresolved_at = \"2026-07-01T00:00:00Z\"\nmanifest_path = \"x\"\n", + ) + .unwrap(); + + let skills = SkillsState { + installed_commit: "abc123".to_string(), + installed_at: "2026-07-10T00:00:00Z".to_string(), + }; + write_skills_state(&path, &skills).unwrap(); + + let read = read_skills_state(&path).unwrap(); + assert_eq!(read.installed_commit, "abc123"); + assert_eq!(read.installed_at, "2026-07-10T00:00:00Z"); + + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("[channels.canary]"), "{text}"); + assert!(text.contains("active_version = \"0.11.0\""), "{text}"); + } + + #[test] + fn skills_state_missing_file_or_section_is_none() { + let tmp = tempfile::tempdir().unwrap(); + let missing = tmp.path().join("missing.toml"); + assert!(read_skills_state(&missing).is_none()); + + let path = tmp.path().join("state.toml"); + fs::write(&path, "[channels.canary]\nactive_version = \"1\"\nresolved_at = \"x\"\nmanifest_path = \"y\"\n").unwrap(); + assert!(read_skills_state(&path).is_none()); + } + + #[test] + fn latest_commit_cache_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("nested").join("latest-commit.json"); + assert!(read_cached_latest_skill_commit(&path).is_none()); + write_cached_latest_skill_commit(&path, "def456").unwrap(); + assert_eq!( + read_cached_latest_skill_commit(&path).as_deref(), + Some("def456") + ); + } + + #[test] + fn utc_now_rfc3339_shape() { + let now = utc_now_rfc3339(); + assert_eq!(now.len(), 20, "{now}"); + assert!(now.ends_with('Z'), "{now}"); + assert_eq!(&now[4..5], "-"); + assert_eq!(&now[10..11], "T"); + } + + #[test] + fn civil_from_days_known_dates() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + // 2026-07-10 is 20644 days after the epoch. + assert_eq!(civil_from_days(20_644), (2026, 7, 10)); + // Leap day. + assert_eq!(civil_from_days(18_321), (2020, 2, 29)); + } +} From 16b44aa25c824510b2618debc014ec102dd6a8b0 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 12:25:40 -0700 Subject: [PATCH 2/9] feat(cli): install agent skills from the skill repo head and record provenance baml agent install now defaults to the current head of BoundaryML/baml-skill: it resolves the main commit SHA first, downloads the tarball pinned to that exact commit, and records [skills] provenance in state.toml (plus the freshness cache) after a successful install. The --latest flag is removed since it is the standard behavior; --from remains for custom sources and the toolchain-release snapshot survives only behind BAML_AGENT_SKILLS_RELEASE_VERSION. Includes an e2e test that runs the real binary against a stubbed GitHub server and asserts the recorded provenance. Co-authored-by: Cursor --- .../crates/baml_cli/src/agent_command.rs | 103 ++++++++++---- .../baml_cli/tests/agent_install_e2e.rs | 126 ++++++++++++++++++ 2 files changed, 203 insertions(+), 26 deletions(-) create mode 100644 baml_language/crates/baml_cli/tests/agent_install_e2e.rs diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index d9e81770401..b166baffbc8 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -12,8 +12,6 @@ use clap::Args; use crate::{ExitCode, project_load::find_project_root_from}; -const SKILL_SOURCE_URL: &str = - "https://codeload.github.com/BoundaryML/baml-skill/tar.gz/refs/heads/main"; const HTTP_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Args, Clone, Debug)] @@ -46,12 +44,8 @@ pub(crate) struct AgentInstallArgs { #[arg(long, value_name = "PATH")] pub dir: Option, - /// Install skills from the current BoundaryML/baml-skill main branch. - #[arg(long, conflicts_with = "from")] - pub latest: bool, - /// Install skills from a tar.gz URL, local tar.gz archive, or local directory. - #[arg(long, value_name = "URL_OR_PATH", conflicts_with = "latest")] + #[arg(long, value_name = "URL_OR_PATH")] pub from: Option, } @@ -88,28 +82,40 @@ impl AgentInstallArgs { Some(dir) => explicit_install_root(dir)?, None => detect_install_root()?, }; - let skills = load_skills(self)?; - install_skills(&root, &skills)?; + let loaded = load_skills(self)?; + install_skills(&root, &loaded.skills)?; + if let Some(commit) = &loaded.commit { + record_installed_commit(commit); + } print_success(&root)?; Ok(ExitCode::Success) } } -fn load_skills(args: &AgentInstallArgs) -> Result> { - if args.latest { - let archive = fetch_url_bytes(SKILL_SOURCE_URL)?; - return skills_from_archive(&archive); - } +struct LoadedSkills { + skills: Vec, + /// Head commit of the skill repo, present only when installing from the + /// default source (the repo's main branch). Custom `--from` sources and + /// the pinned-release escape hatch have no commit identity to record. + commit: Option, +} +fn load_skills(args: &AgentInstallArgs) -> Result { if let Some(source) = &args.from { if is_http_url(source) { let archive = fetch_url_bytes(source)?; - return skills_from_archive(&archive); + return Ok(LoadedSkills { + skills: skills_from_archive(&archive)?, + commit: None, + }); } let path = Path::new(source); if path.is_dir() { - return skills_from_dir(path); + return Ok(LoadedSkills { + skills: skills_from_dir(path)?, + commit: None, + }); } let archive = fs::read(path).with_context(|| { @@ -118,17 +124,62 @@ fn load_skills(args: &AgentInstallArgs) -> Result> { path.display() ) })?; - return skills_from_archive(&archive); - } + return Ok(LoadedSkills { + skills: skills_from_archive(&archive)?, + commit: None, + }); + } + + // Escape hatch: install the checksummed skill snapshot attached to a + // toolchain release instead of the skill repo head. + if let Some(version) = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_VERSION") { + let url = versioned_skill_archive_url(&version); + let archive = fetch_url_bytes(&url)?; + let checksum_text = fetch_url_text(&format!("{url}.sha256"))?; + baml_release::verify_release_archive_checksum_text(&archive, &url, &checksum_text) + .context("verifying agent skills archive checksum failed")?; + return Ok(LoadedSkills { + skills: skills_from_archive(&archive)?, + commit: None, + }); + } + + // Default: install the current head of the skill repo. The commit is + // resolved first and the tarball downloaded at that exact commit, so the + // recorded provenance always matches the installed content. + let commit = baml_release::skills::fetch_latest_skill_commit(HTTP_TIMEOUT) + .context("failed to resolve the latest BAML agent skills commit")?; + let archive = fetch_url_bytes(&baml_release::skills::skill_archive_url(&commit))?; + Ok(LoadedSkills { + skills: skills_from_archive(&archive)?, + commit: Some(commit), + }) +} - let version = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_VERSION") - .unwrap_or_else(|| baml_version::CANONICAL_VERSION.to_string()); - let url = versioned_skill_archive_url(&version); - let archive = fetch_url_bytes(&url)?; - let checksum_text = fetch_url_text(&format!("{url}.sha256"))?; - baml_release::verify_release_archive_checksum_text(&archive, &url, &checksum_text) - .context("verifying agent skills archive checksum failed")?; - skills_from_archive(&archive) +/// Record which skill commit was installed (in `~/.baml/state.toml`) and +/// refresh the latest-commit cache so freshness warnings clear immediately. +/// Failures are reported but don't fail the install: the skills themselves +/// were written successfully. +fn record_installed_commit(commit: &str) { + let state = baml_release::skills::SkillsState { + installed_commit: commit.to_string(), + installed_at: baml_release::skills::utc_now_rfc3339(), + }; + if let Err(err) = + baml_release::skills::write_skills_state(&baml_release::skills::state_path(), &state) + { + crate::reporter::print_warning(format_args!( + "failed to record installed skill commit: {err:#}" + )); + } + if let Err(err) = baml_release::skills::write_cached_latest_skill_commit( + &baml_release::skills::latest_skill_commit_cache_path(), + commit, + ) { + crate::reporter::print_warning(format_args!( + "failed to update skill freshness cache: {err:#}" + )); + } } fn is_http_url(value: &str) -> bool { diff --git a/baml_language/crates/baml_cli/tests/agent_install_e2e.rs b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs new file mode 100644 index 00000000000..d7d491d6a0e --- /dev/null +++ b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs @@ -0,0 +1,126 @@ +//! End-to-end test for `baml-cli agent install`'s default path: resolve the +//! skill repo head commit, download the tarball at that commit, install the +//! skills, and record provenance in `state.toml`. +//! +//! GitHub is stubbed with a local HTTP server via the +//! `BAML_AGENT_SKILLS_COMMITS_URL` / `BAML_AGENT_SKILLS_ARCHIVE_BASE_URL` +//! overrides, so the test exercises the real fetch/parse/install/record flow +//! without network access. + +use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + process::Command, +}; + +const COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +fn skill_archive() -> Vec { + let content = "---\nname: baml-core\ndescription: test skill\n---\n# Core\n"; + let mut bytes = Vec::new(); + { + let encoder = flate2::write::GzEncoder::new(&mut bytes, flate2::Compression::default()); + let mut builder = tar::Builder::new(encoder); + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data( + &mut header, + format!("baml-skill-{COMMIT}/skills/baml-core/SKILL.md"), + content.as_bytes(), + ) + .unwrap(); + builder.finish().unwrap(); + } + bytes +} + +/// Serve canned GitHub-shaped responses: commit JSON for `/commits/main`, +/// the tarball for `/archive/`. Returns the server's base URL. +fn spawn_stub_github() -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut buf = [0u8; 4096]; + let read = stream.read(&mut buf).unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..read]).to_string(); + let path = request.split_whitespace().nth(1).unwrap_or("").to_string(); + + let (content_type, body): (&str, Vec) = if path.starts_with("/commits") { + ( + "application/json", + format!(r#"{{"sha":"{COMMIT}"}}"#).into_bytes(), + ) + } else if path.starts_with("/archive/") { + ("application/gzip", skill_archive()) + } else { + let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\n\r\n"); + continue; + }; + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: {content_type}\r\ncontent-length: {}\r\nconnection: close\r\n\r\n", + body.len() + ) + .as_bytes(), + ); + let _ = stream.write_all(&body); + } + }); + base +} + +#[test] +fn default_install_fetches_head_and_records_provenance() { + let server = spawn_stub_github(); + let home = tempfile::tempdir().unwrap(); + let project = tempfile::tempdir().unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_baml-cli")) + .args(["agent", "install", "--dir"]) + .arg(project.path()) + .env("BAML_HOME", home.path()) + .env( + "BAML_AGENT_SKILLS_COMMITS_URL", + format!("{server}/commits/main"), + ) + .env( + "BAML_AGENT_SKILLS_ARCHIVE_BASE_URL", + format!("{server}/archive"), + ) + .env_remove("BAML_AGENT_SKILLS_RELEASE_VERSION") + .output() + .unwrap(); + assert!( + output.status.success(), + "agent install failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Skills installed into both agent directories. + for dir in [".agents/skills", ".claude/skills"] { + let skill = project.path().join(dir).join("baml-core/SKILL.md"); + let content = fs::read_to_string(&skill).unwrap(); + assert!(content.contains("name: baml-core"), "{content}"); + } + + // Provenance recorded in state.toml. + let state = fs::read_to_string(home.path().join("state.toml")).unwrap(); + assert!(state.contains("[skills]"), "{state}"); + assert!( + state.contains(&format!("installed_commit = \"{COMMIT}\"")), + "{state}" + ); + assert!(state.contains("installed_at = \""), "{state}"); + + // Freshness cache updated to the installed commit, so the wrapper's + // outdated warning clears immediately. + let cache = + fs::read_to_string(home.path().join("manifest-cache/skills/latest-commit.json")).unwrap(); + assert_eq!(cache, format!(r#"{{"sha":"{COMMIT}"}}"#)); +} From aba7049992c5a10f797f289c987fc0440f4fad2b Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 12:25:59 -0700 Subject: [PATCH 3/9] feat(wrapper): warn when the tracked toolchain or agent skill is outdated The wrapper now surfaces staleness on normal commands: before exec'ing baml-cli it compares the recorded channel version against the cached channel manifest and the [skills] provenance against the cached latest skill commit, printing a styled bold-yellow warning: line on stderr when either is behind. Caches auto-refresh over the network at most once per 24h window (5s timeout, silent on failure, throttled by a .last-check marker so offline machines do not retry every command), activating the previously dead [update] auto_check config flag with default-on/opt-out semantics. toolchain status gains a skill section and toolchain use/update do a best-effort skill check afterward. The wrapper's State struct now round-trips foreign state.toml sections so it cannot drop the CLI-written [skills] entry. Covered by unit tests plus an e2e suite that runs the real wrapper binary against a temp BAML_HOME. Co-authored-by: Cursor --- baml_language/Cargo.lock | 1 + baml_language/crates/baml/Cargo.toml | 1 + baml_language/crates/baml/src/main.rs | 339 +++++++++++++++++- .../crates/baml/tests/freshness_e2e.rs | 227 ++++++++++++ 4 files changed, 564 insertions(+), 4 deletions(-) create mode 100644 baml_language/crates/baml/tests/freshness_e2e.rs diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 81d46542dca..a33f0ec75b1 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -671,6 +671,7 @@ version = "0.2.0" dependencies = [ "anyhow", "baml_release", + "console", "reqwest", "serde", "serde_json", diff --git a/baml_language/crates/baml/Cargo.toml b/baml_language/crates/baml/Cargo.toml index b5e404fd804..d902c1a06a3 100644 --- a/baml_language/crates/baml/Cargo.toml +++ b/baml_language/crates/baml/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] baml_release = { workspace = true } anyhow = { workspace = true } +console = { workspace = true } reqwest = { workspace = true, features = [ "blocking", "rustls" ] } serde = { workspace = true, features = [ "derive" ] } serde_json = { workspace = true } diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index 9bea008b3a0..ff516ed27b1 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -17,6 +17,9 @@ const CONFIG_FILE: &str = "config.toml"; const STATE_FILE: &str = "state.toml"; const CHANNEL_CACHE_TTL: Duration = Duration::from_hours(24); const HTTP_TIMEOUT: Duration = Duration::from_secs(30); +/// Short timeout for the passive background freshness checks that run before +/// normal commands, so an unreachable network can't stall the actual work. +const AUTO_CHECK_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Default, Serialize, Deserialize)] struct Config { @@ -41,13 +44,26 @@ impl Default for DefaultConfig { #[derive(Debug, Default, Serialize, Deserialize)] struct UpdateConfig { - auto_check: bool, + /// Whether normal commands may refresh the freshness caches (channel + /// manifest and latest skill commit) over the network once per TTL + /// window. Defaults to on; set `[update] auto_check = false` to opt out. + auto_check: Option, +} + +impl UpdateConfig { + fn auto_check_enabled(&self) -> bool { + self.auto_check.unwrap_or(true) + } } #[derive(Debug, Default, Serialize, Deserialize)] struct State { #[serde(default)] channels: BTreeMap, + /// Sections owned by other writers (e.g. `[skills]`, written by + /// `baml agent install`), preserved verbatim across wrapper writes. + #[serde(flatten)] + rest: BTreeMap, } #[derive(Debug, Serialize, Deserialize)] @@ -363,6 +379,9 @@ fn pass_through(args: Vec) -> Result { return Err(missing_toolchain_error(&selector, &version)); } verify_toolchain_version_file(&version)?; + auto_refresh_caches(&selector); + warn_if_channel_outdated(&selector, &version); + warn_if_skill_outdated(); let mut command = Command::new(cli); command.args(args); @@ -527,6 +546,160 @@ fn is_channel(selector: &str) -> bool { selector == "canary" || selector == "nightly" } +/// Bold-yellow lowercase `warning` prefix, matching the styled diagnostics the +/// toolchain CLI emits (see `baml_exec::diag_print`). Color is dropped +/// automatically when stderr is not a TTY. +fn warning_prefix() -> impl std::fmt::Display { + console::Style::new() + .yellow() + .bold() + .for_stderr() + .apply_to("warning") +} + +/// Refresh the freshness caches (channel manifest and latest skill commit) +/// over the network when they're older than the TTL, at most one attempt per +/// TTL window. Runs before normal commands, so failures are silent and the +/// timeout is short; disable entirely with `[update] auto_check = false`. +fn auto_refresh_caches(selector: &ResolvedSelector) { + if !read_config().update.auto_check_enabled() { + return; + } + + if is_channel(&selector.selector) { + let cache = manifest_cache_dir(&baml_release::manifest_base_url()) + .join(format!("{}.json", selector.selector)); + if should_attempt_refresh(&cache) { + let _ = fetch_manifest_with_timeout( + &selector.selector, + None, + FetchPolicy::ForceRemote, + AUTO_CHECK_TIMEOUT, + ); + } + } + + let cache = baml_release::skills::latest_skill_commit_cache_path(); + if should_attempt_refresh(&cache) { + if let Ok(sha) = baml_release::skills::fetch_latest_skill_commit(AUTO_CHECK_TIMEOUT) { + let _ = baml_release::skills::write_cached_latest_skill_commit(&cache, &sha); + } + } +} + +/// A cache file is due for a refresh attempt when both the file itself and +/// its attempt marker are older than the TTL. The marker is touched before +/// every attempt (success or failure) so an unreachable network is retried at +/// most once per TTL window instead of on every command. +fn should_attempt_refresh(cache_path: &Path) -> bool { + let marker = refresh_marker_path(cache_path); + if !file_older_than(cache_path, CHANNEL_CACHE_TTL) + || !file_older_than(&marker, CHANNEL_CACHE_TTL) + { + return false; + } + if let Some(parent) = marker.parent() { + let _ = fs::create_dir_all(parent); + } + fs::write(&marker, "").is_ok() +} + +fn refresh_marker_path(cache_path: &Path) -> PathBuf { + let mut path = cache_path.as_os_str().to_owned(); + path.push(".last-check"); + PathBuf::from(path) +} + +/// True when the file is missing or its mtime is older than `ttl`. +fn file_older_than(path: &Path, ttl: Duration) -> bool { + !path + .metadata() + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .is_some_and(|age| age <= ttl) +} + +/// Passive freshness check for the installed agent skills. Compares the +/// `[skills]` provenance in state.toml against the cached latest skill repo +/// commit; never touches the network. When no provenance was ever recorded +/// (installs predating provenance tracking), warns only if the current +/// project actually has baml skills installed. +fn warn_if_skill_outdated() { + let Some(latest) = baml_release::skills::read_cached_latest_skill_commit( + &baml_release::skills::latest_skill_commit_cache_path(), + ) else { + return; + }; + let outdated = match baml_release::skills::read_skills_state(&state_path()) { + Some(state) => state.installed_commit != latest, + None => project_has_baml_skills(), + }; + if outdated { + eprintln!( + "{}: Your baml skill is outdated, use baml agent install to upgrade it.", + warning_prefix() + ); + } +} + +/// Walk from the current directory up to $HOME looking for installed +/// `baml-*` agent skills (`.agents/skills/` or `.claude/skills/`). +fn project_has_baml_skills() -> bool { + let Ok(mut dir) = env::current_dir() else { + return false; + }; + let home = env::var_os("HOME").map(PathBuf::from); + loop { + let agents = dir.join(".agents").join("skills"); + let claude = dir.join(".claude").join("skills"); + if dir_contains_baml_skill(&agents) || dir_contains_baml_skill(&claude) { + return true; + } + if home.as_ref().is_some_and(|home| dir == *home) || !dir.pop() { + return false; + } + } +} + +fn dir_contains_baml_skill(skills_dir: &Path) -> bool { + fs::read_dir(skills_dir) + .ok() + .into_iter() + .flatten() + .filter_map(std::result::Result::ok) + .any(|entry| entry.file_name().to_string_lossy().starts_with("baml-")) +} + +/// Passive freshness check for channel selectors, run on every pass-through +/// invocation. Reads only the locally cached channel manifest (written by +/// explicit toolchain commands such as `install`, `use`, `update`, `status`); +/// it never touches the network, and stays silent if no cache exists yet. +fn warn_if_channel_outdated(selector: &ResolvedSelector, active_version: &str) { + if !is_channel(&selector.selector) { + return; + } + let cache_path = manifest_cache_dir(&baml_release::manifest_base_url()) + .join(format!("{}.json", selector.selector)); + if cached_manifest_is_newer(&cache_path, active_version) { + eprintln!( + "{}: Your version of baml for toolchain: {} is outdated. Update it with baml toolchain update.", + warning_prefix(), + selector.selector + ); + } +} + +fn cached_manifest_is_newer(cache_path: &Path, active_version: &str) -> bool { + let Ok(text) = fs::read_to_string(cache_path) else { + return false; + }; + let Ok(manifest) = toml_or_json::(&text) else { + return false; + }; + manifest.version != active_version +} + fn manifest_base_url(override_url: Option<&str>) -> String { override_url .map(|value| value.trim_end_matches('/').to_string()) @@ -537,6 +710,15 @@ fn fetch_manifest( selector: &str, override_url: Option<&str>, policy: FetchPolicy, +) -> Result { + fetch_manifest_with_timeout(selector, override_url, policy, HTTP_TIMEOUT) +} + +fn fetch_manifest_with_timeout( + selector: &str, + override_url: Option<&str>, + policy: FetchPolicy, + timeout: Duration, ) -> Result { let base = manifest_base_url(override_url); let url = if is_channel(selector) { @@ -562,7 +744,7 @@ fn fetch_manifest( Some(text) => text, None => { fetched_remote = true; - let client = http_client()?; + let client = http_client_with_timeout(timeout)?; client .get(&url) .send() @@ -696,6 +878,7 @@ fn use_toolchain(selector: &str, override_url: Option<&str>) -> Result<()> { config.default.selector = selector.to_string(); write_config(&config)?; println!("selected BAML toolchain {selector}"); + check_skill_freshness_after_toolchain_change(); Ok(()) } @@ -720,7 +903,61 @@ fn update_toolchain(override_url: Option<&str>) -> Result<()> { "failed to refresh {} from the remote manifest; the active installed toolchain was left unchanged", config.default.selector ) - }) + })?; + check_skill_freshness_after_toolchain_change(); + Ok(()) +} + +/// Fetch the latest skill repo commit, persist it to the freshness cache, and +/// return it. +fn refresh_latest_skill_commit(timeout: Duration) -> Result { + let sha = baml_release::skills::fetch_latest_skill_commit(timeout)?; + let _ = baml_release::skills::write_cached_latest_skill_commit( + &baml_release::skills::latest_skill_commit_cache_path(), + &sha, + ); + Ok(sha) +} + +fn short_sha(sha: &str) -> &str { + &sha[..sha.len().min(12)] +} + +/// Print the agent-skill section of `baml toolchain status`. Skill problems +/// never fail the status command; network failures degrade to a note. +fn print_skill_status() { + let installed = baml_release::skills::read_skills_state(&state_path()); + match &installed { + Some(state) => println!( + "skill installed: {} ({})", + short_sha(&state.installed_commit), + state.installed_at + ), + None => println!("skill installed: (none recorded locally)"), + } + match refresh_latest_skill_commit(HTTP_TIMEOUT) { + Ok(latest) => { + println!("skill latest: {}", short_sha(&latest)); + match installed { + Some(state) if state.installed_commit == latest => { + println!("skill status: up to date"); + } + _ => { + println!("skill status: outdated"); + println!("Run: baml agent install"); + } + } + } + Err(_) => println!("skill latest: (failed to check)"), + } +} + +/// After a toolchain change, also refresh the skill freshness cache and nudge +/// if the installed skill is behind. Best-effort: silent on network failure. +fn check_skill_freshness_after_toolchain_change() { + if refresh_latest_skill_commit(AUTO_CHECK_TIMEOUT).is_ok() { + warn_if_skill_outdated(); + } } fn status_toolchain(override_url: Option<&str>) -> Result<()> { @@ -762,6 +999,8 @@ fn status_toolchain(override_url: Option<&str>) -> Result<()> { println!("Run: baml toolchain use {}", selector.selector); } } + println!(); + print_skill_status(); return Ok(()); } @@ -781,6 +1020,8 @@ fn status_toolchain(override_url: Option<&str>) -> Result<()> { println!("status: exact versions do not advance automatically"); println!("Run: baml toolchain use canary"); println!("Or: baml toolchain use nightly"); + println!(); + print_skill_status(); Ok(()) } @@ -899,8 +1140,13 @@ fn fetch_wrapper_manifest() -> Result { } fn http_client() -> Result { + http_client_with_timeout(HTTP_TIMEOUT) +} + +fn http_client_with_timeout(timeout: Duration) -> Result { reqwest::blocking::Client::builder() - .timeout(HTTP_TIMEOUT) + .connect_timeout(timeout.min(Duration::from_secs(10))) + .timeout(timeout) .build() .context("failed to build HTTP client") } @@ -1041,4 +1287,89 @@ mod tests { " (nightly, from $BAML_VERSION)" ); } + + fn write_cached_manifest(version: &str) -> tempfile::NamedTempFile { + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write( + tmp.path(), + format!( + r#"{{"schema":1,"version":"{version}","channel":"canary","released_at":"2026-07-10T00:00:00Z","artifacts":{{}}}}"# + ), + ) + .unwrap(); + tmp + } + + #[test] + fn cached_manifest_newer_than_active_version_is_detected() { + let tmp = write_cached_manifest("0.12.0"); + assert!(cached_manifest_is_newer(tmp.path(), "0.11.0")); + } + + #[test] + fn cached_manifest_matching_active_version_is_not_outdated() { + let tmp = write_cached_manifest("0.11.0"); + assert!(!cached_manifest_is_newer(tmp.path(), "0.11.0")); + } + + #[test] + fn missing_manifest_cache_stays_silent() { + let missing = Path::new("/tmp/definitely-missing-baml-manifest.json"); + assert!(!cached_manifest_is_newer(missing, "0.11.0")); + } + + #[test] + fn unparseable_manifest_cache_stays_silent() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + fs::write(tmp.path(), "not json").unwrap(); + assert!(!cached_manifest_is_newer(tmp.path(), "0.11.0")); + } + + #[test] + fn missing_file_counts_as_older_than_ttl() { + let missing = Path::new("/tmp/definitely-missing-baml-freshness-file"); + assert!(file_older_than(missing, CHANNEL_CACHE_TTL)); + } + + #[test] + fn fresh_file_is_not_older_than_ttl() { + let tmp = tempfile::NamedTempFile::new().unwrap(); + assert!(!file_older_than(tmp.path(), CHANNEL_CACHE_TTL)); + } + + #[test] + fn refresh_attempt_is_throttled_by_marker() { + let tmp = tempfile::tempdir().unwrap(); + let cache = tmp.path().join("latest-commit.json"); + // Cache missing and no marker: attempt allowed, marker gets created. + assert!(should_attempt_refresh(&cache)); + assert!(refresh_marker_path(&cache).exists()); + // Fresh marker: no retry within the TTL window. + assert!(!should_attempt_refresh(&cache)); + } + + #[test] + fn state_write_preserves_foreign_sections() { + let text = "[channels.canary]\nactive_version = \"0.11.0\"\nresolved_at = \"x\"\nmanifest_path = \"y\"\n\n[skills]\ninstalled_commit = \"abc\"\ninstalled_at = \"2026-07-10T00:00:00Z\"\n"; + let state: State = toml::from_str(text).unwrap(); + let out = toml::to_string_pretty(&state).unwrap(); + assert!(out.contains("[skills]"), "{out}"); + assert!(out.contains("installed_commit = \"abc\""), "{out}"); + assert!(out.contains("[channels.canary]"), "{out}"); + } + + #[test] + fn auto_check_defaults_on_and_respects_optout() { + assert!(UpdateConfig::default().auto_check_enabled()); + let config: Config = toml::from_str("[update]\nauto_check = false\n").unwrap(); + assert!(!config.update.auto_check_enabled()); + let config: Config = toml::from_str("[update]\nauto_check = true\n").unwrap(); + assert!(config.update.auto_check_enabled()); + } + + #[test] + fn short_sha_truncates_long_and_keeps_short() { + assert_eq!(short_sha("0123456789abcdef0123"), "0123456789ab"); + assert_eq!(short_sha("abc"), "abc"); + } } diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs new file mode 100644 index 00000000000..159d100bb8e --- /dev/null +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -0,0 +1,227 @@ +//! End-to-end tests for the wrapper's freshness warnings: run the real `baml` +//! binary against a temp `BAML_HOME` and assert on stderr. +//! +//! Unix-only: the tests install a fake shell-script `baml-cli` for +//! `pass_through` to exec. + +#![cfg(unix)] + +use std::{ + fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +const TOOLCHAIN_WARNING: &str = "warning: Your version of baml for toolchain: canary is outdated. Update it with baml toolchain update."; +const SKILL_WARNING: &str = + "warning: Your baml skill is outdated, use baml agent install to upgrade it."; + +/// A temp `BAML_HOME` seeded with an installed fake toolchain (version +/// `0.11.0`, tracked via the `canary` channel) whose `baml-cli` prints +/// `cli ran` and exits 0. +struct TestHome { + _dir: tempfile::TempDir, + root: PathBuf, +} + +impl TestHome { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().to_path_buf(); + + let bin = root.join("toolchains/0.11.0/bin"); + fs::create_dir_all(&bin).unwrap(); + fs::write(root.join("toolchains/0.11.0/VERSION"), "0.11.0\n").unwrap(); + let cli = bin.join("baml-cli"); + fs::write(&cli, "#!/bin/sh\necho \"cli ran\"\n").unwrap(); + #[allow(clippy::permissions_set_readonly_false)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).unwrap(); + } + + fs::write( + root.join("config.toml"), + "[default]\nselector = \"canary\"\n\n[update]\nauto_check = false\n", + ) + .unwrap(); + let home = Self { _dir: dir, root }; + home.write_state(None); + home.write_manifest_cache("0.11.0"); + home + } + + /// Write `state.toml` with the canary channel active at 0.11.0 and an + /// optional `[skills]` provenance section. + fn write_state(&self, installed_skill_commit: Option<&str>) { + let mut text = String::from( + "[channels.canary]\nactive_version = \"0.11.0\"\nresolved_at = \"x\"\nmanifest_path = \"y\"\n", + ); + if let Some(commit) = installed_skill_commit { + text.push_str(&format!( + "\n[skills]\ninstalled_commit = \"{commit}\"\ninstalled_at = \"x\"\n" + )); + } + fs::write(self.root.join("state.toml"), text).unwrap(); + } + + fn write_manifest_cache(&self, version: &str) { + let dir = self.root.join("manifest-cache/prod"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("canary.json"), + format!( + r#"{{"schema":1,"version":"{version}","channel":"canary","released_at":"x","artifacts":{{}}}}"# + ), + ) + .unwrap(); + } + + fn write_skill_cache(&self, sha: &str) { + let dir = self.root.join("manifest-cache/skills"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("latest-commit.json"), + format!(r#"{{"sha":"{sha}"}}"#), + ) + .unwrap(); + } + + fn enable_auto_check(&self) { + fs::write( + self.root.join("config.toml"), + "[default]\nselector = \"canary\"\n", + ) + .unwrap(); + } + + /// Run `baml hello` with this home, from `cwd`. `$HOME` is pointed at the + /// cwd's parent so the project-skills walk stays inside the temp tree. + fn run_from(&self, cwd: &Path, extra_env: &[(&str, &str)]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_baml")); + command + .arg("hello") + .current_dir(cwd) + .env("BAML_HOME", &self.root) + .env("HOME", cwd.parent().unwrap_or(cwd)) + .env_remove("BAML_VERSION"); + for (key, value) in extra_env { + command.env(key, value); + } + let output = command.output().unwrap(); + assert!( + output.status.success(), + "wrapper failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("cli ran"), + "fake baml-cli did not run" + ); + output + } + + fn run(&self) -> Output { + let cwd = tempfile::tempdir().unwrap(); + self.run_from(cwd.path(), &[]) + } +} + +fn stderr_of(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} + +#[test] +fn warns_when_cached_manifest_is_newer_than_active_toolchain() { + let home = TestHome::new(); + home.write_manifest_cache("0.12.0"); + let stderr = stderr_of(&home.run()); + assert!(stderr.contains(TOOLCHAIN_WARNING), "{stderr}"); +} + +#[test] +fn silent_when_toolchain_matches_cached_manifest() { + let home = TestHome::new(); + let stderr = stderr_of(&home.run()); + assert!(!stderr.contains("outdated"), "{stderr}"); +} + +#[test] +fn warns_when_skill_provenance_is_behind_cached_latest() { + let home = TestHome::new(); + home.write_state(Some("aaa")); + home.write_skill_cache("bbb"); + let stderr = stderr_of(&home.run()); + assert!(stderr.contains(SKILL_WARNING), "{stderr}"); +} + +#[test] +fn silent_when_skill_provenance_matches_cached_latest() { + let home = TestHome::new(); + home.write_state(Some("aaa")); + home.write_skill_cache("aaa"); + let stderr = stderr_of(&home.run()); + assert!(!stderr.contains("skill"), "{stderr}"); +} + +#[test] +fn without_provenance_warns_only_when_project_has_baml_skills() { + let home = TestHome::new(); + home.write_skill_cache("bbb"); + + // No provenance, no skills in the project: stay silent. + let empty = tempfile::tempdir().unwrap(); + let stderr = stderr_of(&home.run_from(empty.path(), &[])); + assert!(!stderr.contains("skill"), "{stderr}"); + + // Same home, but the project has installed baml skills: warn. + let project = tempfile::tempdir().unwrap(); + fs::create_dir_all(project.path().join(".agents/skills/baml-core")).unwrap(); + let stderr = stderr_of(&home.run_from(project.path(), &[])); + assert!(stderr.contains(SKILL_WARNING), "{stderr}"); +} + +#[test] +fn auto_check_failure_is_quiet_and_throttled_by_marker() { + let home = TestHome::new(); + home.enable_auto_check(); + // Port 1 refuses connections immediately; the skill cache is absent so a + // refresh attempt is due. The manifest cache is fresh, so no toolchain + // fetch happens. + let env = [("BAML_AGENT_SKILLS_COMMITS_URL", "http://127.0.0.1:1/")]; + let cwd = tempfile::tempdir().unwrap(); + + let stderr = stderr_of(&home.run_from(cwd.path(), &env)); + assert!(!stderr.contains("skill"), "{stderr}"); + let marker = home + .root + .join("manifest-cache/skills/latest-commit.json.last-check"); + assert!(marker.exists(), "attempt marker was not written"); + let marker_mtime = marker.metadata().unwrap().modified().unwrap(); + + // Second run inside the TTL window: the marker throttles the retry. + let stderr = stderr_of(&home.run_from(cwd.path(), &env)); + assert!(!stderr.contains("skill"), "{stderr}"); + assert_eq!( + marker.metadata().unwrap().modified().unwrap(), + marker_mtime, + "marker was rewritten; retry was not throttled" + ); +} + +#[test] +fn auto_check_optout_skips_refresh_entirely() { + let home = TestHome::new(); + // auto_check = false (TestHome default config) and no skill cache: no + // attempt marker may appear even though a refresh would be due. + let env = [("BAML_AGENT_SKILLS_COMMITS_URL", "http://127.0.0.1:1/")]; + let cwd = tempfile::tempdir().unwrap(); + home.run_from(cwd.path(), &env); + assert!( + !home + .root + .join("manifest-cache/skills/latest-commit.json.last-check") + .exists(), + "auto_check=false must not attempt a refresh" + ); +} From 7b770d2ad98c341b825e7bd4b13627a72559667b Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 12:54:25 -0700 Subject: [PATCH 4/9] feat(wrapper): prompt skill install when a project has no baml skills Projects without any baml-* agent skills now get a warning on every command (No baml skill is installed, set it up with baml agent install), instead of staying silent. Projects with skills keep the upgrade warning when provenance is missing or behind the cached latest commit; the project-skills check is now the primary gate, so global provenance no longer triggers upgrade warnings in skill-less projects. Decision logic extracted into a pure skill_warning_message helper with unit tests, plus updated e2e coverage. Co-authored-by: Cursor --- baml_language/crates/baml/src/main.rs | 93 +++++++++++++++---- .../crates/baml/tests/freshness_e2e.rs | 52 ++++++++--- 2 files changed, 112 insertions(+), 33 deletions(-) diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index ff516ed27b1..877e508d6e5 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -620,26 +620,40 @@ fn file_older_than(path: &Path, ttl: Duration) -> bool { .is_some_and(|age| age <= ttl) } -/// Passive freshness check for the installed agent skills. Compares the -/// `[skills]` provenance in state.toml against the cached latest skill repo -/// commit; never touches the network. When no provenance was ever recorded -/// (installs predating provenance tracking), warns only if the current -/// project actually has baml skills installed. +/// Passive check of the agent-skill situation, printed on every pass-through +/// invocation; never touches the network (see [`skill_warning_message`]). fn warn_if_skill_outdated() { - let Some(latest) = baml_release::skills::read_cached_latest_skill_commit( + let latest = baml_release::skills::read_cached_latest_skill_commit( &baml_release::skills::latest_skill_commit_cache_path(), - ) else { - return; - }; - let outdated = match baml_release::skills::read_skills_state(&state_path()) { - Some(state) => state.installed_commit != latest, - None => project_has_baml_skills(), - }; - if outdated { - eprintln!( - "{}: Your baml skill is outdated, use baml agent install to upgrade it.", - warning_prefix() - ); + ); + let state = baml_release::skills::read_skills_state(&state_path()); + if let Some(message) = + skill_warning_message(project_has_baml_skills(), state.as_ref(), latest.as_deref()) + { + eprintln!("{}: {message}", warning_prefix()); + } +} + +/// Decide which skill warning (if any) applies: +/// +/// - No `baml-*` skills in the project at all: prompt to install. This fires +/// on every command regardless of caches, so users discover skills exist. +/// - Skills present but the `[skills]` provenance is missing or behind the +/// cached latest skill repo commit: prompt to upgrade. Requires the cache +/// (written by the auto-check or explicit commands); without it we can't +/// know what's current, so we stay silent rather than guess. +fn skill_warning_message( + project_has_skills: bool, + state: Option<&baml_release::skills::SkillsState>, + cached_latest: Option<&str>, +) -> Option<&'static str> { + if !project_has_skills { + return Some("No baml skill is installed, set it up with baml agent install."); + } + let latest = cached_latest?; + match state { + Some(state) if state.installed_commit == latest => None, + _ => Some("Your baml skill is outdated, use baml agent install to upgrade it."), } } @@ -1372,4 +1386,47 @@ mod tests { assert_eq!(short_sha("0123456789abcdef0123"), "0123456789ab"); assert_eq!(short_sha("abc"), "abc"); } + + fn skills_state(commit: &str) -> baml_release::skills::SkillsState { + baml_release::skills::SkillsState { + installed_commit: commit.to_string(), + installed_at: "x".to_string(), + } + } + + #[test] + fn missing_project_skills_prompt_install_regardless_of_caches() { + let expected = Some("No baml skill is installed, set it up with baml agent install."); + assert_eq!(skill_warning_message(false, None, None), expected); + assert_eq!(skill_warning_message(false, None, Some("bbb")), expected); + // Even matching global provenance doesn't matter: this project has no skills. + assert_eq!( + skill_warning_message(false, Some(&skills_state("bbb")), Some("bbb")), + expected + ); + } + + #[test] + fn skills_behind_or_untracked_prompt_upgrade() { + let expected = Some("Your baml skill is outdated, use baml agent install to upgrade it."); + assert_eq!( + skill_warning_message(true, Some(&skills_state("aaa")), Some("bbb")), + expected + ); + assert_eq!(skill_warning_message(true, None, Some("bbb")), expected); + } + + #[test] + fn current_skills_or_unknown_latest_stay_silent() { + assert_eq!( + skill_warning_message(true, Some(&skills_state("bbb")), Some("bbb")), + None + ); + // No cache yet: can't know what's current, so no warning. + assert_eq!(skill_warning_message(true, None, None), None); + assert_eq!( + skill_warning_message(true, Some(&skills_state("aaa")), None), + None + ); + } } diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs index 159d100bb8e..28e154dc981 100644 --- a/baml_language/crates/baml/tests/freshness_e2e.rs +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -13,8 +13,17 @@ use std::{ }; const TOOLCHAIN_WARNING: &str = "warning: Your version of baml for toolchain: canary is outdated. Update it with baml toolchain update."; -const SKILL_WARNING: &str = +const SKILL_OUTDATED_WARNING: &str = "warning: Your baml skill is outdated, use baml agent install to upgrade it."; +const SKILL_MISSING_WARNING: &str = + "warning: No baml skill is installed, set it up with baml agent install."; + +/// A project directory containing installed baml agent skills. +fn project_with_skills() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".agents/skills/baml-core")).unwrap(); + dir +} /// A temp `BAML_HOME` seeded with an installed fake toolchain (version /// `0.11.0`, tracked via the `canary` channel) whose `baml-cli` prints @@ -121,8 +130,10 @@ impl TestHome { output } + /// Run from a project that has skills installed, so skill warnings don't + /// bleed into tests that are about something else. fn run(&self) -> Output { - let cwd = tempfile::tempdir().unwrap(); + let cwd = project_with_skills(); self.run_from(cwd.path(), &[]) } } @@ -152,7 +163,7 @@ fn warns_when_skill_provenance_is_behind_cached_latest() { home.write_state(Some("aaa")); home.write_skill_cache("bbb"); let stderr = stderr_of(&home.run()); - assert!(stderr.contains(SKILL_WARNING), "{stderr}"); + assert!(stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); } #[test] @@ -165,20 +176,31 @@ fn silent_when_skill_provenance_matches_cached_latest() { } #[test] -fn without_provenance_warns_only_when_project_has_baml_skills() { +fn missing_project_skills_prompt_install_on_every_command() { let home = TestHome::new(); - home.write_skill_cache("bbb"); - // No provenance, no skills in the project: stay silent. + // No skills in the project: prompt to install, even with no caches at all. let empty = tempfile::tempdir().unwrap(); let stderr = stderr_of(&home.run_from(empty.path(), &[])); - assert!(!stderr.contains("skill"), "{stderr}"); + assert!(stderr.contains(SKILL_MISSING_WARNING), "{stderr}"); + + // Still the install prompt (not the upgrade one) when a latest-commit + // cache and even matching global provenance exist. + home.write_state(Some("bbb")); + home.write_skill_cache("bbb"); + let stderr = stderr_of(&home.run_from(empty.path(), &[])); + assert!(stderr.contains(SKILL_MISSING_WARNING), "{stderr}"); + assert!(!stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); +} - // Same home, but the project has installed baml skills: warn. - let project = tempfile::tempdir().unwrap(); - fs::create_dir_all(project.path().join(".agents/skills/baml-core")).unwrap(); +#[test] +fn project_skills_without_provenance_prompt_upgrade() { + let home = TestHome::new(); + home.write_skill_cache("bbb"); + let project = project_with_skills(); let stderr = stderr_of(&home.run_from(project.path(), &[])); - assert!(stderr.contains(SKILL_WARNING), "{stderr}"); + assert!(stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); + assert!(!stderr.contains(SKILL_MISSING_WARNING), "{stderr}"); } #[test] @@ -189,10 +211,10 @@ fn auto_check_failure_is_quiet_and_throttled_by_marker() { // refresh attempt is due. The manifest cache is fresh, so no toolchain // fetch happens. let env = [("BAML_AGENT_SKILLS_COMMITS_URL", "http://127.0.0.1:1/")]; - let cwd = tempfile::tempdir().unwrap(); + let cwd = project_with_skills(); let stderr = stderr_of(&home.run_from(cwd.path(), &env)); - assert!(!stderr.contains("skill"), "{stderr}"); + assert!(!stderr.contains("outdated"), "{stderr}"); let marker = home .root .join("manifest-cache/skills/latest-commit.json.last-check"); @@ -201,7 +223,7 @@ fn auto_check_failure_is_quiet_and_throttled_by_marker() { // Second run inside the TTL window: the marker throttles the retry. let stderr = stderr_of(&home.run_from(cwd.path(), &env)); - assert!(!stderr.contains("skill"), "{stderr}"); + assert!(!stderr.contains("outdated"), "{stderr}"); assert_eq!( marker.metadata().unwrap().modified().unwrap(), marker_mtime, @@ -215,7 +237,7 @@ fn auto_check_optout_skips_refresh_entirely() { // auto_check = false (TestHome default config) and no skill cache: no // attempt marker may appear even though a refresh would be due. let env = [("BAML_AGENT_SKILLS_COMMITS_URL", "http://127.0.0.1:1/")]; - let cwd = tempfile::tempdir().unwrap(); + let cwd = project_with_skills(); home.run_from(cwd.path(), &env); assert!( !home From fafb9b5d93567afd9e518cb1c006d8ca795cd777 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 13:00:41 -0700 Subject: [PATCH 5/9] fix: satisfy strict clippy lints in skill freshness code CI runs clippy with -D warnings, which rejected the numeric casts in the skills date helper (cast_possible_wrap/truncation/sign_loss), a nonminimal_bool in file_older_than, and format_push_string in the e2e test. Replace the casts with try_from with in-range fallbacks and simplify the boolean with is_none_or. Co-authored-by: Cursor --- baml_language/crates/baml/src/main.rs | 5 ++--- baml_language/crates/baml/tests/freshness_e2e.rs | 6 +++--- baml_language/crates/baml_release/src/skills.rs | 8 +++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index 877e508d6e5..1982f141d7a 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -612,12 +612,11 @@ fn refresh_marker_path(cache_path: &Path) -> PathBuf { /// True when the file is missing or its mtime is older than `ttl`. fn file_older_than(path: &Path, ttl: Duration) -> bool { - !path - .metadata() + path.metadata() .and_then(|metadata| metadata.modified()) .ok() .and_then(|modified| SystemTime::now().duration_since(modified).ok()) - .is_some_and(|age| age <= ttl) + .is_none_or(|age| age > ttl) } /// Passive check of the agent-skill situation, printed on every pass-through diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs index 28e154dc981..ca2cf464ba6 100644 --- a/baml_language/crates/baml/tests/freshness_e2e.rs +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -67,9 +67,9 @@ impl TestHome { "[channels.canary]\nactive_version = \"0.11.0\"\nresolved_at = \"x\"\nmanifest_path = \"y\"\n", ); if let Some(commit) = installed_skill_commit { - text.push_str(&format!( - "\n[skills]\ninstalled_commit = \"{commit}\"\ninstalled_at = \"x\"\n" - )); + text.push_str("\n[skills]\ninstalled_commit = \""); + text.push_str(commit); + text.push_str("\"\ninstalled_at = \"x\"\n"); } fs::write(self.root.join("state.toml"), text).unwrap(); } diff --git a/baml_language/crates/baml_release/src/skills.rs b/baml_language/crates/baml_release/src/skills.rs index 64a8b69f4d4..4bd46a1cec4 100644 --- a/baml_language/crates/baml_release/src/skills.rs +++ b/baml_language/crates/baml_release/src/skills.rs @@ -182,7 +182,7 @@ pub fn utc_now_rfc3339() -> String { .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs()) .unwrap_or(0); - let days = (secs / 86_400) as i64; + let days = i64::try_from(secs / 86_400).unwrap_or(0); let time_of_day = secs % 86_400; let (year, month, day) = civil_from_days(days); format!( @@ -203,8 +203,10 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) { let year = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; - let day = (doy - (153 * mp + 2) / 5 + 1) as u32; - let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + // Both values are provably in range (day 1..=31, month 1..=12); the + // fallbacks only exist to satisfy the no-panicking-cast lints. + let day = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); + let month = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); (if month <= 2 { year + 1 } else { year }, month, day) } From ceb39e26e71bf9f84b6e86ef0e5628527f52de52 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 13:34:15 -0700 Subject: [PATCH 6/9] chore(release): drop per-version agent-skill packaging from the release pipeline agent install now pulls the skill repo head directly from GitHub, so the versioned baml-agent-skills-{version}.tar.gz release asset has no consumers. Remove the build-agent-skills job, the packaging script, and the BAML_AGENT_SKILLS_RELEASE_VERSION escape hatch that pointed at it. Co-authored-by: Cursor --- .github/workflows/release-baml-language.yml | 44 +--- .../crates/baml_cli/src/agent_command.rs | 74 +------ .../baml_cli/tests/agent_install_e2e.rs | 1 - scripts/package-agent-skills | 202 ------------------ 4 files changed, 5 insertions(+), 316 deletions(-) delete mode 100755 scripts/package-agent-skills diff --git a/.github/workflows/release-baml-language.yml b/.github/workflows/release-baml-language.yml index 1ce0ef4d235..026c0513c7d 100644 --- a/.github/workflows/release-baml-language.yml +++ b/.github/workflows/release-baml-language.yml @@ -42,7 +42,6 @@ env: AWS_REGION: us-east-1 AWS_ROLE_ARN: arn:aws:iam::277707123528:role/pkg-boundaryml-com-github-release PKG_BUCKET: pkgboundarymlcomstack-pkgboundarymlcomsitebucket4f-ybhvygkqittp - BAML_AGENT_SKILLS_REPOSITORY: BoundaryML/baml-skill jobs: plan: @@ -298,38 +297,6 @@ jobs: name: baml-playground-dist path: typescript2/app-vscode-webview/dist - build-agent-skills: - name: Build agent skills - needs: [plan] - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: ${{ needs.plan.outputs.source_sha }} - persist-credentials: false - - uses: actions/checkout@v4 - with: - repository: ${{ env.BAML_AGENT_SKILLS_REPOSITORY }} - path: agent-skills-source - persist-credentials: false - - name: Package agent skills - env: - VERSION: ${{ needs.plan.outputs.version }} - run: | - set -euo pipefail - scripts/package-agent-skills \ - --source agent-skills-source \ - --version "$VERSION" \ - --out-dir dist/agent-skills - - archive="dist/agent-skills/baml-agent-skills-$VERSION.tar.gz" - tar -tzf "$archive" | tee agent-skills-list.txt - grep -E '/SKILL\.md$' agent-skills-list.txt - - uses: actions/upload-artifact@v4 - with: - name: agent-skills - path: dist/agent-skills/* - build-wrapper: name: Build wrapper (${{ matrix.target }}) needs: [plan] @@ -435,7 +402,6 @@ jobs: - build-wrapper - build-python-sdk - build-nodejs-sdk - - build-agent-skills steps: - run: echo "all release build jobs completed" @@ -490,7 +456,7 @@ jobs: publish-toolchain-release: name: Publish toolchain release assets - needs: [plan, all-builds, build-agent-skills] + needs: [plan, all-builds] if: ${{ needs.plan.outputs.dry_run == 'false' && needs.plan.outputs.source_branch == 'canary' }} runs-on: ubuntu-latest env: @@ -507,10 +473,6 @@ jobs: with: name: baml-vscode-vsix path: dist/vsix - - uses: actions/download-artifact@v4 - with: - name: agent-skills - path: dist/agent-skills - name: Generate checksum sidecars run: | set -euo pipefail @@ -526,9 +488,9 @@ jobs: run: | set -euo pipefail if gh release view "$TAG" >/dev/null 2>&1; then - gh release upload "$TAG" dist/toolchain/* dist/vsix/* dist/agent-skills/* --clobber + gh release upload "$TAG" dist/toolchain/* dist/vsix/* --clobber else - gh release create "$TAG" dist/toolchain/* dist/vsix/* dist/agent-skills/* \ + gh release create "$TAG" dist/toolchain/* dist/vsix/* \ --title "BAML Language $VERSION" \ --notes "BAML language toolchain $VERSION" fi diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index b166baffbc8..daac4465fbc 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -95,8 +95,8 @@ impl AgentInstallArgs { struct LoadedSkills { skills: Vec, /// Head commit of the skill repo, present only when installing from the - /// default source (the repo's main branch). Custom `--from` sources and - /// the pinned-release escape hatch have no commit identity to record. + /// default source (the repo's main branch). Custom `--from` sources have + /// no commit identity to record. commit: Option, } @@ -130,20 +130,6 @@ fn load_skills(args: &AgentInstallArgs) -> Result { }); } - // Escape hatch: install the checksummed skill snapshot attached to a - // toolchain release instead of the skill repo head. - if let Some(version) = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_VERSION") { - let url = versioned_skill_archive_url(&version); - let archive = fetch_url_bytes(&url)?; - let checksum_text = fetch_url_text(&format!("{url}.sha256"))?; - baml_release::verify_release_archive_checksum_text(&archive, &url, &checksum_text) - .context("verifying agent skills archive checksum failed")?; - return Ok(LoadedSkills { - skills: skills_from_archive(&archive)?, - commit: None, - }); - } - // Default: install the current head of the skill repo. The commit is // resolved first and the tarball downloaded at that exact commit, so the // recorded provenance always matches the installed content. @@ -186,46 +172,6 @@ fn is_http_url(value: &str) -> bool { value.starts_with("https://") || value.starts_with("http://") } -fn env_var_nonempty(name: &str) -> Option { - std::env::var(name) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn versioned_skill_archive_url(version: &str) -> String { - let base_url = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_BASE_URL"); - let repo = env_var_nonempty("BAML_AGENT_SKILLS_RELEASE_REPO") - .unwrap_or_else(baml_release::release_repo); - versioned_skill_archive_url_with_env(version, base_url.as_deref(), Some(&repo)) -} - -fn versioned_skill_archive_url_with_env( - version: &str, - base_url: Option<&str>, - repo: Option<&str>, -) -> String { - let filename = versioned_skill_archive_filename(version); - if let Some(base_url) = base_url.map(str::trim).filter(|value| !value.is_empty()) { - return format!("{}/{}", base_url.trim_end_matches('/'), filename); - } - - let repo = repo - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(baml_release::DEFAULT_RELEASE_REPO); - format!("https://github.com/{repo}/releases/download/baml-language-{version}/{filename}") -} - -fn versioned_skill_archive_filename(version: &str) -> String { - format!("baml-agent-skills-{version}.tar.gz") -} - -fn fetch_url_text(url: &str) -> Result { - let bytes = fetch_url_bytes(url)?; - String::from_utf8(bytes).with_context(|| format!("{url} was not valid UTF-8")) -} - fn fetch_url_bytes(url: &str) -> Result> { let client = reqwest::blocking::Client::builder() .connect_timeout(Duration::from_secs(10)) @@ -802,22 +748,6 @@ mod tests { ); } - #[test] - fn versioned_archive_url_defaults_to_release_asset() { - assert_eq!( - versioned_skill_archive_url_with_env("1.2.3", None, None), - "https://github.com/BoundaryML/baml/releases/download/baml-language-1.2.3/baml-agent-skills-1.2.3.tar.gz" - ); - assert_eq!( - versioned_skill_archive_url_with_env("1.2.3", Some("https://example.com/base/"), None), - "https://example.com/base/baml-agent-skills-1.2.3.tar.gz" - ); - assert_eq!( - versioned_skill_archive_url_with_env("1.2.3", None, Some("BoundaryML/custom")), - "https://github.com/BoundaryML/custom/releases/download/baml-language-1.2.3/baml-agent-skills-1.2.3.tar.gz" - ); - } - #[test] fn root_detection_prefers_nearest_baml_toml() { let _guard = cwd_lock().lock().unwrap(); diff --git a/baml_language/crates/baml_cli/tests/agent_install_e2e.rs b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs index d7d491d6a0e..c3e9920d1de 100644 --- a/baml_language/crates/baml_cli/tests/agent_install_e2e.rs +++ b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs @@ -93,7 +93,6 @@ fn default_install_fetches_head_and_records_provenance() { "BAML_AGENT_SKILLS_ARCHIVE_BASE_URL", format!("{server}/archive"), ) - .env_remove("BAML_AGENT_SKILLS_RELEASE_VERSION") .output() .unwrap(); assert!( diff --git a/scripts/package-agent-skills b/scripts/package-agent-skills deleted file mode 100755 index 830246925dd..00000000000 --- a/scripts/package-agent-skills +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat >&2 <<'USAGE' -Usage: scripts/package-agent-skills --source DIR --version VERSION --out-dir DIR -USAGE -} - -source_dir="" -version="" -out_dir="" -tar_list="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --source) - if [[ -z "${2:-}" ]]; then - usage - exit 2 - fi - source_dir="${2:-}" - shift 2 - ;; - --version) - if [[ -z "${2:-}" ]]; then - usage - exit 2 - fi - version="${2:-}" - shift 2 - ;; - --out-dir) - if [[ -z "${2:-}" ]]; then - usage - exit 2 - fi - out_dir="${2:-}" - shift 2 - ;; - -h | --help) - usage - exit 0 - ;; - *) - usage - exit 2 - ;; - esac -done - -if [[ -z "$source_dir" || -z "$version" || -z "$out_dir" ]]; then - usage - exit 2 -fi - -if [[ ! -d "$source_dir" ]]; then - echo "source directory does not exist: $source_dir" >&2 - exit 1 -fi - -mkdir -p "$out_dir" -staging="$(mktemp -d "${TMPDIR:-/tmp}/baml-agent-skills.XXXXXX")" -cleanup() { - rm -rf "$staging" - if [[ -n "${tar_list:-}" ]]; then - rm -f "$tar_list" - fi -} -trap cleanup EXIT - -rewrite_skill() { - local input="$1" - local output="$2" - local name="$3" - local tmp - tmp="$(mktemp "${TMPDIR:-/tmp}/baml-agent-skill.XXXXXX")" - awk -v name="$name" ' - BEGIN { - in_frontmatter = 0 - saw_open = 0 - replaced = 0 - } - NR == 1 && $0 == "---" { - print - in_frontmatter = 1 - saw_open = 1 - next - } - saw_open && in_frontmatter && $0 == "---" { - if (!replaced) { - exit 42 - } - print - in_frontmatter = 0 - next - } - saw_open && in_frontmatter && $0 ~ /^[[:space:]]*name:[[:space:]]*/ { - print "name: " name - replaced = 1 - next - } - { - print - } - END { - if (!saw_open || !replaced) { - exit 42 - } - } - ' "$input" > "$tmp" || { - rm -f "$tmp" - echo "failed to rewrite frontmatter name in $input" >&2 - exit 1 - } - mv "$tmp" "$output" -} - -stage_skill() { - local input="$1" - local name="$2" - local dest_dir="$staging/skills/$name" - local dest="$dest_dir/SKILL.md" - - if [[ -e "$dest" ]]; then - echo "duplicate skill after normalization: $name" >&2 - exit 1 - fi - - mkdir -p "$dest_dir" - rewrite_skill "$input" "$dest" "$name" -} - -count=0 - -if [[ -d "$source_dir/skills" ]]; then - while IFS= read -r -d '' skill_file; do - skill_dir="$(basename "$(dirname "$skill_file")")" - stage_skill "$skill_file" "$skill_dir" - count=$((count + 1)) - done < <(find "$source_dir/skills" -mindepth 2 -maxdepth 2 -type f -path '*/baml-*/SKILL.md' -print0 | sort -z) -fi - -if [[ -d "$source_dir/plugins/baml/skills" ]]; then - while IFS= read -r -d '' skill_file; do - legacy_name="$(basename "$(dirname "$skill_file")")" - stage_skill "$skill_file" "baml-$legacy_name" - count=$((count + 1)) - done < <(find "$source_dir/plugins/baml/skills" -mindepth 2 -maxdepth 2 -type f -name 'SKILL.md' -print0 | sort -z) -fi - -if [[ "$count" -eq 0 ]]; then - echo "no BAML agent skills found in $source_dir" >&2 - exit 1 -fi - -archive_name="baml-agent-skills-$version.tar.gz" -archive="$out_dir/$archive_name" - -find "$staging" -type d -exec chmod 755 {} + -find "$staging" -type f -exec chmod 644 {} + -find "$staging" -exec touch -t 197001010000.00 {} + -export COPYFILE_DISABLE=1 -export GZIP=-n - -tar_help="$(tar --help 2>/dev/null || true)" -if grep -q -- '--sort' <<< "$tar_help"; then - tar_flags=( - --sort=name - --mtime=1970-01-01 - --numeric-owner - --owner=0 - --group=0 - ) - if grep -q -- '--no-acls' <<< "$tar_help"; then - tar_flags+=(--no-acls) - fi - if grep -q -- '--no-xattrs' <<< "$tar_help"; then - tar_flags+=(--no-xattrs) - fi - tar -C "$staging" "${tar_flags[@]}" -czf "$archive" skills -else - tar_list="$(mktemp "${TMPDIR:-/tmp}/baml-agent-skills-list.XXXXXX")" - ( - cd "$staging" - find skills -print | LC_ALL=C sort > "$tar_list" - tar --no-recursion \ - --uid 0 \ - --gid 0 \ - --uname root \ - --gname root \ - --format ustar \ - -T "$tar_list" \ - -czf "$archive" - ) -fi - -if command -v sha256sum >/dev/null 2>&1; then - (cd "$out_dir" && sha256sum "$archive_name" > "$archive_name.sha256") -else - (cd "$out_dir" && shasum -a 256 "$archive_name" > "$archive_name.sha256") -fi From 3a1f825922afc5901f88ab91dbd9550c8c492e16 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 14:11:31 -0700 Subject: [PATCH 7/9] fix: address review feedback on skill freshness edge cases - agent install --from now clears recorded provenance instead of leaving a stale commit that misreports custom content as current - write_skills_state treats only a missing state.toml as empty; corrupt or unreadable files error instead of being silently replaced - passive cache refreshes share one 5s wall-clock budget instead of up to 5s each - skill detection requires a baml-* directory containing SKILL.md, so leftover empty directories don't suppress the install prompt Co-authored-by: Cursor --- baml_language/crates/baml/src/main.rs | 24 ++++- .../crates/baml/tests/freshness_e2e.rs | 17 +++- .../crates/baml_cli/src/agent_command.rs | 17 +++- .../crates/baml_release/src/skills.rs | 88 +++++++++++++++++-- 4 files changed, 133 insertions(+), 13 deletions(-) diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index 1982f141d7a..78f32916e9f 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -559,12 +559,15 @@ fn warning_prefix() -> impl std::fmt::Display { /// Refresh the freshness caches (channel manifest and latest skill commit) /// over the network when they're older than the TTL, at most one attempt per -/// TTL window. Runs before normal commands, so failures are silent and the -/// timeout is short; disable entirely with `[update] auto_check = false`. +/// TTL window. Runs before normal commands, so failures are silent and both +/// refreshes share a single [`AUTO_CHECK_TIMEOUT`] wall-clock budget (a +/// command never stalls longer than that, even with both caches due); +/// disable entirely with `[update] auto_check = false`. fn auto_refresh_caches(selector: &ResolvedSelector) { if !read_config().update.auto_check_enabled() { return; } + let deadline = std::time::Instant::now() + AUTO_CHECK_TIMEOUT; if is_channel(&selector.selector) { let cache = manifest_cache_dir(&baml_release::manifest_base_url()) @@ -579,9 +582,16 @@ fn auto_refresh_caches(selector: &ResolvedSelector) { } } + // The skill refresh gets whatever budget the manifest refresh left over. + // When nothing remains, skip without touching the attempt marker so the + // next command retries instead of waiting out the full TTL window. + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return; + } let cache = baml_release::skills::latest_skill_commit_cache_path(); if should_attempt_refresh(&cache) { - if let Ok(sha) = baml_release::skills::fetch_latest_skill_commit(AUTO_CHECK_TIMEOUT) { + if let Ok(sha) = baml_release::skills::fetch_latest_skill_commit(remaining) { let _ = baml_release::skills::write_cached_latest_skill_commit(&cache, &sha); } } @@ -675,13 +685,19 @@ fn project_has_baml_skills() -> bool { } } +/// A skill counts only when it's a `baml-*` directory actually containing a +/// `SKILL.md`; a leftover empty directory or stray file must not suppress the +/// missing-skill prompt. fn dir_contains_baml_skill(skills_dir: &Path) -> bool { fs::read_dir(skills_dir) .ok() .into_iter() .flatten() .filter_map(std::result::Result::ok) - .any(|entry| entry.file_name().to_string_lossy().starts_with("baml-")) + .any(|entry| { + entry.file_name().to_string_lossy().starts_with("baml-") + && entry.path().join("SKILL.md").is_file() + }) } /// Passive freshness check for channel selectors, run on every pass-through diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs index ca2cf464ba6..c1173cb5380 100644 --- a/baml_language/crates/baml/tests/freshness_e2e.rs +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -21,7 +21,9 @@ const SKILL_MISSING_WARNING: &str = /// A project directory containing installed baml agent skills. fn project_with_skills() -> tempfile::TempDir { let dir = tempfile::tempdir().unwrap(); - fs::create_dir_all(dir.path().join(".agents/skills/baml-core")).unwrap(); + let skill_dir = dir.path().join(".agents/skills/baml-core"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "---\nname: baml-core\n---\n").unwrap(); dir } @@ -193,6 +195,19 @@ fn missing_project_skills_prompt_install_on_every_command() { assert!(!stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); } +#[test] +fn empty_skill_directory_still_prompts_install() { + let home = TestHome::new(); + + // A leftover baml-* directory without a SKILL.md is not an installation. + let project = tempfile::tempdir().unwrap(); + fs::create_dir_all(project.path().join(".agents/skills/baml-core")).unwrap(); + + let stderr = stderr_of(&home.run_from(project.path(), &[])); + assert!(stderr.contains(SKILL_MISSING_WARNING), "{stderr}"); + assert!(!stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); +} + #[test] fn project_skills_without_provenance_prompt_upgrade() { let home = TestHome::new(); diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index daac4465fbc..73cbff9cac8 100644 --- a/baml_language/crates/baml_cli/src/agent_command.rs +++ b/baml_language/crates/baml_cli/src/agent_command.rs @@ -84,8 +84,9 @@ impl AgentInstallArgs { }; let loaded = load_skills(self)?; install_skills(&root, &loaded.skills)?; - if let Some(commit) = &loaded.commit { - record_installed_commit(commit); + match &loaded.commit { + Some(commit) => record_installed_commit(commit), + None => clear_installed_commit(), } print_success(&root)?; Ok(ExitCode::Success) @@ -168,6 +169,18 @@ fn record_installed_commit(commit: &str) { } } +/// Installs from custom `--from` sources have no commit identity. Drop any +/// previously recorded provenance so the wrapper doesn't report the custom +/// content as up to date with (or behind) the official skill repo. +fn clear_installed_commit() { + if let Err(err) = baml_release::skills::clear_skills_state(&baml_release::skills::state_path()) + { + crate::reporter::print_warning(format_args!( + "failed to clear recorded skill provenance: {err:#}" + )); + } +} + fn is_http_url(value: &str) -> bool { value.starts_with("https://") || value.starts_with("http://") } diff --git a/baml_language/crates/baml_release/src/skills.rs b/baml_language/crates/baml_release/src/skills.rs index 4bd46a1cec4..989ad3978f0 100644 --- a/baml_language/crates/baml_release/src/skills.rs +++ b/baml_language/crates/baml_release/src/skills.rs @@ -100,12 +100,12 @@ pub fn read_skills_state(state_path: &Path) -> Option { } /// Write the `[skills]` section of `state.toml`, preserving every other -/// section (notably the wrapper-owned `[channels.*]`). +/// section (notably the wrapper-owned `[channels.*]`). Only a missing file is +/// treated as empty; an unreadable or unparseable file is an error, so a +/// corrupt `state.toml` is never silently replaced (which would also destroy +/// the other writers' sections). pub fn write_skills_state(state_path: &Path, skills: &SkillsState) -> Result<()> { - let mut root = fs::read_to_string(state_path) - .ok() - .and_then(|text| text.parse::().ok()) - .unwrap_or_else(|| toml::Value::Table(toml::value::Table::new())); + let mut root = read_state_document(state_path)?; let table = root .as_table_mut() .context("state.toml root is not a table")?; @@ -113,7 +113,44 @@ pub fn write_skills_state(state_path: &Path, skills: &SkillsState) -> Result<()> "skills".to_string(), toml::Value::try_from(skills).context("failed to serialize skills state")?, ); - let text = toml::to_string_pretty(&root).context("failed to serialize state.toml")?; + write_state_document(state_path, &root) +} + +/// Remove the `[skills]` section of `state.toml`, preserving every other +/// section. Used after installs from custom sources, which have no commit +/// identity: stale provenance would otherwise make the wrapper report +/// unrelated content as current. +pub fn clear_skills_state(state_path: &Path) -> Result<()> { + let mut root = read_state_document(state_path)?; + let Some(table) = root.as_table_mut() else { + return Ok(()); + }; + if table.remove("skills").is_none() { + return Ok(()); + } + write_state_document(state_path, &root) +} + +fn read_state_document(state_path: &Path) -> Result { + let text = match fs::read_to_string(state_path) { + Ok(text) => text, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(toml::Value::Table(toml::value::Table::new())); + } + Err(err) => { + return Err(err).with_context(|| format!("failed to read {}", state_path.display())); + } + }; + text.parse::().with_context(|| { + format!( + "failed to parse {} (fix or delete the file and retry)", + state_path.display() + ) + }) +} + +fn write_state_document(state_path: &Path, root: &toml::Value) -> Result<()> { + let text = toml::to_string_pretty(root).context("failed to serialize state.toml")?; write_text_atomic(state_path, &text) } @@ -250,6 +287,45 @@ mod tests { assert!(read_skills_state(&path).is_none()); } + #[test] + fn write_skills_state_refuses_to_clobber_corrupt_file() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("state.toml"); + fs::write(&path, "this is not [valid toml").unwrap(); + + let skills = SkillsState { + installed_commit: "abc123".to_string(), + installed_at: "2026-07-10T00:00:00Z".to_string(), + }; + let err = write_skills_state(&path, &skills).unwrap_err(); + assert!(format!("{err:#}").contains("failed to parse"), "{err:#}"); + assert_eq!( + fs::read_to_string(&path).unwrap(), + "this is not [valid toml" + ); + } + + #[test] + fn clear_skills_state_removes_section_and_preserves_others() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("state.toml"); + fs::write( + &path, + "[channels.canary]\nactive_version = \"0.11.0\"\nresolved_at = \"x\"\nmanifest_path = \"y\"\n\n[skills]\ninstalled_commit = \"abc\"\ninstalled_at = \"z\"\n", + ) + .unwrap(); + + clear_skills_state(&path).unwrap(); + + assert!(read_skills_state(&path).is_none()); + let text = fs::read_to_string(&path).unwrap(); + assert!(text.contains("[channels.canary]"), "{text}"); + + // Missing file and missing section are both no-ops. + clear_skills_state(&path).unwrap(); + clear_skills_state(&tmp.path().join("missing.toml")).unwrap(); + } + #[test] fn latest_commit_cache_roundtrip() { let tmp = tempfile::tempdir().unwrap(); From ccef4d525f7a135e055f7f08680805f518b1e99a Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 14:41:09 -0700 Subject: [PATCH 8/9] refactor(wrapper): reuse refresh_latest_skill_commit in the passive auto-check The passive path inlined its own fetch-then-persist sequence; route it through the same helper the explicit toolchain commands use so there is exactly one fetch-and-cache path and one compare-and-warn path, with trigger policy (TTL-gated vs forced) as the only difference. Co-authored-by: Cursor --- baml_language/crates/baml/src/main.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index 78f32916e9f..0b05c81eab9 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -591,9 +591,7 @@ fn auto_refresh_caches(selector: &ResolvedSelector) { } let cache = baml_release::skills::latest_skill_commit_cache_path(); if should_attempt_refresh(&cache) { - if let Ok(sha) = baml_release::skills::fetch_latest_skill_commit(remaining) { - let _ = baml_release::skills::write_cached_latest_skill_commit(&cache, &sha); - } + let _ = refresh_latest_skill_commit(remaining); } } From 9cd66d8e8435704bf86cdf19a92dd53cbb55d074 Mon Sep 17 00:00:00 2001 From: atx24 Date: Fri, 10 Jul 2026 15:08:32 -0700 Subject: [PATCH 9/9] perf(wrapper): run freshness cache refreshes behind the command instead of before it Warnings from existing caches still print immediately (no network). When a refresh is due (at most once per TTL window), it now runs on a background thread while baml-cli executes; the wrapper spawns and waits instead of exec'ing on that rare path, joins the refresh with whatever remains of the 5s budget after the command exits, and prints only warnings the fresh caches newly justify (never duplicating one already shown). The common no-refresh case keeps the zero-overhead exec path. Co-authored-by: Cursor --- baml_language/crates/baml/src/main.rs | 175 ++++++++++++------ .../crates/baml/tests/freshness_e2e.rs | 56 ++++++ 2 files changed, 175 insertions(+), 56 deletions(-) diff --git a/baml_language/crates/baml/src/main.rs b/baml_language/crates/baml/src/main.rs index 0b05c81eab9..53aef741290 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -379,26 +379,55 @@ fn pass_through(args: Vec) -> Result { return Err(missing_toolchain_error(&selector, &version)); } verify_toolchain_version_file(&version)?; - auto_refresh_caches(&selector); - warn_if_channel_outdated(&selector, &version); - warn_if_skill_outdated(); + + // Warnings from the existing caches print immediately (no network). + let channel_warned = warn_if_channel_outdated(&selector, &version); + let skill_warned = warn_if_skill_outdated(); + // A cache refresh (due at most once per TTL window) runs in the + // background while the command itself runs, instead of stalling it. + let refresh = start_lazy_refresh(&selector); let mut command = Command::new(cli); command.args(args); command.env("BAML_WRAPPER_EXEC", "1"); command.env("BAML_WRAPPER_RESOLVED_TOOLCHAIN", &version); - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - let err = command.exec(); - Err(anyhow!("failed to exec baml-cli: {err}")) + let Some(refresh) = refresh else { + // Common case: nothing to refresh, so the wrapper can hand the + // process over entirely. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + let err = command.exec(); + return Err(anyhow!("failed to exec baml-cli: {err}")); + } + #[cfg(not(unix))] + { + let status = command.status().context("failed to run baml-cli")?; + return Ok(status.code().unwrap_or(1)); + } + }; + + // Refresh in flight: run the command as a child (exec would kill the + // refresh thread), then give the refresh whatever remains of its budget + // and surface any warnings the fresh caches newly justify. + let status = command.status().context("failed to run baml-cli")?; + refresh.wait(); + if !channel_warned { + warn_if_channel_outdated(&selector, &version); } - #[cfg(not(unix))] - { - let status = command.status().context("failed to run baml-cli")?; - Ok(status.code().unwrap_or(1)) + let latest = baml_release::skills::read_cached_latest_skill_commit( + &baml_release::skills::latest_skill_commit_cache_path(), + ); + let state = baml_release::skills::read_skills_state(&state_path()); + let skill_message = + skill_warning_message(project_has_baml_skills(), state.as_ref(), latest.as_deref()); + if let Some(message) = skill_message { + if skill_warned != Some(message) { + eprintln!("{}: {message}", warning_prefix()); + } } + Ok(status.code().unwrap_or(1)) } fn active_selector() -> Result { @@ -557,42 +586,71 @@ fn warning_prefix() -> impl std::fmt::Display { .apply_to("warning") } -/// Refresh the freshness caches (channel manifest and latest skill commit) -/// over the network when they're older than the TTL, at most one attempt per -/// TTL window. Runs before normal commands, so failures are silent and both -/// refreshes share a single [`AUTO_CHECK_TIMEOUT`] wall-clock budget (a -/// command never stalls longer than that, even with both caches due); -/// disable entirely with `[update] auto_check = false`. -fn auto_refresh_caches(selector: &ResolvedSelector) { +/// An in-flight background refresh of the freshness caches (channel manifest +/// and/or latest skill commit). Started before the main command runs and +/// joined after it finishes, so the network latency hides behind the +/// command's own runtime instead of stalling it up front. +struct LazyRefresh { + done: std::sync::mpsc::Receiver<()>, + deadline: std::time::Instant, +} + +impl LazyRefresh { + /// Wait for the refresh to finish, but never past the shared + /// [`AUTO_CHECK_TIMEOUT`] deadline (anchored at refresh start, so a + /// command that ran 3s only waits up to 2s more). On timeout the thread + /// is abandoned; cache writes are atomic, so dying mid-write is safe. + fn wait(self) { + let remaining = self + .deadline + .saturating_duration_since(std::time::Instant::now()); + let _ = self.done.recv_timeout(remaining); + } +} + +/// Kick off a background refresh of any freshness cache older than the TTL, +/// at most one attempt per TTL window (failures are silent; the marker in +/// [`should_attempt_refresh`] throttles retries). Returns `None` when +/// nothing is due or `[update] auto_check = false` — the common case, which +/// costs only a few mtime checks and lets the caller keep the exec fast path. +fn start_lazy_refresh(selector: &ResolvedSelector) -> Option { if !read_config().update.auto_check_enabled() { - return; + return None; } - let deadline = std::time::Instant::now() + AUTO_CHECK_TIMEOUT; - if is_channel(&selector.selector) { - let cache = manifest_cache_dir(&baml_release::manifest_base_url()) - .join(format!("{}.json", selector.selector)); - if should_attempt_refresh(&cache) { + let manifest_due = is_channel(&selector.selector) + && should_attempt_refresh( + &manifest_cache_dir(&baml_release::manifest_base_url()) + .join(format!("{}.json", selector.selector)), + ); + let skill_due = should_attempt_refresh(&baml_release::skills::latest_skill_commit_cache_path()); + if !manifest_due && !skill_due { + return None; + } + + let deadline = std::time::Instant::now() + AUTO_CHECK_TIMEOUT; + let channel = selector.selector.clone(); + let (sender, done) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + if manifest_due { let _ = fetch_manifest_with_timeout( - &selector.selector, + &channel, None, FetchPolicy::ForceRemote, AUTO_CHECK_TIMEOUT, ); } - } - - // The skill refresh gets whatever budget the manifest refresh left over. - // When nothing remains, skip without touching the attempt marker so the - // next command retries instead of waiting out the full TTL window. - let remaining = deadline.saturating_duration_since(std::time::Instant::now()); - if remaining.is_zero() { - return; - } - let cache = baml_release::skills::latest_skill_commit_cache_path(); - if should_attempt_refresh(&cache) { - let _ = refresh_latest_skill_commit(remaining); - } + if skill_due { + // The skill refresh gets whatever budget the manifest refresh + // left over. + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if !remaining.is_zero() { + let _ = refresh_latest_skill_commit(remaining); + } + } + let _ = sender.send(()); + }); + Some(LazyRefresh { done, deadline }) } /// A cache file is due for a refresh attempt when both the file itself and @@ -629,16 +687,17 @@ fn file_older_than(path: &Path, ttl: Duration) -> bool { /// Passive check of the agent-skill situation, printed on every pass-through /// invocation; never touches the network (see [`skill_warning_message`]). -fn warn_if_skill_outdated() { +/// Returns the message it printed (if any) so a post-refresh re-check can +/// avoid printing the same warning twice in one invocation. +fn warn_if_skill_outdated() -> Option<&'static str> { let latest = baml_release::skills::read_cached_latest_skill_commit( &baml_release::skills::latest_skill_commit_cache_path(), ); let state = baml_release::skills::read_skills_state(&state_path()); - if let Some(message) = - skill_warning_message(project_has_baml_skills(), state.as_ref(), latest.as_deref()) - { - eprintln!("{}: {message}", warning_prefix()); - } + let message = + skill_warning_message(project_has_baml_skills(), state.as_ref(), latest.as_deref())?; + eprintln!("{}: {message}", warning_prefix()); + Some(message) } /// Decide which skill warning (if any) applies: @@ -700,21 +759,25 @@ fn dir_contains_baml_skill(skills_dir: &Path) -> bool { /// Passive freshness check for channel selectors, run on every pass-through /// invocation. Reads only the locally cached channel manifest (written by -/// explicit toolchain commands such as `install`, `use`, `update`, `status`); -/// it never touches the network, and stays silent if no cache exists yet. -fn warn_if_channel_outdated(selector: &ResolvedSelector, active_version: &str) { +/// explicit toolchain commands and the background auto-refresh); it never +/// touches the network, and stays silent if no cache exists yet. Returns +/// whether it printed, so a post-refresh re-check can avoid duplicating the +/// warning within one invocation. +fn warn_if_channel_outdated(selector: &ResolvedSelector, active_version: &str) -> bool { if !is_channel(&selector.selector) { - return; + return false; } let cache_path = manifest_cache_dir(&baml_release::manifest_base_url()) .join(format!("{}.json", selector.selector)); - if cached_manifest_is_newer(&cache_path, active_version) { - eprintln!( - "{}: Your version of baml for toolchain: {} is outdated. Update it with baml toolchain update.", - warning_prefix(), - selector.selector - ); + if !cached_manifest_is_newer(&cache_path, active_version) { + return false; } + eprintln!( + "{}: Your version of baml for toolchain: {} is outdated. Update it with baml toolchain update.", + warning_prefix(), + selector.selector + ); + true } fn cached_manifest_is_newer(cache_path: &Path, active_version: &str) -> bool { @@ -983,7 +1046,7 @@ fn print_skill_status() { /// if the installed skill is behind. Best-effort: silent on network failure. fn check_skill_freshness_after_toolchain_change() { if refresh_latest_skill_commit(AUTO_CHECK_TIMEOUT).is_ok() { - warn_if_skill_outdated(); + let _ = warn_if_skill_outdated(); } } diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs index c1173cb5380..f4ef82dc778 100644 --- a/baml_language/crates/baml/tests/freshness_e2e.rs +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -8,6 +8,8 @@ use std::{ fs, + io::{Read, Write}, + net::TcpListener, path::{Path, PathBuf}, process::{Command, Output}, }; @@ -218,6 +220,60 @@ fn project_skills_without_provenance_prompt_upgrade() { assert!(!stderr.contains(SKILL_MISSING_WARNING), "{stderr}"); } +/// Serve GitHub-commit-shaped JSON (`{"sha": ...}`) for every request. +/// Returns the server's base URL. +fn spawn_stub_commits_server(sha: &str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let body = format!(r#"{{"sha":"{sha}"}}"#); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { continue }; + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let _ = stream.write_all( + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ); + } + }); + base +} + +#[test] +fn lazy_refresh_discovers_outdated_skill_after_the_command() { + let home = TestHome::new(); + home.enable_auto_check(); + home.write_state(Some("aaa")); + // No skill cache: pre-command there is nothing to compare against, so no + // warning prints up front; the background refresh (stubbed to return a + // newer commit) must surface the warning after the command instead. + let server = spawn_stub_commits_server("bbb"); + let env = [("BAML_AGENT_SKILLS_COMMITS_URL", server.as_str())]; + let cwd = project_with_skills(); + + let output = home.run_from(cwd.path(), &env); + let stderr = stderr_of(&output); + assert!(stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); + assert_eq!( + fs::read_to_string(home.root.join("manifest-cache/skills/latest-commit.json")).unwrap(), + r#"{"sha":"bbb"}"#, + "background refresh did not persist the cache" + ); + + // Next command: cache is fresh, no refresh spawns, and the warning now + // prints from cache (up front) exactly once. + let stderr = stderr_of(&home.run_from(cwd.path(), &env)); + assert_eq!( + stderr.matches(SKILL_OUTDATED_WARNING).count(), + 1, + "{stderr}" + ); +} + #[test] fn auto_check_failure_is_quiet_and_throttled_by_marker() { let home = TestHome::new();