From 0bcf651b75c2a03644d9fd17aa60d922506ab5be Mon Sep 17 00:00:00 2001 From: Avery Townsend Date: Tue, 28 Jul 2026 12:41:46 -0700 Subject: [PATCH] feat(cli): unify shell output and wrapper help --- baml_language/Cargo.lock | 15 +- baml_language/Cargo.toml | 3 + baml_language/crates/baml/Cargo.toml | 2 +- baml_language/crates/baml/src/main.rs | 425 +++++++++++++----- .../crates/baml/tests/freshness_e2e.rs | 145 +++++- .../crates/baml/tests/no_self_update_e2e.rs | 2 +- baml_language/crates/baml_cli/Cargo.toml | 1 + baml_language/crates/baml_cli/src/auth.rs | 2 +- baml_language/crates/baml_cli/src/commands.rs | 136 +++++- .../crates/baml_cli/src/describe_command.rs | 22 +- .../crates/baml_cli/src/feedback_command.rs | 4 +- .../crates/baml_cli/src/grep_command.rs | 16 +- baml_language/crates/baml_cli/src/lib.rs | 4 + baml_language/crates/baml_cli/src/main.rs | 25 +- baml_language/crates/baml_cli/src/output.rs | 12 + baml_language/crates/baml_cli/src/reporter.rs | 127 +----- .../crates/baml_cli/src/run_command.rs | 57 ++- .../crates/baml_cli/src/telemetry/storage.rs | 21 +- .../crates/baml_cli/src/telemetry_command.rs | 68 +-- .../crates/baml_cli/src/test_command.rs | 11 +- baml_language/crates/baml_exec/Cargo.toml | 2 +- .../crates/baml_exec/src/clap_target.rs | 38 +- .../crates/baml_exec/src/diag_print.rs | 49 +- baml_language/crates/baml_exec/src/lib.rs | 5 +- .../crates/baml_pack_host/src/main.rs | 2 +- baml_language/crates/baml_shell/Cargo.toml | 27 ++ baml_language/crates/baml_shell/src/lib.rs | 389 ++++++++++++++++ baml_language/stow.toml | 2 +- 28 files changed, 1199 insertions(+), 413 deletions(-) create mode 100644 baml_language/crates/baml_shell/Cargo.toml create mode 100644 baml_language/crates/baml_shell/src/lib.rs diff --git a/baml_language/Cargo.lock b/baml_language/Cargo.lock index 182556fb601..4f11f103e91 100644 --- a/baml_language/Cargo.lock +++ b/baml_language/Cargo.lock @@ -662,7 +662,7 @@ version = "0.2.3" dependencies = [ "anyhow", "baml_release", - "console", + "baml_shell", "reqwest", "serde", "serde_json", @@ -735,6 +735,7 @@ dependencies = [ "baml_lsp_server", "baml_project", "baml_release", + "baml_shell", "baml_tests", "baml_type", "baml_version", @@ -981,13 +982,13 @@ name = "baml_exec" version = "0.0.0-beta" dependencies = [ "anyhow", + "baml_shell", "baml_tests", "baml_type", "bex_engine", "bex_vm_types", "borsh", "clap", - "console", "indexmap", "serde_json", "sys_native", @@ -1147,6 +1148,16 @@ dependencies = [ "zip", ] +[[package]] +name = "baml_shell" +version = "0.0.0-beta" +dependencies = [ + "anstream", + "anstyle", + "clap", + "serde", +] + [[package]] name = "baml_tests" version = "0.0.0-beta" diff --git a/baml_language/Cargo.toml b/baml_language/Cargo.toml index ae2ecb7f656..53c79f60b12 100644 --- a/baml_language/Cargo.toml +++ b/baml_language/Cargo.toml @@ -118,6 +118,7 @@ baml_lsp2_actions_tests = { path = "crates/baml_lsp2_actions_tests" } baml_lsp_server = { path = "crates/baml_lsp_server", default-features = false } baml_project = { path = "crates/baml_project" } baml_release = { path = "crates/baml_release" } +baml_shell = { path = "crates/baml_shell" } baml_tests = { path = "crates/baml_tests" } baml_version = { path = "crates/baml_version" } bex_vm = { path = "crates/bex_vm" } @@ -148,6 +149,8 @@ baml_workspace = { path = "crates/baml_workspace" } # External dependencies anyhow = { version = "1" } +anstream = "1" +anstyle = "1" async-trait = "0.1" askama = { version = "0.14.0" } # Slim, reqwest/serde-based replacements for the AWS SDK + Google Cloud auth diff --git a/baml_language/crates/baml/Cargo.toml b/baml_language/crates/baml/Cargo.toml index b94fed6441b..7cb43dfa729 100644 --- a/baml_language/crates/baml/Cargo.toml +++ b/baml_language/crates/baml/Cargo.toml @@ -27,8 +27,8 @@ no-self-update = [ "baml_release/no-self-update" ] [dependencies] baml_release = { workspace = true } +baml_shell = { 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 a342362c538..934aef54c2c 100644 --- a/baml_language/crates/baml/src/main.rs +++ b/baml_language/crates/baml/src/main.rs @@ -1,5 +1,3 @@ -#![allow(clippy::print_stdout, clippy::print_stderr)] - use std::{ collections::{BTreeMap, hash_map::DefaultHasher}, env, fs, @@ -13,6 +11,7 @@ use anyhow::{Context, Result, anyhow}; #[cfg(all(feature = "self-update", not(feature = "no-self-update")))] use baml_release::WrapperManifest; use baml_release::{Artifact, Product, ReleaseSpec, ToolchainManifest}; +use baml_shell::{HelpRow, RootHelpV1, Shell}; use serde::{Deserialize, Serialize}; const CONFIG_FILE: &str = "config.toml"; @@ -140,34 +139,51 @@ print the package-manager upgrade command instead. "#; fn main() { - let exit = match run() { + let mut shell = Shell::new(); + let exit = match run(&mut shell) { Ok(code) => code, Err(err) => { - eprintln!("{err:#}"); + drop(shell.error(format_args!("{err:#}"))); 1 } }; std::process::exit(exit); } -fn run() -> Result { +fn run(shell: &mut Shell) -> Result { let mut args: Vec = env::args().skip(1).collect(); #[cfg(all(windows, feature = "self-update", not(feature = "no-self-update")))] if args.first().map(String::as_str) == Some("--replace") { args.remove(0); return replace_running_exe(args).map(|()| 0); } + if is_root_help_request(&args) { + return root_help(shell, args); + } + if args.first().map(String::as_str) == Some("help") { + match args.get(1).map(String::as_str) { + Some("toolchain") => { + shell.out().write_all(TOOLCHAIN_HELP.as_bytes())?; + return Ok(0); + } + Some("self-update") => { + shell.out().write_all(SELF_UPDATE_HELP.as_bytes())?; + return Ok(0); + } + _ => {} + } + } if matches!(args.first().map(String::as_str), Some("--version" | "-V")) { - print_version(); + print_version(shell)?; return Ok(0); } if args.first().map(String::as_str) == Some("toolchain") { args.remove(0); - return toolchain(args).map(|()| 0); + return toolchain(shell, args).map(|()| 0); } if args.first().map(String::as_str) == Some("self-update") { if args.get(1).is_some_and(|arg| is_help_arg(arg)) { - print!("{SELF_UPDATE_HELP}"); + shell.out().write_all(SELF_UPDATE_HELP.as_bytes())?; return Ok(0); } if args.len() > 1 { @@ -176,56 +192,63 @@ fn run() -> Result { args[1..].join(" ") )); } - return self_update().map(|()| 0); + return self_update(shell).map(|()| 0); } - pass_through(args) + pass_through(shell, args) } -fn print_version() { - println!("baml wrapper {}", env!("CARGO_PKG_VERSION")); +fn print_version(shell: &mut Shell) -> Result<()> { + writeln!(shell.out(), "baml wrapper {}", env!("CARGO_PKG_VERSION"))?; let selector = match active_selector() { Ok(selector) => selector, Err(err) => { - println!("baml toolchain not resolved"); - println!("{err:#}"); - return; + writeln!(shell.out(), "baml toolchain not resolved")?; + writeln!(shell.out(), "{err:#}")?; + return Ok(()); } }; let version = match concrete_version_for_selector(&selector) { Ok(version) => version, Err(_) if is_channel(&selector.selector) => { - println!( + writeln!( + shell.out(), "baml toolchain not installed{}", selector_annotation(&selector) - ); - println!("Run: baml toolchain use {}", selector.selector); - return; + )?; + writeln!(shell.out(), "Run: baml toolchain use {}", selector.selector)?; + return Ok(()); } Err(err) => { - println!("baml toolchain not resolved"); - println!("{err:#}"); - return; + writeln!(shell.out(), "baml toolchain not resolved")?; + writeln!(shell.out(), "{err:#}")?; + return Ok(()); } }; if !toolchain_cli_path(&version).exists() { - println!( + writeln!( + shell.out(), "baml toolchain not installed ({version}){}", selector_annotation(&selector) - ); + )?; if is_channel(&selector.selector) { - println!("Run: baml toolchain use {}", selector.selector); + writeln!(shell.out(), "Run: baml toolchain use {}", selector.selector)?; } else { - println!("Run: baml toolchain install {version}"); + writeln!(shell.out(), "Run: baml toolchain install {version}")?; } - return; + return Ok(()); } match verify_toolchain_version_file(&version) { - Ok(()) => println!("baml toolchain {version}{}", selector_annotation(&selector)), + Ok(()) => writeln!( + shell.out(), + "baml toolchain {version}{}", + selector_annotation(&selector) + )?, Err(_) => { - println!("baml toolchain corrupt ({version})"); - println!("Run: baml toolchain install {version} --force"); + writeln!(shell.out(), "baml toolchain corrupt ({version})")?; + writeln!(shell.out(), "Run: baml toolchain install {version} --force")?; } } + Ok(()) } /// Where the active selector was resolved from, as a trailing parenthetical @@ -313,11 +336,11 @@ fn write_toml_atomic(path: &Path, value: &T) -> Result<()> { Ok(()) } -fn toolchain(args: Vec) -> Result<()> { +fn toolchain(shell: &mut Shell, args: Vec) -> Result<()> { let (args, manifest_base_url) = parse_manifest_base_url(args)?; match args.first().map(String::as_str) { Some("--help" | "-h" | "help") | None => { - print!("{TOOLCHAIN_HELP}"); + shell.out().write_all(TOOLCHAIN_HELP.as_bytes())?; Ok(()) } Some("install") => { @@ -325,25 +348,25 @@ fn toolchain(args: Vec) -> Result<()> { .get(1) .ok_or_else(|| anyhow!("usage: baml toolchain install "))?; let force = args.iter().any(|arg| arg == "--force"); - install_toolchain(selector, false, manifest_base_url.as_deref(), force) + install_toolchain(shell, selector, false, manifest_base_url.as_deref(), force) } Some("use") => { let selector = args .get(1) .ok_or_else(|| anyhow!("usage: baml toolchain use "))?; - use_toolchain(selector, manifest_base_url.as_deref()) + use_toolchain(shell, selector, manifest_base_url.as_deref()) } - Some("update") => update_toolchain(manifest_base_url.as_deref()), - Some("status") => status_toolchain(manifest_base_url.as_deref()), + Some("update") => update_toolchain(shell, manifest_base_url.as_deref()), + Some("status") => status_toolchain(shell, manifest_base_url.as_deref()), Some("list") => { - list_toolchains(); + list_toolchains(shell)?; Ok(()) } Some("uninstall") => { let version = args .get(1) .ok_or_else(|| anyhow!("usage: baml toolchain uninstall "))?; - uninstall_toolchain(version) + uninstall_toolchain(shell, version) } Some(other) => Err(anyhow!( "unknown toolchain command {other:?}\n\n{TOOLCHAIN_HELP}" @@ -355,6 +378,117 @@ fn is_help_arg(arg: &str) -> bool { matches!(arg, "--help" | "-h" | "help") } +fn is_root_help_request(args: &[String]) -> bool { + matches!(args, [arg] if is_help_arg(arg)) +} + +struct ResolvedToolchain { + selector: ResolvedSelector, + version: String, + cli: PathBuf, +} + +fn resolve_toolchain() -> Result { + let selector = active_selector()?; + let version = concrete_version_for_selector(&selector)?; + let cli = toolchain_cli_path(&version); + if !cli.exists() { + return Err(missing_toolchain_error(&selector, &version)); + } + verify_toolchain_version_file(&version)?; + Ok(ResolvedToolchain { + selector, + version, + cli, + }) +} + +fn root_help(shell: &mut Shell, original_args: Vec) -> Result { + let Ok(resolved) = resolve_toolchain() else { + shell.root_help(&wrapper_only_root_help())?; + return Ok(0); + }; + match toolchain_root_help(&resolved) { + Ok(mut help) => { + add_wrapper_commands(&mut help); + shell.root_help(&help)?; + Ok(0) + } + Err(_) => pass_through_resolved(shell, original_args, resolved), + } +} + +fn toolchain_root_help(resolved: &ResolvedToolchain) -> Result { + let output = Command::new(&resolved.cli) + .arg(baml_shell::ROOT_HELP_COMMAND_V1) + .env("BAML_WRAPPER_EXEC", "1") + .env("BAML_WRAPPER_RESOLVED_TOOLCHAIN", &resolved.version) + .output() + .context("failed to query baml-cli root help")?; + if !output.status.success() { + anyhow::bail!("baml-cli does not support root-help metadata"); + } + let help: RootHelpV1 = + serde_json::from_slice(&output.stdout).context("invalid baml-cli root-help metadata")?; + help.validate().map_err(anyhow::Error::msg)?; + Ok(help) +} + +fn add_wrapper_commands(help: &mut RootHelpV1) { + help.commands + .retain(|row| !matches!(row.syntax.as_str(), "toolchain" | "self-update")); + let insert_at = help + .commands + .iter() + .position(|row| row.syntax == "help") + .unwrap_or(help.commands.len()); + help.commands.splice( + insert_at..insert_at, + [ + HelpRow { + syntax: "toolchain".to_string(), + summary: "Manage installed BAML toolchains".to_string(), + }, + HelpRow { + syntax: "self-update".to_string(), + summary: "Update the BAML wrapper".to_string(), + }, + ], + ); +} + +fn wrapper_only_root_help() -> RootHelpV1 { + RootHelpV1 { + schema_version: baml_shell::ROOT_HELP_SCHEMA_V1.to_string(), + name: "baml".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + about: + "A CLI tool for working with BAML. Install or select a toolchain to see core commands." + .to_string(), + usage: "baml ".to_string(), + commands: vec![ + HelpRow { + syntax: "toolchain".to_string(), + summary: "Install and manage BAML toolchains".to_string(), + }, + HelpRow { + syntax: "self-update".to_string(), + summary: "Update the BAML wrapper".to_string(), + }, + ], + options: vec![ + HelpRow { + syntax: "-h, --help".to_string(), + summary: "Print help".to_string(), + }, + HelpRow { + syntax: "-V, --version".to_string(), + summary: "Print version information".to_string(), + }, + ], + } +} + fn parse_manifest_base_url(mut args: Vec) -> Result<(Vec, Option)> { let mut manifest_base_url = None; let mut i = 0; @@ -374,28 +508,29 @@ fn parse_manifest_base_url(mut args: Vec) -> Result<(Vec, Option Ok((args, manifest_base_url)) } -fn pass_through(args: Vec) -> Result { - let selector = active_selector()?; - let version = concrete_version_for_selector(&selector)?; - let cli = toolchain_cli_path(&version); - if !cli.exists() { - return Err(missing_toolchain_error(&selector, &version)); - } - verify_toolchain_version_file(&version)?; +fn pass_through(shell: &mut Shell, args: Vec) -> Result { + let resolved = resolve_toolchain()?; + pass_through_resolved(shell, args, resolved) +} +fn pass_through_resolved( + shell: &mut Shell, + args: Vec, + resolved: ResolvedToolchain, +) -> Result { // Warnings from the existing caches print immediately (no network). The // agent-skill warning is NOT printed here: it lives in the toolchain // binary (which ships nightly, unlike the wrapper), so printing it here // too would double it up. - let channel_warned = warn_if_channel_outdated(&selector, &version); + let channel_warned = warn_if_channel_outdated(shell, &resolved.selector, &resolved.version); // 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 refresh = start_lazy_refresh(&resolved.selector); - let mut command = Command::new(cli); + let mut command = Command::new(resolved.cli); command.args(args); command.env("BAML_WRAPPER_EXEC", "1"); - command.env("BAML_WRAPPER_RESOLVED_TOOLCHAIN", &version); + command.env("BAML_WRAPPER_RESOLVED_TOOLCHAIN", &resolved.version); let Some(refresh) = refresh else { // Common case: nothing to refresh, so the wrapper can hand the @@ -419,7 +554,7 @@ fn pass_through(args: Vec) -> Result { let status = command.status().context("failed to run baml-cli")?; refresh.wait(); if !channel_warned { - warn_if_channel_outdated(&selector, &version); + warn_if_channel_outdated(shell, &resolved.selector, &resolved.version); } Ok(status.code().unwrap_or(1)) } @@ -509,7 +644,7 @@ fn concrete_version_for_selector_with_base( ) .context(format!("project config: {}", path.display()))), _ => Err(anyhow!( - "error: no BAML toolchain is installed.\nRun: baml toolchain use canary\nOr: baml toolchain use nightly" + "no BAML toolchain is installed.\nRun: baml toolchain use canary\nOr: baml toolchain use nightly" )), }; } @@ -569,17 +704,6 @@ 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 channel-manifest freshness cache. /// 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 @@ -675,7 +799,11 @@ fn file_older_than(path: &Path, ttl: Duration) -> bool { /// 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 { +fn warn_if_channel_outdated( + shell: &mut Shell, + selector: &ResolvedSelector, + active_version: &str, +) -> bool { if !is_channel(&selector.selector) { return false; } @@ -684,11 +812,10 @@ fn warn_if_channel_outdated(selector: &ResolvedSelector, active_version: &str) - 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(), + drop(shell.warn(format_args!( + "Your version of baml for toolchain: {} is outdated. Update it with baml toolchain update.", selector.selector - ); + ))); true } @@ -791,12 +918,14 @@ fn should_use_manifest_cache(selector: &str, cache_path: &Path, policy: FetchPol } fn install_toolchain( + shell: &mut Shell, selector: &str, activate_channel: bool, override_url: Option<&str>, force: bool, ) -> Result<()> { install_toolchain_with_policy( + shell, selector, activate_channel, override_url, @@ -806,6 +935,7 @@ fn install_toolchain( } fn install_toolchain_with_policy( + shell: &mut Shell, selector: &str, activate_channel: bool, override_url: Option<&str>, @@ -833,7 +963,10 @@ fn install_toolchain_with_policy( ); write_state(&state)?; } - println!("installed BAML toolchain {}", manifest.version); + shell.status( + "Installed", + format_args!("BAML toolchain {}", manifest.version), + )?; Ok(()) } @@ -864,9 +997,9 @@ fn install_manifest_artifact( .map_err(|err| anyhow!("{err}")) } -fn use_toolchain(selector: &str, override_url: Option<&str>) -> Result<()> { +fn use_toolchain(shell: &mut Shell, selector: &str, override_url: Option<&str>) -> Result<()> { if is_channel(selector) { - install_toolchain(selector, true, override_url, false)?; + install_toolchain(shell, selector, true, override_url, false)?; } else { let target = baml_release::release_host_target_triple()?; if !toolchain_cli_path(selector).exists() { @@ -879,20 +1012,22 @@ fn use_toolchain(selector: &str, override_url: Option<&str>) -> Result<()> { let mut config = read_config(); config.default.selector = selector.to_string(); write_config(&config)?; - println!("selected BAML toolchain {selector}"); + shell.status("Selected", format_args!("BAML toolchain {selector}"))?; Ok(()) } -fn update_toolchain(override_url: Option<&str>) -> Result<()> { +fn update_toolchain(shell: &mut Shell, override_url: Option<&str>) -> Result<()> { let config = read_config(); if !is_channel(&config.default.selector) { - println!( + writeln!( + shell.out(), "active selector {} is an exact version and does not advance automatically.\nRun: baml toolchain use canary\nOr: baml toolchain use nightly", config.default.selector - ); + )?; return Ok(()); } install_toolchain_with_policy( + shell, &config.default.selector, true, override_url, @@ -908,15 +1043,15 @@ fn update_toolchain(override_url: Option<&str>) -> Result<()> { Ok(()) } -fn status_toolchain(override_url: Option<&str>) -> Result<()> { +fn status_toolchain(shell: &mut Shell, override_url: Option<&str>) -> Result<()> { let selector = active_selector()?; let base = manifest_base_url(override_url); - println!("active selector: {}", selector.selector); + writeln!(shell.out(), "active selector: {}", selector.selector)?; if is_channel(&selector.selector) { match concrete_version_for_selector_with_base(&selector, &base) { - Ok(version) => println!("active version: {version}"), - Err(_) => println!("active version: (none recorded locally)"), + Ok(version) => writeln!(shell.out(), "active version: {version}")?, + Err(_) => writeln!(shell.out(), "active version: (none recorded locally)")?, } let manifest = fetch_manifest(&selector.selector, override_url, FetchPolicy::ForceRemote) @@ -926,46 +1061,64 @@ fn status_toolchain(override_url: Option<&str>) -> Result<()> { selector.selector ) })?; - println!("latest {}: {}", selector.selector, manifest.version); + writeln!( + shell.out(), + "latest {}: {}", + selector.selector, + manifest.version + )?; match concrete_version_for_selector_with_base(&selector, &base).ok() { Some(active) if active == manifest.version => { - println!("status: up to date"); + writeln!(shell.out(), "status: up to date")?; } Some(_) => { - println!( + writeln!( + shell.out(), "status: a newer {} toolchain is available", selector.selector - ); - println!("Run: baml toolchain update"); + )?; + writeln!(shell.out(), "Run: baml toolchain update")?; } None => { - println!( + writeln!( + shell.out(), "status: no active {} toolchain is recorded locally", selector.selector - ); - println!("Run: baml toolchain use {}", selector.selector); + )?; + writeln!(shell.out(), "Run: baml toolchain use {}", selector.selector)?; } } return Ok(()); } if toolchain_cli_path(&selector.selector).exists() { - println!("selected version: {}", selector.selector); + writeln!(shell.out(), "selected version: {}", selector.selector)?; } else { - println!("selected version: {} (not installed)", selector.selector); - println!("Run: baml toolchain install {}", selector.selector); + writeln!( + shell.out(), + "selected version: {} (not installed)", + selector.selector + )?; + writeln!( + shell.out(), + "Run: baml toolchain install {}", + selector.selector + )?; } let canary = fetch_manifest("canary", override_url, FetchPolicy::ForceRemote) .context("failed to check latest canary from the remote manifest")?; let nightly = fetch_manifest("nightly", override_url, FetchPolicy::ForceRemote) .context("failed to check latest nightly from the remote manifest")?; - println!("latest canary: {}", canary.version); - println!("latest nightly: {}", nightly.version); - println!("status: exact versions do not advance automatically"); - println!("Run: baml toolchain use canary"); - println!("Or: baml toolchain use nightly"); + writeln!(shell.out(), "latest canary: {}", canary.version)?; + writeln!(shell.out(), "latest nightly: {}", nightly.version)?; + writeln!( + shell.out(), + "status: exact versions do not advance automatically" + )?; + writeln!(shell.out(), "Run: baml toolchain use canary")?; + writeln!(shell.out(), "Or: baml toolchain use nightly")?; Ok(()) } @@ -987,46 +1140,47 @@ fn installed_toolchains() -> Vec { versions } -fn list_toolchains() { +fn list_toolchains(shell: &mut Shell) -> Result<()> { let config = read_config(); let state = read_state(); - println!("default selector: {}", config.default.selector); + writeln!(shell.out(), "default selector: {}", config.default.selector)?; for (channel, state) in state.channels { - println!("{channel}: {}", state.active_version); + writeln!(shell.out(), "{channel}: {}", state.active_version)?; } let installed = installed_toolchains(); if installed.is_empty() { - println!("installed toolchains: (none)"); + writeln!(shell.out(), "installed toolchains: (none)")?; } else { - println!("installed toolchains:"); + writeln!(shell.out(), "installed toolchains:")?; for version in installed { - println!(" {version}"); + writeln!(shell.out(), " {version}")?; } } - println!(); - println!("Remote versions were not checked."); - println!("Run: baml toolchain status"); + writeln!(shell.out())?; + writeln!(shell.out(), "Remote versions were not checked.")?; + writeln!(shell.out(), "Run: baml toolchain status")?; + Ok(()) } -fn uninstall_toolchain(version: &str) -> Result<()> { +fn uninstall_toolchain(shell: &mut Shell, version: &str) -> Result<()> { let dir = toolchains_dir().join(version); if !dir.exists() { return Err(anyhow!("BAML toolchain {version} is not installed")); } fs::remove_dir_all(dir)?; - println!("uninstalled BAML toolchain {version}"); + shell.status("Uninstalled", format_args!("BAML toolchain {version}"))?; Ok(()) } #[cfg(any(not(feature = "self-update"), feature = "no-self-update"))] -fn self_update() -> Result<()> { +fn self_update(_shell: &mut Shell) -> Result<()> { Err(anyhow!( "self-update is disabled in this build.\nUpdate BAML with your package manager." )) } #[cfg(all(feature = "self-update", not(feature = "no-self-update")))] -fn self_update() -> Result<()> { +fn self_update(shell: &mut Shell) -> Result<()> { let current = env::current_exe()?; let manifest = fetch_wrapper_manifest()?; let target = baml_release::release_host_target_triple()?; @@ -1058,11 +1212,17 @@ fn self_update() -> Result<()> { .arg(¤t) .spawn() .with_context(|| format!("failed to spawn updater {}", current.display()))?; - println!("updating BAML wrapper to {}", manifest.version); + shell.status( + "Updating", + format_args!("BAML wrapper to {}", manifest.version), + )?; return Ok(()); } fs::rename(tmp, current)?; - println!("updated BAML wrapper to {}", manifest.version); + shell.status( + "Updated", + format_args!("BAML wrapper to {}", manifest.version), + )?; Ok(()) } @@ -1148,6 +1308,51 @@ fn write_text_atomic(path: &Path, text: &str) -> Result<()> { mod tests { use super::*; + #[test] + fn only_top_level_help_is_owned_by_wrapper() { + assert!(is_root_help_request(&["--help".to_string()])); + assert!(is_root_help_request(&["-h".to_string()])); + assert!(is_root_help_request(&["help".to_string()])); + assert!(!is_root_help_request(&[ + "check".to_string(), + "--help".to_string() + ])); + assert!(!is_root_help_request(&[ + "help".to_string(), + "check".to_string() + ])); + } + + #[test] + fn wrapper_commands_are_added_before_clap_help() { + let mut help = RootHelpV1 { + schema_version: baml_shell::ROOT_HELP_SCHEMA_V1.to_string(), + name: "baml".to_string(), + version: "1".to_string(), + about: "BAML".to_string(), + usage: "baml ".to_string(), + commands: vec![ + HelpRow { + syntax: "check".to_string(), + summary: "Check".to_string(), + }, + HelpRow { + syntax: "help".to_string(), + summary: "Print help".to_string(), + }, + ], + options: vec![], + }; + add_wrapper_commands(&mut help); + assert_eq!( + help.commands + .iter() + .map(|row| row.syntax.as_str()) + .collect::>(), + ["check", "toolchain", "self-update", "help"] + ); + } + #[test] fn cache_allowed_uses_fresh_channel_cache() { let tmp = tempfile::NamedTempFile::new().unwrap(); diff --git a/baml_language/crates/baml/tests/freshness_e2e.rs b/baml_language/crates/baml/tests/freshness_e2e.rs index fc874f55e69..768231e1c49 100644 --- a/baml_language/crates/baml/tests/freshness_e2e.rs +++ b/baml_language/crates/baml/tests/freshness_e2e.rs @@ -1,5 +1,4 @@ -//! End-to-end tests for the wrapper's toolchain freshness warning: run the real `baml` -//! binary against a temp `BAML_HOME` and assert on stderr. +//! End-to-end tests for wrapper output and delegation. //! //! Unix-only: the tests install a fake shell-script `baml-cli` for //! `pass_through` to exec. @@ -49,6 +48,16 @@ impl TestHome { home } + fn write_cli(&self, script: &str) { + let cli = self.root.join("toolchains/0.11.0/bin/baml-cli"); + fs::write(&cli, script).unwrap(); + #[allow(clippy::permissions_set_readonly_false)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).unwrap(); + } + } + /// Write `state.toml` with the canary channel active at 0.11.0. fn write_state(&self) { fs::write( @@ -73,17 +82,7 @@ impl TestHome { /// 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(); + let output = self.run_args_from(cwd, &["hello"], extra_env); assert!( output.status.success(), "wrapper failed: {}", @@ -96,6 +95,25 @@ impl TestHome { output } + fn run_args(&self, args: &[&str]) -> Output { + let cwd = tempfile::tempdir().unwrap(); + self.run_args_from(cwd.path(), args, &[]) + } + + fn run_args_from(&self, cwd: &Path, args: &[&str], extra_env: &[(&str, &str)]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_baml")); + command + .args(args) + .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); + } + command.output().unwrap() + } + fn run(&self) -> Output { let cwd = tempfile::tempdir().unwrap(); self.run_from(cwd.path(), &[]) @@ -120,3 +138,104 @@ fn silent_when_toolchain_matches_cached_manifest() { let stderr = stderr_of(&home.run()); assert!(!stderr.contains("outdated"), "{stderr}"); } + +#[test] +fn root_help_merges_toolchain_metadata_with_wrapper_commands() { + let home = TestHome::new(); + home.write_cli( + r#"#!/bin/sh +if [ "$1" = "__baml-root-help-v1" ]; then + [ "$BAML_WRAPPER_EXEC" = "1" ] || exit 8 + [ "$BAML_WRAPPER_RESOLVED_TOOLCHAIN" = "0.11.0" ] || exit 8 + printf '%s\n' '{"schema_version":"baml.root-help.v1","name":"baml","version":"0.11.0","about":"BAML CLI","usage":"baml [OPTIONS] ","commands":[{"syntax":"check","summary":"Check BAML"},{"syntax":"help","summary":"Print help"}],"options":[{"syntax":"-h, --help","summary":"Print help"}]}' + exit 0 +fi +exit 9 +"#, + ); + let output = home.run_args(&["--help"]); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + for expected in [ + "check", + "Check BAML", + "toolchain", + "Manage installed BAML toolchains", + "self-update", + "Update the BAML wrapper", + ] { + assert!(stdout.contains(expected), "{stdout}"); + } + assert_eq!(stdout.matches("Usage:").count(), 1, "{stdout}"); +} + +#[test] +fn root_help_falls_back_to_old_toolchain_help() { + let home = TestHome::new(); + home.write_cli("#!/bin/sh\nif [ \"$1\" = \"__baml-root-help-v1\" ]; then exit 2; fi\nif [ \"$1\" = \"--help\" ]; then printf '%s\\n' 'legacy baml-cli help'; exit 0; fi\nexit 9\n"); + let output = home.run_args(&["--help"]); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "legacy baml-cli help\n" + ); +} + +#[test] +fn root_help_falls_back_from_an_unsupported_schema() { + let home = TestHome::new(); + home.write_cli( + r#"#!/bin/sh +if [ "$1" = "__baml-root-help-v1" ]; then + printf '%s\n' '{"schema_version":"baml.root-help.v2","name":"baml","version":"2","about":"BAML","usage":"baml ","commands":[],"options":[]}' + exit 0 +fi +if [ "$1" = "--help" ]; then printf '%s\n' 'newer baml-cli help'; exit 0; fi +exit 9 +"#, + ); + let output = home.run_args(&["--help"]); + assert!(output.status.success()); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "newer baml-cli help\n" + ); +} + +#[test] +fn root_help_falls_back_from_malformed_metadata() { + let home = TestHome::new(); + home.write_cli( + "#!/bin/sh\nif [ \"$1\" = \"__baml-root-help-v1\" ]; then printf '%s\\n' 'not json'; exit 0; fi\nif [ \"$1\" = \"--help\" ]; then printf '%s\\n' 'fallback help'; exit 0; fi\nexit 9\n", + ); + let output = home.run_args(&["--help"]); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "fallback help\n"); +} + +#[test] +fn subcommand_help_is_forwarded_unchanged() { + let home = TestHome::new(); + home.write_cli("#!/bin/sh\nprintf '%s\\n' \"$*\"\n"); + let output = home.run_args(&["check", "--help"]); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "check --help\n"); +} + +#[test] +fn root_help_works_before_a_toolchain_is_installed() { + let home = tempfile::tempdir().unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_baml")) + .arg("--help") + .env("BAML_HOME", home.path()) + .env("HOME", home.path()) + .env_remove("BAML_VERSION") + .output() + .unwrap(); + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Install or select a toolchain"), "{stdout}"); + assert!(stdout.contains("toolchain"), "{stdout}"); + assert!(stdout.contains("self-update"), "{stdout}"); + assert!(!stdout.contains("check"), "{stdout}"); +} diff --git a/baml_language/crates/baml/tests/no_self_update_e2e.rs b/baml_language/crates/baml/tests/no_self_update_e2e.rs index 7b2649e939a..b330746cb95 100644 --- a/baml_language/crates/baml/tests/no_self_update_e2e.rs +++ b/baml_language/crates/baml/tests/no_self_update_e2e.rs @@ -17,6 +17,6 @@ fn self_update_is_disabled_without_network_access() { assert!(output.stdout.is_empty()); assert_eq!( String::from_utf8_lossy(&output.stderr), - "self-update is disabled in this build.\nUpdate BAML with your package manager.\n" + "error: self-update is disabled in this build.\nUpdate BAML with your package manager.\n" ); } diff --git a/baml_language/crates/baml_cli/Cargo.toml b/baml_language/crates/baml_cli/Cargo.toml index d3aa17ecce7..7720fdb9242 100644 --- a/baml_language/crates/baml_cli/Cargo.toml +++ b/baml_language/crates/baml_cli/Cargo.toml @@ -26,6 +26,7 @@ baml_lsp2_actions = { workspace = true } baml_lsp_server = { workspace = true } baml_project = { workspace = true } baml_release = { workspace = true } +baml_shell = { workspace = true } baml_type = { workspace = true } baml_version = { workspace = true } baml_workspace = { workspace = true } diff --git a/baml_language/crates/baml_cli/src/auth.rs b/baml_language/crates/baml_cli/src/auth.rs index 4d44633ceec..5a2485cbd6f 100644 --- a/baml_language/crates/baml_cli/src/auth.rs +++ b/baml_language/crates/baml_cli/src/auth.rs @@ -296,7 +296,7 @@ impl TokenArgs { let mut creds = match Credentials::read()? { Some(creds) => creds, None => { - eprintln!("Not logged in. Run `baml login`."); + crate::reporter::print_error("Not logged in. Run `baml login`."); return Ok(crate::ExitCode::Other); } }; diff --git a/baml_language/crates/baml_cli/src/commands.rs b/baml_language/crates/baml_cli/src/commands.rs index 6926ab3bbf8..dc3b1bfe178 100644 --- a/baml_language/crates/baml_cli/src/commands.rs +++ b/baml_language/crates/baml_cli/src/commands.rs @@ -16,8 +16,7 @@ pub(crate) const fn release_version() -> &'static str { author, version = release_version(), about = "A CLI tool for working with BAML. Learn more at https://docs.boundaryml.com.", - long_about = None, - after_help = "Manage installed BAML toolchains:\n baml toolchain --help" + long_about = None )] #[command(styles = crate::reporter::CLAP_STYLING)] #[command(propagate_version = true)] @@ -290,6 +289,124 @@ fn baml_internal_env_is_truthy() -> bool { .unwrap_or(false) } +pub(crate) fn root_help_v1() -> baml_shell::RootHelpV1 { + let mut command = RuntimeCli::command(); + command.build(); + let mut usage_command = command + .clone() + .styles(clap::builder::styling::Styles::plain()); + let usage = usage_command.render_usage().to_string(); + let usage = usage + .trim() + .strip_prefix("Usage:") + .unwrap_or(usage.trim()) + .trim() + .to_string(); + let commands = command + .get_subcommands() + .filter(|subcommand| !subcommand.is_hide_set()) + .map(|subcommand| baml_shell::HelpRow { + syntax: subcommand.get_name().to_string(), + summary: subcommand + .get_long_about() + .or_else(|| subcommand.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + }) + .collect(); + let options = command + .get_arguments() + .filter(|arg| !arg.is_hide_set() && !arg.is_positional()) + .map(|arg| baml_shell::HelpRow { + syntax: help_option_syntax(arg), + summary: help_option_summary(arg), + }) + .collect(); + baml_shell::RootHelpV1 { + schema_version: baml_shell::ROOT_HELP_SCHEMA_V1.to_string(), + name: command + .get_bin_name() + .unwrap_or_else(|| command.get_name()) + .to_string(), + version: command.get_version().unwrap_or_default().to_string(), + about: command + .get_long_about() + .or_else(|| command.get_about()) + .map(ToString::to_string) + .unwrap_or_default(), + usage, + commands, + options, + } +} + +fn help_option_syntax(arg: &clap::Arg) -> String { + let mut syntax = match (arg.get_short(), arg.get_long()) { + (Some(short), Some(long)) => format!("-{short}, --{long}"), + (Some(short), None) => format!("-{short}"), + (None, Some(long)) => format!("--{long}"), + (None, None) => arg.get_id().to_string(), + }; + if arg.get_action().takes_values() { + let names = arg + .get_value_names() + .map(|names| names.iter().map(ToString::to_string).collect::>()) + .unwrap_or_else(|| vec![arg.get_id().to_string().to_ascii_uppercase()]); + for name in names { + syntax.push_str(&format!(" <{name}>")); + } + if arg + .get_num_args() + .is_some_and(|range| range.max_values() > 1) + { + syntax.push_str("..."); + } + } + syntax +} + +fn help_option_summary(arg: &clap::Arg) -> String { + let mut summary = arg + .get_long_help() + .or_else(|| arg.get_help()) + .map(ToString::to_string) + .unwrap_or_default(); + if let Some(env) = arg.get_env() { + append_help_detail(&mut summary, format!("env: {}", env.to_string_lossy())); + } + if !arg.get_default_values().is_empty() { + let values = arg + .get_default_values() + .iter() + .map(|value| value.to_string_lossy()) + .collect::>() + .join(", "); + append_help_detail(&mut summary, format!("default: {values}")); + } + let possible = arg + .get_possible_values() + .into_iter() + .filter(|value| !value.is_hide_set()) + .map(|value| value.get_name().to_string()) + .collect::>(); + if !possible.is_empty() { + append_help_detail( + &mut summary, + format!("possible values: {}", possible.join(", ")), + ); + } + summary +} + +fn append_help_detail(summary: &mut String, detail: String) { + if !summary.is_empty() { + summary.push(' '); + } + summary.push('['); + summary.push_str(&detail); + summary.push(']'); +} + #[cfg(test)] mod tests { use super::*; @@ -389,6 +506,21 @@ mod tests { assert!(help.contains("Open the BAML playground"), "{help}"); } + #[test] + fn serialized_root_help_contains_public_commands_and_options() { + let help = root_help_v1(); + assert_eq!(help.schema_version, baml_shell::ROOT_HELP_SCHEMA_V1); + assert_eq!(help.name, "baml"); + assert_eq!(help.usage, "baml [OPTIONS] "); + assert!(help.commands.iter().any(|row| row.syntax == "check")); + assert!( + help.options + .iter() + .any(|row| row.syntax.contains("--color")) + ); + assert!(!help.commands.iter().any(|row| row.syntax == "telemetry")); + } + #[test] fn output_dials_are_global_and_independent() { let cli = RuntimeCli::parse_from_smart(vec![ diff --git a/baml_language/crates/baml_cli/src/describe_command.rs b/baml_language/crates/baml_cli/src/describe_command.rs index d3936693122..f7ee7ac0636 100644 --- a/baml_language/crates/baml_cli/src/describe_command.rs +++ b/baml_language/crates/baml_cli/src/describe_command.rs @@ -161,7 +161,7 @@ fn print_did_you_mean(db: &ProjectDatabase, name: &str) { let suggestions = suggest_similar_kinded(db, name, 5); if !suggestions.is_empty() { eprintln!(); - eprintln!("did you mean:"); + crate::reporter::print_note("did you mean:"); // did-you-mean writes to stderr, so use a stderr-bound painter (gets the // stderr color decision, never stdout's). let painter = crate::paint::Painter::stderr(); @@ -298,8 +298,8 @@ impl DescribeArgs { // ── --symbols deprecation ─────────────────────────────────────────── if self.symbols { - eprintln!( - "warning: `--symbols` is deprecated. Use `baml describe` with no arguments instead." + crate::reporter::print_warning( + "`--symbols` is deprecated. Use `baml describe` with no arguments instead.", ); } @@ -323,7 +323,7 @@ impl DescribeArgs { Some(ResolvedTarget::Package(pkg)) => { let entries = baml_lsp2_actions::list_package_items(&db, pkg); if entries.is_empty() { - eprintln!("no symbols found"); + crate::reporter::print_error("no symbols found"); return Ok(crate::ExitCode::Other); } if self.json { @@ -347,10 +347,10 @@ impl DescribeArgs { ); if baml_lsp2_actions::resolve_target(&db, user_pkg, pkg_name).is_some() { eprintln!(); - eprintln!( - "note: your project also defines `{pkg_name}`. \ + crate::reporter::print_note(format_args!( + "your project also defines `{pkg_name}`. \ Use `baml describe root.{pkg_name}` to see your definition." - ); + )); } } Ok(crate::ExitCode::Success) @@ -359,7 +359,7 @@ impl DescribeArgs { let entries = baml_lsp2_actions::list_namespace_items(&db, package, &ns_path) .unwrap_or_default(); if entries.is_empty() { - eprintln!("no symbols found in namespace"); + crate::reporter::print_error("no symbols found in namespace"); return Ok(crate::ExitCode::Other); } if self.json { @@ -395,7 +395,7 @@ impl DescribeArgs { } Ok(crate::ExitCode::Success) } else { - eprintln!("no symbol found: {name}"); + crate::reporter::print_error(format_args!("no symbol found: {name}")); print_did_you_mean(&db, name); Ok(crate::ExitCode::Other) } @@ -428,7 +428,7 @@ impl DescribeArgs { } Ok(crate::ExitCode::Success) } else { - eprintln!("no symbol found: {name}"); + crate::reporter::print_error(format_args!("no symbol found: {name}")); print_did_you_mean(&db, name); Ok(crate::ExitCode::Other) } @@ -439,7 +439,7 @@ impl DescribeArgs { let descriptions = describe(&db, &describe_files, name); if descriptions.is_empty() { - eprintln!("no symbol found: {name}"); + crate::reporter::print_error(format_args!("no symbol found: {name}")); print_did_you_mean(&db, name); return Ok(crate::ExitCode::Other); } diff --git a/baml_language/crates/baml_cli/src/feedback_command.rs b/baml_language/crates/baml_cli/src/feedback_command.rs index 6ec5df5bd96..50e1989d61b 100644 --- a/baml_language/crates/baml_cli/src/feedback_command.rs +++ b/baml_language/crates/baml_cli/src/feedback_command.rs @@ -99,9 +99,9 @@ impl FeedbackArgs { // Non-interactive email mode without a session: instruct and // exit rather than trying to run an interactive login under a // flag that suggests automation. - eprintln!( + crate::reporter::print_error( "Reporting via email requires a login. Run `baml login`, then \ - re-run this command." + re-run this command.", ); return Ok(crate::ExitCode::Other); } else if creds.feedback_anonymous { diff --git a/baml_language/crates/baml_cli/src/grep_command.rs b/baml_language/crates/baml_cli/src/grep_command.rs index b4f1f80d4cd..2b33be2cf77 100644 --- a/baml_language/crates/baml_cli/src/grep_command.rs +++ b/baml_language/crates/baml_cli/src/grep_command.rs @@ -85,7 +85,7 @@ impl GrepArgs { if self.symbols { let symbols = list_symbols(&db, &source_files, &kind_filter); if symbols.is_empty() { - eprintln!("no symbols found"); + crate::reporter::print_error("no symbols found"); return Ok(crate::ExitCode::Other); } if self.json { @@ -128,7 +128,9 @@ impl GrepArgs { let pattern = match &self.pattern { Some(p) => p.as_str(), None => { - eprintln!("no pattern provided; use `--symbols` to list all symbols"); + crate::reporter::print_error( + "no pattern provided; use `--symbols` to list all symbols", + ); return Ok(crate::ExitCode::InvalidArgs); } }; @@ -137,7 +139,7 @@ impl GrepArgs { if self.def { let descriptions = describe(&db, &source_files, pattern); if descriptions.is_empty() { - eprintln!("no symbol found: {pattern}"); + crate::reporter::print_error(format_args!("no symbol found: {pattern}")); return Ok(crate::ExitCode::Other); } if self.json { @@ -169,7 +171,7 @@ impl GrepArgs { if self.refs { let descriptions = describe(&db, &source_files, pattern); if descriptions.is_empty() { - eprintln!("no symbol found: {pattern}"); + crate::reporter::print_error(format_args!("no symbol found: {pattern}")); return Ok(crate::ExitCode::Other); } if self.json { @@ -252,7 +254,9 @@ impl GrepArgs { GrepMode::Semantic => { if result.descriptions.is_empty() { // Symbol exists but was filtered out by --kind. - eprintln!("no symbol found matching pattern and kind filter: {pattern}"); + crate::reporter::print_error(format_args!( + "no symbol found matching pattern and kind filter: {pattern}" + )); return Ok(crate::ExitCode::Other); } let history: std::collections::HashSet<&str> = @@ -267,7 +271,7 @@ impl GrepArgs { } GrepMode::TextSearch => { if result.text_matches.is_empty() { - eprintln!("no matches found for: {pattern}"); + crate::reporter::print_error(format_args!("no matches found for: {pattern}")); return Ok(crate::ExitCode::Other); } render_text_matches(&db, &result.text_matches, &from); diff --git a/baml_language/crates/baml_cli/src/lib.rs b/baml_language/crates/baml_cli/src/lib.rs index 129c1e97acb..23a16549de6 100644 --- a/baml_language/crates/baml_cli/src/lib.rs +++ b/baml_language/crates/baml_cli/src/lib.rs @@ -57,6 +57,10 @@ pub(crate) mod util; use anyhow::{Result, anyhow}; +pub fn root_help_v1() -> baml_shell::RootHelpV1 { + commands::root_help_v1() +} + #[derive(Debug, Clone)] pub enum ExitCode { Success, diff --git a/baml_language/crates/baml_cli/src/main.rs b/baml_language/crates/baml_cli/src/main.rs index 123bfd45b69..0d11c9813cf 100644 --- a/baml_language/crates/baml_cli/src/main.rs +++ b/baml_language/crates/baml_cli/src/main.rs @@ -18,12 +18,24 @@ fn main() { // TODO: baml_log is disabled for now // baml_log::init()?; - warn_if_direct_invocation(); - let argv: Vec = std::env::args().collect(); + if argv.len() == 2 && argv[1] == baml_shell::ROOT_HELP_COMMAND_V1 { + let mut stdout = std::io::stdout().lock(); + let result = (|| -> anyhow::Result<()> { + serde_json::to_writer(&mut stdout, &baml_cli::root_help_v1())?; + writeln!(stdout)?; + Ok(()) + })(); + if let Err(err) = result { + baml_cli::reporter::print_error(err); + std::process::exit(1); + } + return; + } + warn_if_direct_invocation(); // Route every top-level error through the graphical printer - // (bold-red "Error:" header + cause chain) so plain bails look the + // (bold-red `error:` header + cause chain) so plain bails look the // same as compiler diagnostics. Returning `Result<()>` from `main` // would defer to anyhow's Debug printer instead, which renders the // header uncolored. @@ -41,10 +53,9 @@ fn warn_if_direct_invocation() { if env_flag("BAML_WRAPPER_EXEC") || env_flag("BAML_CLI_ALLOW_DIRECT") { return; } - let _ = writeln!( - std::io::stderr(), - "warning: using the internal BAML toolchain binary directly is not recommended. Use `baml` instead." - ); + drop(baml_shell::Shell::new().warn( + "using the internal BAML toolchain binary directly is not recommended. Use `baml` instead.", + )); } fn env_flag(name: &str) -> bool { diff --git a/baml_language/crates/baml_cli/src/output.rs b/baml_language/crates/baml_cli/src/output.rs index 15100323e16..88c36579ce4 100644 --- a/baml_language/crates/baml_cli/src/output.rs +++ b/baml_language/crates/baml_cli/src/output.rs @@ -140,11 +140,23 @@ pub(crate) fn init(args: OutputArgs) { let policy = resolve(args, output_signals()); console::set_colors_enabled(policy.stdout.color); console::set_colors_enabled_stderr(policy.stderr.color); + baml_shell::set_default_color_choices( + shell_color(policy.stdout.color), + shell_color(policy.stderr.color), + ); *OUTPUT_POLICY .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = policy; } +fn shell_color(enabled: bool) -> baml_shell::ColorChoice { + if enabled { + baml_shell::ColorChoice::Always + } else { + baml_shell::ColorChoice::Never + } +} + pub(crate) fn policy() -> OutputPolicy { *OUTPUT_POLICY .read() diff --git a/baml_language/crates/baml_cli/src/reporter.rs b/baml_language/crates/baml_cli/src/reporter.rs index b51a1ce0fc1..7ec26b48862 100644 --- a/baml_language/crates/baml_cli/src/reporter.rs +++ b/baml_language/crates/baml_cli/src/reporter.rs @@ -1,148 +1,61 @@ -//! Cargo-style status reporter. -//! -//! Provides a [`Reporter`] that emits cargo-shaped status lines -//! (` Compiling 552 files`). `console::style` auto-strips ANSI when -//! stderr is not a TTY, so piped output stays plain. -//! -//! Verb formatting matches cargo: 12-char right-aligned, bold green for -//! normal phases. +//! Cargo-style status reporting for BAML commands. -// Status reporting is intrinsically `print*!` to stderr — that's the -// whole job of this module. The workspace clippy ban exists to flag -// stray dev-prints elsewhere, not the deliberate non-TTY fallbacks -// inside `Reporter::status` / `spin` / `finish` / `warning`. -#![allow(clippy::print_stderr)] +use std::{cell::RefCell, time::Instant}; -use std::time::Instant; - -use console::{Color, Style}; +pub use baml_shell::CLAP_STYLING; +use baml_shell::Shell; use indicatif::HumanDuration; -/// Brand purple `#A855F7` for the cargo-style verb. Rendered via 24-bit -/// truecolor ANSI (`\x1b[38;2;168;85;247m`) on terminals that support it; -/// `console::Style` strips the escape when stderr isn't a TTY, so piped -/// output stays plain. -const VERB_COLOR: Color = Color::TrueColor(0xA8, 0x55, 0xF7); - -fn verb_style() -> Style { - Style::new().fg(VERB_COLOR).bold() -} - -/// Clap help-text styling re-exported from `baml_exec`. Lives there so -/// the packed-binary host — which can't depend on `baml_cli` — gets the -/// same brand-purple look as `baml run`, `baml pack`, etc. Everything -/// in this crate that wants the styled `--help` palette pulls it from -/// here for the shorter import path. -pub use baml_exec::CLAP_STYLING; - -/// Cargo-style status reporter. pub struct Reporter { started: Instant, + shell: RefCell, } impl Reporter { - /// Construct a reporter. pub fn new() -> Self { Self { started: Instant::now(), + shell: RefCell::new(Shell::new()), } } - /// Print a one-shot status line that stays in the scrollback — - /// cargo's ` Compiling foo v0.1.0` shape. pub fn status(&self, verb: &str, msg: impl AsRef) { - let line = format_status(verb, msg.as_ref()); - eprintln!("{line}"); + drop(self.shell.borrow_mut().status(verb, msg.as_ref())); } - /// Mark a new phase with a persistent cargo-style line. pub fn spin(&self, verb: &str, msg: impl AsRef) { - eprintln!("{}", format_status(verb, msg.as_ref())); + self.status(verb, msg); } - /// Print a final cargo-style "Finished" line with elapsed wall-clock time. pub fn finish(&self, verb: &str, msg: impl AsRef) { - // `{:#}` is `HumanDuration`'s alternate form — the compact - // `5s` / `2m` / `1h` shape, matching cargo's `Finished` line. - // The default `{}` form spells out `5 seconds`, which is too - // wordy for a one-line status. let elapsed = HumanDuration(self.started.elapsed()); - let line = format!("{} in {elapsed:#}", format_status(verb, msg.as_ref())); - eprintln!("{line}"); + drop( + self.shell + .borrow_mut() + .status(verb, format_args!("{} in {elapsed:#}", msg.as_ref())), + ); } - /// Preserve the old call shape for diagnostic/error paths. pub fn abandon(&self) {} - /// Run `f` and return whatever it returns. pub fn suspend(&self, f: impl FnOnce() -> R) -> R { f() } -} - -impl Default for Reporter { - fn default() -> Self { - Self::new() - } -} -/// Format a single status line as cargo does it: 12-char right-aligned -/// bold purple verb (BAML brand `#A855F7`), then a space, then the -/// payload. Padding is computed on the *unstyled* verb so ANSI escape -/// bytes don't blow up column counts. `console::Style` strips the escape -/// when stderr isn't a TTY, so piped output stays plain. -fn format_status(verb: &str, msg: &str) -> String { - let padded = format!("{verb:>12}"); - let styled = verb_style().apply_to(padded); - format!("{styled} {msg}") -} - -/// Re-exports of the shared `error:` / `warning:` printers (defined in -/// `baml_exec::diag_print`). Lives in this module so callers across -/// `baml_cli` keep importing `crate::reporter::print_error` etc., while -/// the pack host — which can't depend on `baml_cli` — pulls the same -/// functions from `baml_exec` directly. -pub use baml_exec::{print_anyhow_error, print_error, print_warning}; - -impl Reporter { - /// Print an error through this reporter. pub fn error(&self, msg: impl std::fmt::Display) { self.abandon(); - print_error(msg); + drop(self.shell.borrow_mut().error(msg)); } - /// Print a warning. Mirrors the formatting of [`print_warning`]. pub fn warning(&self, msg: impl std::fmt::Display) { - let line = format!( - "{}: {msg}", - Style::new().yellow().bold().apply_to("warning") - ); - eprintln!("{line}"); + drop(self.shell.borrow_mut().warn(msg)); } } -#[cfg(test)] -mod tests { - use super::*; - - /// Status lines pad to cargo's 12-char column, putting the verb - /// flush against where cargo prints `Compiling`. Counting on unstyled - /// text keeps ANSI codes from breaking the alignment. - #[test] - fn format_status_pads_verb_to_column_12() { - let line = format_status("Compiling", "foo"); - // Strip ANSI to inspect the underlying spacing. - let stripped = console::strip_ansi_codes(&line); - assert!( - stripped.starts_with(" Compiling foo"), - "got: {stripped:?}" - ); - // Sanity: longer verb shifts the leading padding. - let line = format_status("Loading", "bar"); - let stripped = console::strip_ansi_codes(&line); - assert!( - stripped.starts_with(" Loading bar"), - "got: {stripped:?}" - ); +impl Default for Reporter { + fn default() -> Self { + Self::new() } } + +pub use baml_exec::{print_anyhow_error, print_error, print_note, print_warning}; diff --git a/baml_language/crates/baml_cli/src/run_command.rs b/baml_language/crates/baml_cli/src/run_command.rs index 2e7e812ae47..5618dbc93b1 100644 --- a/baml_language/crates/baml_cli/src/run_command.rs +++ b/baml_language/crates/baml_cli/src/run_command.rs @@ -1251,7 +1251,7 @@ impl RunArgs { match output { OutputFormat::Debug => { - Self::print_list_debug(&functions, scripts, namespaces, self.verbose); + Self::print_list_debug(&functions, scripts, namespaces, self.verbose)?; } OutputFormat::Json => Self::print_list_json(&functions, scripts, namespaces), } @@ -1269,15 +1269,10 @@ impl RunArgs { scripts: &HashMap>, namespaces: &HashSet, verbose: bool, - ) { + ) -> Result<()> { + use baml_shell::{Shell, ThemeStyle}; use bex_vm_types::FunctionOrigin; - use console::Style; - - let header_style = Style::new() - .fg(console::Color::TrueColor(0xA8, 0x55, 0xF7)) - .bold(); - let dim = Style::new().color256(244); - let header = |s: &str| println!("{}", header_style.apply_to(s)); + let mut shell = Shell::new(); // Visible by default: user-authored entries only. Pass // `--verbose` to expose compiler-synthesized helpers @@ -1308,25 +1303,25 @@ impl RunArgs { namespace_mains.dedup(); if !scripts.is_empty() { - header("Scripts"); + shell.writeln_out_styled(ThemeStyle::Heading, "Scripts")?; let mut names: Vec<&String> = scripts.keys().collect(); names.sort(); for name in names { - println!(" {name}"); + writeln!(shell.out(), " {name}")?; } - println!(); + writeln!(shell.out())?; } if !namespace_mains.is_empty() { - header("Namespaces"); + shell.writeln_out_styled(ThemeStyle::Heading, "Namespaces")?; for ns in &namespace_mains { - println!(" {ns}"); + writeln!(shell.out(), " {ns}")?; } - println!(); + writeln!(shell.out())?; } if !visible.is_empty() { - header("Functions"); + shell.writeln_out_styled(ThemeStyle::Heading, "Functions")?; for func in &visible { let params: Vec = func .param_names @@ -1347,33 +1342,35 @@ impl RunArgs { } else { format!("<{}>", func.display_type_params.join(", ")) }; - let suffix = if func.is_llm { - format!(" {}", dim.apply_to("[llm]")) - } else { - String::new() - }; - println!( - " {}{}({}) -> {}{suffix}", + write!( + shell.out(), + " {}{}({}) -> {}", func.display_name, generic_suffix, params.join(", "), func.display_return_type, - ); + )?; + if func.is_llm { + write!(shell.out(), " ")?; + shell.write_out_styled(ThemeStyle::Dim, "[llm]")?; + } + writeln!(shell.out())?; } - println!(); + writeln!(shell.out())?; } // When the default filter hid everything, surface that fact // rather than leaving the user staring at an empty section. if !verbose && visible.is_empty() && !functions.is_empty() { - println!( - "{}", - dim.apply_to(format!( + shell.writeln_out_styled( + ThemeStyle::Dim, + format_args!( "(hiding {} compiler-synthesized function(s); pass --verbose to show them)", functions.len() - )) - ); + ), + )?; } + Ok(()) } fn print_list_json( diff --git a/baml_language/crates/baml_cli/src/telemetry/storage.rs b/baml_language/crates/baml_cli/src/telemetry/storage.rs index 25156ca8e18..54607be583f 100644 --- a/baml_language/crates/baml_cli/src/telemetry/storage.rs +++ b/baml_language/crates/baml_cli/src/telemetry/storage.rs @@ -30,7 +30,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use console::style; +use baml_shell::{Shell, ThemeStyle}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -391,12 +391,19 @@ impl Drop for InvocationGuard { /// team like ours decides what to build next," which is more honest for /// a team our size. fn print_first_run_notice() { - let attention = style("Attention:").magenta().bold(); - let url = style(TELEMETRY_URL).cyan(); - eprintln!("\n{attention} BAML now collects completely anonymous CLI usage telemetry."); - eprintln!("This is how a small team like ours decides what to build next."); - eprintln!("Learn more, including how to opt out, at:"); - eprintln!("{url}\n"); + let mut shell = Shell::new(); + drop(writeln!(shell.err())); + drop(shell.warn("BAML now collects completely anonymous CLI usage telemetry.")); + drop(writeln!( + shell.err(), + "This is how a small team like ours decides what to build next." + )); + drop(writeln!( + shell.err(), + "Learn more, including how to opt out, at:" + )); + drop(shell.writeln_err_styled(ThemeStyle::Note, TELEMETRY_URL)); + drop(writeln!(shell.err())); } // ── Config file I/O ────────────────────────────────────────────────────────── diff --git a/baml_language/crates/baml_cli/src/telemetry_command.rs b/baml_language/crates/baml_cli/src/telemetry_command.rs index dab3b9d1f6e..1a1d09d763d 100644 --- a/baml_language/crates/baml_cli/src/telemetry_command.rs +++ b/baml_language/crates/baml_cli/src/telemetry_command.rs @@ -4,15 +4,9 @@ //! palette (green for Enabled, red for Disabled, cyan for the docs URL), //! same output structure. -// `println!` here is the primary UX of the subcommand: printing the current -// status and confirmation of a state change. The workspace-wide ban on -// `print*!` exists to catch stray debug prints, not to break intentional -// user-facing output. -#![allow(clippy::print_stdout)] - use anyhow::Result; +use baml_shell::{Shell, ThemeStyle}; use clap::{Args, ValueEnum}; -use console::style; use crate::{ExitCode, telemetry}; @@ -62,6 +56,7 @@ pub(crate) enum TelemetryAction { impl TelemetryArgs { pub(crate) fn run(&self) -> Result { + let mut shell = Shell::new(); let t = telemetry::Telemetry::load(); // Snapshot the pre-change state so `disable` can distinguish // "already off" from "just turned off" (matches Next's UX). @@ -71,35 +66,37 @@ impl TelemetryArgs { TelemetryAction::Status => {} TelemetryAction::Enable => { let path = t.set_enabled(true); - println!("{}", style("Success!").cyan()); + shell.writeln_out_styled(ThemeStyle::Good, "Success!")?; if let Some(path) = &path { - println!("Your preference has been saved to {}.", path.display()); + writeln!( + shell.out(), + "Your preference has been saved to {}.", + path.display() + )?; } } TelemetryAction::Disable => { let path = t.set_enabled(false); if was_enabled { + shell.write_out_styled(ThemeStyle::Good, "Success!")?; if let Some(path) = &path { - println!( - "{} Your preference has been saved to {}.", - style("Success!").cyan(), + writeln!( + shell.out(), + " Your preference has been saved to {}.", path.display() - ); + )?; } else { - println!("{}", style("Success!").cyan()); + writeln!(shell.out())?; } } else { - println!( - "{}", - style("BAML CLI telemetry is already disabled.").yellow() - ); + shell.warn("BAML CLI telemetry is already disabled.")?; } } } // Re-load so the printed status reflects the post-change value. let t = telemetry::Telemetry::load(); - print_status(&t); + print_status(&mut shell, &t)?; Ok(ExitCode::Success) } } @@ -107,28 +104,35 @@ impl TelemetryArgs { /// Print the "Status: Enabled/Disabled" block plus a link to the docs page. /// Format matches Next.js's `nextTelemetry`: bold header, colored status, /// short body, cyan "Learn more" URL. -fn print_status(t: &telemetry::Telemetry) { +fn print_status(shell: &mut Shell, t: &telemetry::Telemetry) -> Result<()> { let is_enabled = t.is_enabled(); - println!(); - println!("{}", style("BAML CLI Telemetry").bold()); - let status_word = if is_enabled { - style("Enabled").green().bold().to_string() + writeln!(shell.out())?; + shell.writeln_out_styled(ThemeStyle::Heading, "BAML CLI Telemetry")?; + write!(shell.out(), "\nStatus: ")?; + if is_enabled { + shell.writeln_out_styled(ThemeStyle::Good, "Enabled")?; } else { - style("Disabled").red().bold().to_string() - }; - println!("\nStatus: {status_word}"); - println!("Config: {}", style(t.config_path().display()).dim()); + shell.writeln_out_styled(ThemeStyle::Bad, "Disabled")?; + } + write!(shell.out(), "Config: ")?; + shell.writeln_out_styled(ThemeStyle::Dim, t.config_path().display())?; if is_enabled { - println!("\nBAML telemetry is completely anonymous. Thank you for participating!"); + writeln!( + shell.out(), + "\nBAML telemetry is completely anonymous. Thank you for participating!" + )?; } else { - println!( + writeln!( + shell.out(), "\nYou have opted out of BAML's anonymous telemetry program.\n\ No data will be collected from your machine." - ); + )?; } - println!("\nLearn more: {}", style(telemetry::TELEMETRY_URL).cyan()); + write!(shell.out(), "\nLearn more: ")?; + shell.writeln_out_styled(ThemeStyle::Note, telemetry::TELEMETRY_URL)?; + Ok(()) } #[cfg(test)] diff --git a/baml_language/crates/baml_cli/src/test_command.rs b/baml_language/crates/baml_cli/src/test_command.rs index 26889538e6e..58880aa0c94 100644 --- a/baml_language/crates/baml_cli/src/test_command.rs +++ b/baml_language/crates/baml_cli/src/test_command.rs @@ -330,7 +330,10 @@ impl RunCtx<'_> { } } for failure in report.failures { - eprintln!("WARN test log capture failed: {}", failure.diagnostic); + crate::reporter::print_warning(format_args!( + "test log capture failed: {}", + failure.diagnostic + )); } // Redirected stdout is block-buffered. Flush every drained batch so @@ -518,7 +521,9 @@ impl TestArgs { let cancelled = report.cancelled; let error = report.into_engine_error(); if cancelled { - eprintln!("WARN cancelled spawned task failed: {error}"); + crate::reporter::print_warning(format_args!( + "cancelled spawned task failed: {error}" + )); } else { eprintln!("FAIL testing::unhandled_spawn_error"); eprintln!(" => {error}"); @@ -705,7 +710,7 @@ impl TestArgs { }; if command_failed { // `reporter.finish` styles success — print as an error so the - // bold-red `Error:` carries the visual weight of "tests failed". + // bold-red `error:` carries the visual weight of "tests failed". crate::reporter::print_error(format_args!("test failures — {summary}")); Ok(crate::ExitCode::TestFailure) } else { diff --git a/baml_language/crates/baml_exec/Cargo.toml b/baml_language/crates/baml_exec/Cargo.toml index 389e114e30c..e4344037d61 100644 --- a/baml_language/crates/baml_exec/Cargo.toml +++ b/baml_language/crates/baml_exec/Cargo.toml @@ -18,13 +18,13 @@ doctest = false workspace = true [dependencies] +baml_shell = { workspace = true, features = [ "clap" ] } baml_type = { workspace = true } bex_engine = { workspace = true } bex_vm_types = { workspace = true } anyhow = { workspace = true } borsh = { workspace = true } clap = { workspace = true } -console = { workspace = true } indexmap = { workspace = true } serde_json = { workspace = true } diff --git a/baml_language/crates/baml_exec/src/clap_target.rs b/baml_language/crates/baml_exec/src/clap_target.rs index e05251889b5..f7a482c6951 100644 --- a/baml_language/crates/baml_exec/src/clap_target.rs +++ b/baml_language/crates/baml_exec/src/clap_target.rs @@ -19,11 +19,9 @@ use std::collections::HashMap; use anyhow::Result; +use baml_shell::CLAP_STYLING; use bex_engine::{BexExternalValue, RuntimeTy, UserFunctionInfo}; -use clap::{ - Arg, ArgMatches, Command, - builder::{PossibleValuesParser, styling}, -}; +use clap::{Arg, ArgMatches, Command, builder::PossibleValuesParser}; use crate::{ auto_cli::{is_auto_cli_primitive, parse_cli_value}, @@ -42,38 +40,6 @@ fn leak(s: String) -> &'static str { Box::leak(s.into_boxed_str()) } -/// Clap styling shared by every baml-cli surface. Lives in `baml_exec` -/// rather than `baml_cli::reporter` so packed binaries — which can't -/// depend on `baml_cli` — get the same look as the dev CLI. -pub const CLAP_STYLING: styling::Styles = { - use clap::builder::styling::{AnsiColor, Color, Effects, RgbColor, Style, Styles}; - const PURPLE: Color = Color::Rgb(RgbColor(0xA8, 0x55, 0xF7)); - // Tonal pair — same hue family, pale (Tailwind purple-200). Used on - // `` so they read as quiet secondary text against the - // bold primary purple without washing out into background gray. - const PURPLE_LIGHT: Color = Color::Rgb(RgbColor(0xE9, 0xD5, 0xFF)); - Styles::styled() - .header(Style::new().fg_color(Some(PURPLE)).effects(Effects::BOLD)) - .usage(Style::new().fg_color(Some(PURPLE)).effects(Effects::BOLD)) - .literal(Style::new().effects(Effects::BOLD)) - .placeholder(Style::new().fg_color(Some(PURPLE_LIGHT))) - .error( - Style::new() - .fg_color(Some(Color::Ansi(AnsiColor::Red))) - .effects(Effects::BOLD), - ) - .valid( - Style::new() - .fg_color(Some(Color::Ansi(AnsiColor::Green))) - .effects(Effects::BOLD), - ) - .invalid( - Style::new() - .fg_color(Some(Color::Ansi(AnsiColor::Yellow))) - .effects(Effects::BOLD), - ) -}; - /// Result of feeding raw tokens through the target's clap command. #[derive(Debug, Default)] pub struct ParsedTargetArgs { diff --git a/baml_language/crates/baml_exec/src/diag_print.rs b/baml_language/crates/baml_exec/src/diag_print.rs index 8e28319c9f2..4b559ddfebb 100644 --- a/baml_language/crates/baml_exec/src/diag_print.rs +++ b/baml_language/crates/baml_exec/src/diag_print.rs @@ -1,51 +1,26 @@ -// Styled `Error:` / `Warning:` line printers shared by every CLI -// surface (`baml-cli` and the packed-binary host). Lives here — rather -// than in `baml_cli::reporter` — so the pack host, which can't depend -// on `baml_cli`, still gets the bold-red / bold-yellow prefixes that -// match ariadne's diagnostic header. `console::Style` strips ANSI -// automatically when stderr isn't a TTY, so piping through `grep` -// produces clean plain text. +use baml_shell::Shell; -#![allow(clippy::print_stderr)] - -use console::Style; - -/// Bold-red `error` keyword, matching ariadne's lowercase -/// custom-kind header. -fn error_prefix() -> impl std::fmt::Display { - Style::new().red().bold().apply_to("error") -} - -/// Bold-yellow `warning` keyword, matching ariadne's lowercase -/// custom-kind header. -fn warning_prefix() -> impl std::fmt::Display { - Style::new().yellow().bold().apply_to("warning") -} - -/// Print a single-line error with the ariadne-matching bold-red -/// `Error:` header. Routes to stderr. pub fn print_error(msg: impl std::fmt::Display) { - eprintln!("{}: {msg}", error_prefix()); + drop(Shell::new().error(msg)); } -/// Print a one-line warning with the ariadne-matching bold-yellow -/// `Warning:` header. Routes to stderr. pub fn print_warning(msg: impl std::fmt::Display) { - eprintln!("{}: {msg}", warning_prefix()); + drop(Shell::new().warn(msg)); +} + +pub fn print_note(msg: impl std::fmt::Display) { + drop(Shell::new().note(msg)); } -/// Print an [`anyhow::Error`] with the ariadne-matching header, walking -/// the cause chain so each tier is visible — matches anyhow's default -/// Debug format but with the bold-red header so it lines up visually -/// with the ariadne source-snippet blocks the compiler emits. pub fn print_anyhow_error(err: &anyhow::Error) { - eprintln!("{}: {err}", error_prefix()); + let mut shell = Shell::new(); + drop(shell.error(err)); let causes: Vec<_> = err.chain().skip(1).collect(); if !causes.is_empty() { - eprintln!(); - eprintln!("caused by:"); + drop(writeln!(shell.err())); + drop(writeln!(shell.err(), "caused by:")); for (i, cause) in causes.iter().enumerate() { - eprintln!(" {i}: {cause}"); + drop(writeln!(shell.err(), " {i}: {cause}")); } } } diff --git a/baml_language/crates/baml_exec/src/lib.rs b/baml_language/crates/baml_exec/src/lib.rs index 2d829a360af..df7c6b04caf 100644 --- a/baml_language/crates/baml_exec/src/lib.rs +++ b/baml_language/crates/baml_exec/src/lib.rs @@ -22,8 +22,9 @@ pub mod json_coerce; pub mod output; pub use auto_cli::{is_auto_cli_primitive, parse_cli_value}; -pub use clap_target::{CLAP_STYLING, ParsedTargetArgs, parse_multi_target_argv, parse_target_argv}; -pub use diag_print::{print_anyhow_error, print_error, print_warning}; +pub use baml_shell::{CLAP_STYLING, Shell}; +pub use clap_target::{ParsedTargetArgs, parse_multi_target_argv, parse_target_argv}; +pub use diag_print::{print_anyhow_error, print_error, print_note, print_warning}; /// Subset of `clap` re-exported so downstream binaries (pack-host) can /// classify [`parse_target_argv`] errors without taking a direct clap diff --git a/baml_language/crates/baml_pack_host/src/main.rs b/baml_language/crates/baml_pack_host/src/main.rs index 91fa58e4d5b..6286796a6ab 100644 --- a/baml_language/crates/baml_pack_host/src/main.rs +++ b/baml_language/crates/baml_pack_host/src/main.rs @@ -266,7 +266,7 @@ fn finalize_dispatch( for report in engine.take_unhandled_spawn_errors() { if report.cancelled { let error = report.into_engine_error(); - eprintln!("Warning: cancelled spawned task failed: {error}"); + baml_exec::print_warning(format_args!("cancelled spawned task failed: {error}")); } else { let error = report.into_engine_error(); print_error(format_args!("unhandled spawned task failed: {error}")); diff --git a/baml_language/crates/baml_shell/Cargo.toml b/baml_language/crates/baml_shell/Cargo.toml new file mode 100644 index 00000000000..a54512663a4 --- /dev/null +++ b/baml_language/crates/baml_shell/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "baml_shell" +version = { workspace = true } +publish = false +authors = { workspace = true } +edition = { workspace = true } +rust-version = { workspace = true } +homepage = { workspace = true } +documentation = { workspace = true } +repository = { workspace = true } +license = { workspace = true } +description = "Shared terminal output and help protocol for BAML CLIs" + +[lib] +doctest = false + +[lints] +workspace = true + +[features] +clap = [ "dep:clap" ] + +[dependencies] +anstream = { workspace = true } +anstyle = { workspace = true } +clap = { workspace = true, optional = true } +serde = { workspace = true, features = [ "derive" ] } diff --git a/baml_language/crates/baml_shell/src/lib.rs b/baml_language/crates/baml_shell/src/lib.rs new file mode 100644 index 00000000000..f1b6575a7de --- /dev/null +++ b/baml_language/crates/baml_shell/src/lib.rs @@ -0,0 +1,389 @@ +//! Terminal output and root-help protocol shared by BAML command-line surfaces. + +use std::{ + fmt, + io::{self, Write}, + sync::atomic::{AtomicU8, Ordering}, +}; + +use anstream::AutoStream; +use anstyle::{AnsiColor, Color, Effects, RgbColor, Style}; +use serde::{Deserialize, Serialize}; + +pub const ROOT_HELP_COMMAND_V1: &str = "__baml-root-help-v1"; +pub const ROOT_HELP_SCHEMA_V1: &str = "baml.root-help.v1"; + +const AUTO: u8 = 0; +const ALWAYS: u8 = 1; +const NEVER: u8 = 2; +const STATUS: Style = Style::new() + .fg_color(Some(Color::Rgb(RgbColor(0xA8, 0x55, 0xF7)))) + .effects(Effects::BOLD); +const ERROR: Style = AnsiColor::Red.on_default().effects(Effects::BOLD); +const WARNING: Style = AnsiColor::Yellow.on_default().effects(Effects::BOLD); +const NOTE: Style = AnsiColor::Cyan.on_default().effects(Effects::BOLD); +const GOOD: Style = AnsiColor::Green.on_default().effects(Effects::BOLD); +const DIM: Style = Style::new().effects(Effects::DIMMED); +const HEADING: Style = STATUS; +const LITERAL: Style = Style::new().effects(Effects::BOLD); +static STDOUT_COLOR: AtomicU8 = AtomicU8::new(AUTO); +static STDERR_COLOR: AtomicU8 = AtomicU8::new(AUTO); + +#[cfg(feature = "clap")] +pub const CLAP_STYLING: clap::builder::styling::Styles = { + use clap::builder::styling::{AnsiColor, Color, Effects, RgbColor, Style, Styles}; + const PURPLE: Color = Color::Rgb(RgbColor(0xA8, 0x55, 0xF7)); + const PURPLE_LIGHT: Color = Color::Rgb(RgbColor(0xE9, 0xD5, 0xFF)); + Styles::styled() + .header(Style::new().fg_color(Some(PURPLE)).effects(Effects::BOLD)) + .usage(Style::new().fg_color(Some(PURPLE)).effects(Effects::BOLD)) + .literal(Style::new().effects(Effects::BOLD)) + .placeholder(Style::new().fg_color(Some(PURPLE_LIGHT))) + .error( + Style::new() + .fg_color(Some(Color::Ansi(AnsiColor::Red))) + .effects(Effects::BOLD), + ) + .valid( + Style::new() + .fg_color(Some(Color::Ansi(AnsiColor::Green))) + .effects(Effects::BOLD), + ) + .invalid( + Style::new() + .fg_color(Some(Color::Ansi(AnsiColor::Yellow))) + .effects(Effects::BOLD), + ) +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ColorChoice { + #[default] + Auto, + Always, + Never, +} + +impl ColorChoice { + fn encode(self) -> u8 { + match self { + Self::Auto => AUTO, + Self::Always => ALWAYS, + Self::Never => NEVER, + } + } + + fn decode(value: u8) -> Self { + match value { + ALWAYS => Self::Always, + NEVER => Self::Never, + _ => Self::Auto, + } + } + + fn anstream(self) -> anstream::ColorChoice { + match self { + Self::Auto => anstream::ColorChoice::Auto, + Self::Always => anstream::ColorChoice::Always, + Self::Never => anstream::ColorChoice::Never, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ThemeStyle { + Heading, + Good, + Bad, + Warning, + Note, + Dim, +} + +impl ThemeStyle { + fn style(self) -> &'static Style { + match self { + Self::Heading => &HEADING, + Self::Good => &GOOD, + Self::Bad => &ERROR, + Self::Warning => &WARNING, + Self::Note => &NOTE, + Self::Dim => &DIM, + } + } +} + +pub fn set_default_color_choices(stdout: ColorChoice, stderr: ColorChoice) { + STDOUT_COLOR.store(stdout.encode(), Ordering::Relaxed); + STDERR_COLOR.store(stderr.encode(), Ordering::Relaxed); +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct RootHelpV1 { + pub schema_version: String, + pub name: String, + pub version: String, + pub about: String, + pub usage: String, + pub commands: Vec, + pub options: Vec, +} + +impl RootHelpV1 { + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != ROOT_HELP_SCHEMA_V1 { + return Err("unsupported root-help schema"); + } + if self.name.is_empty() || self.usage.is_empty() { + return Err("incomplete root-help metadata"); + } + Ok(()) + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct HelpRow { + pub syntax: String, + pub summary: String, +} + +pub struct Shell { + output: ShellOut, +} + +enum ShellOut { + Streams { + stdout: AutoStream, + stderr: AutoStream, + }, + Write(AutoStream>), +} + +impl Shell { + pub fn new() -> Self { + Self::with_color_choices( + ColorChoice::decode(STDOUT_COLOR.load(Ordering::Relaxed)), + ColorChoice::decode(STDERR_COLOR.load(Ordering::Relaxed)), + ) + } + + pub fn with_color_choices(stdout: ColorChoice, stderr: ColorChoice) -> Self { + Self { + output: ShellOut::Streams { + stdout: AutoStream::new(io::stdout(), stdout.anstream()), + stderr: AutoStream::new(io::stderr(), stderr.anstream()), + }, + } + } + + pub fn from_write(output: Box) -> Self { + Self { + output: ShellOut::Write(AutoStream::never(output)), + } + } + + pub fn out(&mut self) -> &mut dyn Write { + match &mut self.output { + ShellOut::Streams { stdout, .. } => stdout, + ShellOut::Write(output) => output, + } + } + + pub fn err(&mut self) -> &mut dyn Write { + match &mut self.output { + ShellOut::Streams { stderr, .. } => stderr, + ShellOut::Write(output) => output, + } + } + + pub fn status( + &mut self, + status: impl fmt::Display, + message: impl fmt::Display, + ) -> io::Result<()> { + self.message(&status, Some(&message), &STATUS, true) + } + + pub fn error(&mut self, message: impl fmt::Display) -> io::Result<()> { + self.message(&"error", Some(&message), &ERROR, false) + } + + pub fn warn(&mut self, message: impl fmt::Display) -> io::Result<()> { + self.message(&"warning", Some(&message), &WARNING, false) + } + + pub fn note(&mut self, message: impl fmt::Display) -> io::Result<()> { + self.message(&"note", Some(&message), &NOTE, false) + } + + pub fn write_out_styled( + &mut self, + theme: ThemeStyle, + message: impl fmt::Display, + ) -> io::Result<()> { + let style = theme.style(); + write!(self.out(), "{style}{message}{style:#}") + } + + pub fn writeln_out_styled( + &mut self, + theme: ThemeStyle, + message: impl fmt::Display, + ) -> io::Result<()> { + self.write_out_styled(theme, message)?; + writeln!(self.out()) + } + + pub fn writeln_err_styled( + &mut self, + theme: ThemeStyle, + message: impl fmt::Display, + ) -> io::Result<()> { + let style = theme.style(); + writeln!(self.err(), "{style}{message}{style:#}") + } + + pub fn root_help(&mut self, help: &RootHelpV1) -> io::Result<()> { + writeln!(self.out(), "{}\n", help.about)?; + self.help_heading("Usage:")?; + writeln!(self.out(), " {}\n", help.usage)?; + if !help.commands.is_empty() { + self.help_heading("Commands:")?; + self.help_rows(&help.commands)?; + writeln!(self.out())?; + } + if !help.options.is_empty() { + self.help_heading("Options:")?; + self.help_rows(&help.options)?; + } + Ok(()) + } + + fn message( + &mut self, + status: &dyn fmt::Display, + message: Option<&dyn fmt::Display>, + style: &Style, + justified: bool, + ) -> io::Result<()> { + let mut buffer = Vec::new(); + if justified { + write!(&mut buffer, "{style}{status:>12}{style:#}")?; + } else { + write!(&mut buffer, "{style}{status}{style:#}:")?; + } + match message { + Some(message) => writeln!(&mut buffer, " {message}"), + None => writeln!(&mut buffer), + }?; + self.err().write_all(&buffer) + } + + fn help_heading(&mut self, heading: &str) -> io::Result<()> { + writeln!(self.out(), "{HEADING}{heading}{HEADING:#}") + } + + fn help_rows(&mut self, rows: &[HelpRow]) -> io::Result<()> { + let width = rows.iter().map(|row| row.syntax.len()).max().unwrap_or(0); + for row in rows { + let mut lines = row.summary.lines(); + let summary = lines.next().unwrap_or_default(); + writeln!( + self.out(), + " {LITERAL}{}{LITERAL:#}{} {}", + row.syntax, + " ".repeat(width.saturating_sub(row.syntax.len())), + summary + )?; + for line in lines { + writeln!(self.out(), "{}{}", " ".repeat(width + 4), line)?; + } + } + Ok(()) + } +} + +impl Default for Shell { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::*; + + #[derive(Clone)] + struct Buffer(Arc>>); + + impl Write for Buffer { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.0.lock().unwrap().write(bytes) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + #[test] + fn diagnostics_and_status_share_one_format() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let mut shell = Shell::from_write(Box::new(Buffer(bytes.clone()))); + shell.status("Checking", "project").unwrap(); + shell.warn("old toolchain").unwrap(); + shell.error("failed").unwrap(); + assert_eq!( + String::from_utf8(bytes.lock().unwrap().clone()).unwrap(), + " Checking project\nwarning: old toolchain\nerror: failed\n" + ); + } + + #[test] + fn root_help_aligns_rows() { + let bytes = Arc::new(Mutex::new(Vec::new())); + let mut shell = Shell::from_write(Box::new(Buffer(bytes.clone()))); + shell + .root_help(&RootHelpV1 { + schema_version: ROOT_HELP_SCHEMA_V1.to_string(), + name: "baml".to_string(), + version: "1.0.0".to_string(), + about: "BAML".to_string(), + usage: "baml ".to_string(), + commands: vec![ + HelpRow { + syntax: "run".to_string(), + summary: "Run a function".to_string(), + }, + HelpRow { + syntax: "toolchain".to_string(), + summary: "Manage toolchains".to_string(), + }, + ], + options: vec![], + }) + .unwrap(); + let actual = String::from_utf8(bytes.lock().unwrap().clone()).unwrap(); + assert!(actual.contains(" run Run a function\n"), "{actual}"); + assert!( + actual.contains(" toolchain Manage toolchains\n"), + "{actual}" + ); + } + + #[test] + fn root_help_rejects_an_unknown_schema() { + let help = RootHelpV1 { + schema_version: "baml.root-help.v2".to_string(), + name: "baml".to_string(), + version: "2.0.0".to_string(), + about: "BAML".to_string(), + usage: "baml ".to_string(), + commands: vec![], + options: vec![], + }; + assert_eq!(help.validate(), Err("unsupported root-help schema")); + } +} diff --git a/baml_language/stow.toml b/baml_language/stow.toml index 192227e7486..2f248925f8a 100644 --- a/baml_language/stow.toml +++ b/baml_language/stow.toml @@ -85,7 +85,7 @@ reason = "Use thiserror for proper error types in library crates." # baml namespace configuration [[namespaces]] name = "baml" -approved_prefixes = ["compiler", "builtins", "builtins2", "compiler2", "lsp2", "type"] +approved_prefixes = ["compiler", "builtins", "builtins2", "compiler2", "lsp2", "shell", "type"] test_crate_exceptions = ["baml_tests"] embed_crates = ["bex_project"] # The Rust SDK runtime crate keeps the `bridge_rust` folder (mirroring