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/Cargo.lock b/baml_language/Cargo.lock index f6336e8e2d8..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", @@ -1103,7 +1104,9 @@ dependencies = [ "serde_json", "sha2", "tar", + "tempfile", "thiserror 2.0.18", + "toml 0.8.23", "zip", ] 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..53aef741290 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)] @@ -364,22 +380,54 @@ fn pass_through(args: Vec) -> Result { } verify_toolchain_version_file(&version)?; + // 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 { @@ -527,6 +575,221 @@ 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") +} + +/// 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 None; + } + + 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( + &channel, + None, + FetchPolicy::ForceRemote, + AUTO_CHECK_TIMEOUT, + ); + } + 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 +/// 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_none_or(|age| age > ttl) +} + +/// Passive check of the agent-skill situation, printed on every pass-through +/// invocation; never touches the network (see [`skill_warning_message`]). +/// 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()); + 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: +/// +/// - 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."), + } +} + +/// 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; + } + } +} + +/// 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-") + && entry.path().join("SKILL.md").is_file() + }) +} + +/// Passive freshness check for channel selectors, run on every pass-through +/// invocation. Reads only the locally cached channel manifest (written by +/// 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 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) { + 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 { + 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 +800,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 +834,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 +968,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 +993,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() { + let _ = warn_if_skill_outdated(); + } } fn status_toolchain(override_url: Option<&str>) -> Result<()> { @@ -762,6 +1089,8 @@ fn status_toolchain(override_url: Option<&str>) -> Result<()> { println!("Run: baml toolchain use {}", selector.selector); } } + println!(); + print_skill_status(); return Ok(()); } @@ -781,6 +1110,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 +1230,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 +1377,132 @@ 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"); + } + + 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 new file mode 100644 index 00000000000..f4ef82dc778 --- /dev/null +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -0,0 +1,320 @@ +//! 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, + io::{Read, Write}, + net::TcpListener, + 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_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(); + 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 +} + +/// 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("\n[skills]\ninstalled_commit = \""); + text.push_str(commit); + text.push_str("\"\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 + } + + /// 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 = project_with_skills(); + 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_OUTDATED_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 missing_project_skills_prompt_install_on_every_command() { + let home = TestHome::new(); + + // 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_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}"); +} + +#[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(); + home.write_skill_cache("bbb"); + let project = project_with_skills(); + let stderr = stderr_of(&home.run_from(project.path(), &[])); + assert!(stderr.contains(SKILL_OUTDATED_WARNING), "{stderr}"); + 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(); + 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 = project_with_skills(); + + let stderr = stderr_of(&home.run_from(cwd.path(), &env)); + assert!(!stderr.contains("outdated"), "{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("outdated"), "{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 = project_with_skills(); + 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" + ); +} diff --git a/baml_language/crates/baml_cli/src/agent_command.rs b/baml_language/crates/baml_cli/src/agent_command.rs index d9e81770401..73cbff9cac8 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,41 @@ 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)?; + match &loaded.commit { + Some(commit) => record_installed_commit(commit), + None => clear_installed_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 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,61 +125,64 @@ fn load_skills(args: &AgentInstallArgs) -> Result> { path.display() ) })?; - return skills_from_archive(&archive); - } - - 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) -} - -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)) + 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), + }) } -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); +/// 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:#}" + )); } - - 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") +/// 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 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 is_http_url(value: &str) -> bool { + value.starts_with("https://") || value.starts_with("http://") } fn fetch_url_bytes(url: &str) -> Result> { @@ -751,22 +761,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 new file mode 100644 index 00000000000..c3e9920d1de --- /dev/null +++ b/baml_language/crates/baml_cli/tests/agent_install_e2e.rs @@ -0,0 +1,125 @@ +//! 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"), + ) + .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}"}}"#)); +} 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..989ad3978f0 --- /dev/null +++ b/baml_language/crates/baml_release/src/skills.rs @@ -0,0 +1,358 @@ +//! 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.*]`). 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 = read_state_document(state_path)?; + 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")?, + ); + 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) +} + +#[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 = 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!( + "{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; + // 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) +} + +#[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 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(); + 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)); + } +} 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