diff --git a/Cargo.lock b/Cargo.lock index cefa77c6..8cb11884 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -427,7 +427,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.23.0", + "darling 0.21.3", "ident_case", "prettyplease", "proc-macro2", @@ -464,6 +464,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "braintrust-sdk-rust" +version = "0.1.0-alpha.2" +source = "git+https://github.com/braintrustdata/braintrust-sdk-rust?rev=d33e806bf6ab9548d37355f6a5098a971ef150aa#d33e806bf6ab9548d37355f6a5098a971ef150aa" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "backoff", + "base64 0.22.1", + "bon", + "bytes", + "chrono", + "crossbeam", + "futures", + "indexmap 2.13.0", + "regex", + "reqwest", + "serde", + "serde_json 1.0.149", + "serde_repr", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "brotli" version = "8.0.2" @@ -505,7 +533,8 @@ dependencies = [ "assert_cmd", "backoff", "base64 0.22.1", - "braintrust-sdk-rust", + "braintrust-sdk-rust 0.1.0-alpha.2 (git+https://github.com/braintrustdata/braintrust-sdk-rust?rev=43ba73edbf5220b57090e049feb094b60a92fcd4)", + "bt-daemon", "chrono", "clap", "comfy-table", @@ -536,12 +565,33 @@ dependencies = [ "tempfile", "tokio", "toml", + "tracing-subscriber", "unicode-width 0.1.14", "urlencoding", "uuid", "windows-sys 0.59.0", ] +[[package]] +name = "bt-daemon" +version = "0.1.0" +source = "git+https://github.com/braintrustdata/braintrust-coding-agent-plugins?rev=bebff4e0349fd9d2ee4a8ce1eeeafafee91ac74b#bebff4e0349fd9d2ee4a8ce1eeeafafee91ac74b" +dependencies = [ + "anyhow", + "async-trait", + "braintrust-sdk-rust 0.1.0-alpha.2 (git+https://github.com/braintrustdata/braintrust-sdk-rust?rev=d33e806bf6ab9548d37355f6a5098a971ef150aa)", + "chrono", + "clap", + "regex", + "serde", + "serde_json 1.0.149", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -1101,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1481,7 +1531,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1789,6 +1839,12 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1912,6 +1968,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -1968,6 +2033,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2293,7 +2367,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2330,9 +2404,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.5.10", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -2600,7 +2674,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2894,6 +2968,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -2905,6 +2985,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -3082,7 +3171,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3377,6 +3466,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3512,9 +3631,16 @@ dependencies = [ "getrandom 0.4.1", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -3729,7 +3855,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a7da2549..488195bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ actix-web = "4.11.0" anyhow = "1.0.89" backoff = { version = "0.4.0", features = ["tokio"] } braintrust-sdk-rust = { git = "https://github.com/braintrustdata/braintrust-sdk-rust", rev = "43ba73edbf5220b57090e049feb094b60a92fcd4" } +bt-daemon = { git = "https://github.com/braintrustdata/braintrust-coding-agent-plugins", rev = "bebff4e0349fd9d2ee4a8ce1eeeafafee91ac74b" } clap = { version = "4.5.20", features = ["derive", "env"] } crossterm = "0.28.1" futures-util = "0.3.31" @@ -32,6 +33,7 @@ toml = "0.8" sha2 = "0.10.8" strip-ansi-escapes = "0.2.0" tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "process", "net", "signal", "sync"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-width = "0.1.13" dialoguer = { version = "0.11", features = ["fuzzy-select"] } fuzzy-matcher = "0.3" diff --git a/src/agents.rs b/src/agents.rs new file mode 100644 index 00000000..9482f6ca --- /dev/null +++ b/src/agents.rs @@ -0,0 +1,350 @@ +//! `bt trace` — manages coding-agent tracing integrations. +//! +//! The daemon library is credential-passive: it receives a resolved +//! `BackendAuth` with each session's config. Here `bt` fills that from its own +//! `resolve_auth` (profiles / OAuth refresh / keychain), so a `bt trace hook` +//! invocation traces to whatever profile the user is on. See +//! `../plugin-monorepo/bt-daemon/DESIGN.md` ("Dual consumption", auth handoff). + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; + +use anyhow::{bail, Context}; +use clap::{Args, Subcommand}; +use serde_json::{Map, Value}; + +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use bt_daemon::{ + braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, shutdown_daemon, + BraintrustSinkConfig, HookArgs, HostInfo, ImportArgs, Registry, ServeArgs, ServeOptions, + StatusArgs, +}; + +use crate::args::BaseArgs; + +#[derive(Debug, Clone, Args)] +pub struct TraceArgs { + #[command(subcommand)] + command: TraceCommand, +} + +#[derive(Debug, Clone, Subcommand)] +enum TraceCommand { + /// Install the published Braintrust tracing plugin for a coding agent. + Setup(SetupArgs), + /// Run the tracing daemon (foreground). + #[command(hide = true)] + Daemon(ServeArgs), + /// Forward one coding-agent hook event (read from stdin) to the daemon. + #[command(hide = true)] + Hook(HookArgs), + /// Print daemon/session status. + #[command(hide = true)] + Status(StatusArgs), + /// Gracefully stop the tracing daemon. + #[command(hide = true)] + Stop(StopArgs), + /// Import a past Codex or Claude Code session by its resume id. + Import(ImportArgs), +} + +#[derive(Debug, Clone, Args)] +struct StopArgs { + /// Socket path override (default: see the daemon protocol documentation). + #[arg(long)] + socket: Option, +} + +#[derive(Debug, Clone, Args)] +struct SetupArgs { + #[command(subcommand)] + agent: SetupAgent, +} + +#[derive(Debug, Clone, Copy, Subcommand)] +enum SetupAgent { + /// Install the published Codex tracing plugin. + Codex, + /// Install the published Claude Code tracing plugin. + Claude, +} + +const CODEX_MARKETPLACE: &str = "braintrust-codex-plugins"; +const CODEX_MARKETPLACE_SOURCE: &str = "braintrustdata/braintrust-codex-plugin"; +const CODEX_PLUGIN: &str = "trace-codex@braintrust-codex-plugins"; +const CLAUDE_MARKETPLACE: &str = "braintrust-claude-plugin"; +const CLAUDE_MARKETPLACE_SOURCE: &str = "braintrustdata/braintrust-claude-plugin"; +const CLAUDE_PLUGIN: &str = "trace-claude-code@braintrust-claude-plugin"; + +/// How the shim (re)launches the daemon: `bt trace daemon` from this same +/// binary. +fn host_info() -> HostInfo { + let exe = std::env::current_exe() + .map(OsString::from) + .unwrap_or_else(|_| OsString::from("bt")); + HostInfo { + serve_argv: vec![exe, OsString::from("trace"), OsString::from("daemon")], + version: crate::CLI_VERSION.to_string(), + } +} + +/// Production serve options: real agent translators + the Braintrust sink. +/// Per-session backend URLs arrive with each event's config (bt resolves them +/// per profile), so no daemon-level defaults are set here. +fn serve_options() -> ServeOptions { + let cfg = BraintrustSinkConfig { + api_url: None, + app_url: None, + version: crate::CLI_VERSION.to_string(), + }; + braintrust_serve_options( + crate::CLI_VERSION, + cfg, + Arc::new(Registry::default_agents()), + ) +} + +fn init_daemon_logging(verbose: bool) { + let fallback = if verbose { "debug" } else { "info" }; + let filter = tracing_subscriber::EnvFilter::new(fallback); + if let Err(error) = tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init() + { + eprintln!("bt trace daemon logging unavailable: {error}"); + } +} + +fn command_json(program: &str, args: &[&str]) -> anyhow::Result { + let output = Command::new(program).args(args).output().with_context(|| { + format!("failed to run `{program}`; install {program} and ensure it is on PATH") + })?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("`{program} {}` failed: {}", args.join(" "), stderr.trim()); + } + serde_json::from_slice(&output.stdout) + .with_context(|| format!("`{program} {}` returned invalid JSON", args.join(" "))) +} + +fn run_command(program: &str, args: &[&str]) -> anyhow::Result<()> { + let status = Command::new(program).args(args).status().with_context(|| { + format!("failed to run `{program}`; install {program} and ensure it is on PATH") + })?; + if !status.success() { + bail!("`{program} {}` failed with {status}", args.join(" ")); + } + Ok(()) +} + +fn codex_marketplace_installed(value: &Value) -> bool { + value + .get("marketplaces") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("name").and_then(Value::as_str) == Some(CODEX_MARKETPLACE)) + }) +} + +fn codex_plugin_installed(value: &Value) -> bool { + value + .get("installed") + .and_then(Value::as_array) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("pluginId").and_then(Value::as_str) == Some(CODEX_PLUGIN)) + }) +} + +fn claude_marketplace_installed(value: &Value) -> bool { + value.as_array().is_some_and(|items| { + items + .iter() + .any(|item| item.get("name").and_then(Value::as_str) == Some(CLAUDE_MARKETPLACE)) + }) +} + +fn claude_plugin(value: &Value) -> Option<&Value> { + value + .as_array()? + .iter() + .find(|item| item.get("id").and_then(Value::as_str) == Some(CLAUDE_PLUGIN)) +} + +fn setup_codex() -> anyhow::Result<()> { + let marketplaces = command_json("codex", &["plugin", "marketplace", "list", "--json"])?; + if !codex_marketplace_installed(&marketplaces) { + run_command( + "codex", + &["plugin", "marketplace", "add", CODEX_MARKETPLACE_SOURCE], + )?; + } + + let plugins = command_json("codex", &["plugin", "list", "--json"])?; + if !codex_plugin_installed(&plugins) { + run_command("codex", &["plugin", "add", CODEX_PLUGIN])?; + } + Ok(()) +} + +fn setup_claude() -> anyhow::Result<()> { + let marketplaces = command_json("claude", &["plugin", "marketplace", "list", "--json"])?; + if !claude_marketplace_installed(&marketplaces) { + run_command( + "claude", + &["plugin", "marketplace", "add", CLAUDE_MARKETPLACE_SOURCE], + )?; + } + + let plugins = command_json("claude", &["plugin", "list", "--json"])?; + match claude_plugin(&plugins) { + None => run_command("claude", &["plugin", "install", CLAUDE_PLUGIN])?, + Some(plugin) if plugin.get("enabled").and_then(Value::as_bool) == Some(false) => { + run_command("claude", &["plugin", "enable", CLAUDE_PLUGIN])?; + } + Some(_) => {} + } + Ok(()) +} + +fn load_settings(path: &Path) -> anyhow::Result> { + match std::fs::read(path) { + Ok(raw) => { + let value: Value = serde_json::from_slice(&raw) + .with_context(|| format!("invalid shared agent settings: {}", path.display()))?; + value.as_object().cloned().ok_or_else(|| { + anyhow::anyhow!( + "shared agent settings must be a JSON object: {}", + path.display() + ) + }) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Map::new()), + Err(error) => Err(error) + .with_context(|| format!("failed to read shared agent settings: {}", path.display())), + } +} + +fn enable_tracing(project: Option<&str>) -> anyhow::Result { + let path = paths::settings_path(None); + let mut settings = load_settings(&path)?; + settings.insert("traceToBraintrust".into(), Value::Bool(true)); + + let existing_project = settings + .get("project") + .and_then(Value::as_str) + .filter(|project| !project.is_empty()); + let project = project + .filter(|project| !project.is_empty()) + .or(existing_project) + .unwrap_or("coding-agents"); + settings.insert("project".into(), Value::String(project.to_string())); + + let mut encoded = serde_json::to_string_pretty(&Value::Object(settings))?; + encoded.push('\n'); + crate::utils::write_text_atomic(&path, &encoded)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to protect shared settings: {}", path.display()))?; + } + Ok(path) +} + +fn run_setup(base: &BaseArgs, args: SetupArgs) -> anyhow::Result<()> { + match args.agent { + SetupAgent::Codex => setup_codex()?, + SetupAgent::Claude => setup_claude()?, + } + let settings_path = enable_tracing(base.project.as_deref())?; + println!( + "The Braintrust tracing plugin is installed for {} and configured in {}.", + match args.agent { + SetupAgent::Codex => "Codex", + SetupAgent::Claude => "Claude Code", + }, + settings_path.display() + ); + println!("Restart the coding agent to load the tracing plugin."); + Ok(()) +} + +/// Resolve `bt`'s auth into the daemon's per-session config. +async fn session_config(base: &BaseArgs) -> anyhow::Result { + let auth = crate::auth::resolve_auth(base) + .await + .map_err(|e| anyhow::anyhow!("resolve auth: {e}"))?; + Ok(SessionConfig { + auth: BackendAuth { + token: auth.api_key.unwrap_or_default(), + api_url: auth.api_url, + app_url: auth.app_url, + org_name: auth.org_name, + org_id: None, + }, + project: base.project.clone(), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + }) +} + +pub async fn run(base: BaseArgs, args: TraceArgs) -> anyhow::Result<()> { + match args.command { + TraceCommand::Setup(setup_args) => run_setup(&base, setup_args), + TraceCommand::Daemon(serve_args) => { + init_daemon_logging(base.verbose); + run_serve(serve_args, serve_options()).await + } + TraceCommand::Hook(hook_args) => { + // A hook must NEVER fail the agent's turn. Resolve auth and forward; + // log and swallow any error, exit 0. + match session_config(&base).await { + Ok(config) => { + if let Err(e) = run_hook(hook_args, config, host_info()).await { + eprintln!("bt trace hook (non-fatal): {e}"); + } + } + Err(e) => eprintln!("bt trace hook (non-fatal): {e}"), + } + Ok(()) + } + TraceCommand::Status(status_args) => match run_status(status_args).await? { + Some(status) => { + println!("{}", serde_json::to_string_pretty(&status)?); + Ok(()) + } + None => { + println!("bt-daemon is not running"); + Ok(()) + } + }, + TraceCommand::Stop(stop_args) => { + let socket = paths::socket_path(stop_args.socket.as_deref()); + let status_args = StatusArgs { + socket: Some(socket.clone()), + session_id: None, + }; + if run_status(status_args).await?.is_none() { + println!("No tracing daemon is running."); + return Ok(()); + } + shutdown_daemon(&socket).await?; + println!("Tracing daemon stopped."); + Ok(()) + } + TraceCommand::Import(import_args) => { + let config = session_config(&base).await?; + run_import(import_args, serve_options(), Some(config)).await + } + } +} diff --git a/src/main.rs b/src/main.rs index 5f12e60f..163d33f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use anyhow::{Context, Result}; use clap::{parser::ValueSource, ArgMatches, CommandFactory, FromArgMatches, Parser, Subcommand}; use std::ffi::{OsStr, OsString}; +mod agents; mod args; mod auth; #[allow(dead_code)] @@ -79,6 +80,7 @@ Data & evaluation Additional docs Manage workflow docs for coding agents + trace Manage coding-agent tracing setup Configure Braintrust setup flows status Show current org and project context update Update bt in-place @@ -165,6 +167,8 @@ enum Commands { Switch(CLIArgs), /// Show current org and project context Status(CLIArgs), + /// Manage coding-agent tracing + Trace(CLIArgs), // /// View and modify config // Config(CLIArgs), } @@ -194,6 +198,7 @@ impl Commands { Commands::Util(cmd) => &cmd.base, Commands::Switch(cmd) => &cmd.base, Commands::Status(cmd) => &cmd.base, + Commands::Trace(cmd) => &cmd.base, } } @@ -221,6 +226,7 @@ impl Commands { Commands::Util(cmd) => &mut cmd.base, Commands::Switch(cmd) => &mut cmd.base, Commands::Status(cmd) => &mut cmd.base, + Commands::Trace(cmd) => &mut cmd.base, } } @@ -337,6 +343,7 @@ fn try_main() -> Result<()> { Commands::SelfCommand(cmd) => self_update::run(cmd.base, cmd.args).await?, Commands::Switch(cmd) => switch::run(cmd.base, cmd.args).await?, Commands::Status(cmd) => status::run(cmd.base, cmd.args).await?, + Commands::Trace(cmd) => agents::run(cmd.base, cmd.args).await?, } Ok(()) }); diff --git a/tests/cli.rs b/tests/cli.rs index acb09bfd..aa13a8d0 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -29,6 +29,28 @@ fn write_executable(path: &Path) { } } +#[cfg(unix)] +fn write_agent_cli(path: &Path, marketplace_json: &str, plugin_json: &str) { + let script = format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> "$AGENT_SETUP_LOG" +case "$*" in + "plugin marketplace list --json") + printf '%s\n' '{marketplace_json}' + ;; + "plugin list --json") + printf '%s\n' '{plugin_json}' + ;; +esac +"# + ); + fs::write(path, script).expect("write fake agent CLI"); + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod"); +} + fn make_git_repo() -> tempfile::TempDir { let dir = tempfile::tempdir().expect("tempdir"); fs::write(dir.path().join(".git"), "gitdir: /tmp/fake").expect("write .git"); @@ -109,6 +131,256 @@ fn top_level_help_shows_update_not_self() { .stdout(predicate::str::contains("self Self-management commands").not()); } +#[test] +fn trace_help_exposes_user_commands_and_hides_internal_commands() { + bt_command().args(["daemon", "--help"]).assert().failure(); + bt_command().args(["agents", "--help"]).assert().failure(); + + bt_command() + .args(["trace", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("setup")) + .stdout(predicate::str::contains("\n import")) + .stdout(predicate::str::contains("\n daemon").not()) + .stdout(predicate::str::contains("serve").not()) + .stdout(predicate::str::contains("\n hook").not()) + .stdout(predicate::str::contains("\n status").not()) + .stdout(predicate::str::contains("\n stop").not()) + .stdout(predicate::str::contains("\n replay").not()); + + bt_command() + .args(["trace", "daemon", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Run the tracing daemon")) + .stdout(predicate::str::contains("--socket")) + .stdout(predicate::str::contains("--idle-timeout-secs")); + + bt_command() + .args(["trace", "hook", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--source")) + .stdout(predicate::str::contains("--flush-on-turn-end")) + .stdout(predicate::str::contains("--experiment-id")); + + bt_command() + .args(["trace", "status", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--socket")); + + bt_command() + .args(["trace", "stop", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--socket")); + + bt_command() + .args(["trace", "import", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("codex")) + .stdout(predicate::str::contains("claude")); + + bt_command() + .args(["trace", "replay", "--help"]) + .assert() + .failure(); + + bt_command() + .args(["trace", "setup", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("codex")) + .stdout(predicate::str::contains("claude")); +} + +#[cfg(unix)] +#[test] +fn trace_stop_gracefully_stops_an_isolated_daemon() { + use std::process::Stdio; + use std::thread; + use std::time::Duration; + + let state = tempfile::tempdir().expect("state tempdir"); + let socket = state.path().join("daemon.sock"); + let bin = env!("CARGO_BIN_EXE_bt"); + let mut daemon = std::process::Command::new(bin) + .args([ + "trace", + "daemon", + "--socket", + socket.to_str().expect("UTF-8 socket path"), + "--data-dir", + state.path().to_str().expect("UTF-8 state path"), + "--idle-timeout-secs", + "0", + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn tracing daemon"); + + for _ in 0..100 { + if socket.exists() { + break; + } + thread::sleep(Duration::from_millis(25)); + } + if !socket.exists() { + let _ = daemon.kill(); + panic!("tracing daemon did not create its socket"); + } + + bt_command() + .args([ + "trace", + "stop", + "--socket", + socket.to_str().expect("UTF-8 socket path"), + ]) + .assert() + .success() + .stdout(predicate::str::contains("Tracing daemon stopped.")); + + for _ in 0..100 { + if let Some(status) = daemon.try_wait().expect("poll tracing daemon") { + assert!(status.success(), "tracing daemon exited unsuccessfully"); + + bt_command() + .args([ + "trace", + "stop", + "--socket", + socket.to_str().expect("UTF-8 socket path"), + ]) + .assert() + .success() + .stdout(predicate::str::contains("No tracing daemon is running.")); + return; + } + thread::sleep(Duration::from_millis(25)); + } + + let _ = daemon.kill(); + panic!("tracing daemon did not stop"); +} + +#[cfg(unix)] +#[test] +fn trace_setup_codex_installs_plugin_and_preserves_existing_settings() { + let home = tempfile::tempdir().expect("home tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + let state_dir = tempfile::tempdir().expect("state tempdir"); + let log = state_dir.path().join("codex.log"); + let config = state_dir.path().join("config.json"); + write_agent_cli( + &bin_dir.path().join("codex"), + r#"{"marketplaces":[]}"#, + r#"{"installed":[]}"#, + ); + fs::write( + &config, + r#"{ + "flushOnTurnEnd": true, + "additionalMetadata": {"team": "sdk"}, + "apiKey": "legacy-secret", + "apiUrl": "https://legacy.example", + "auth": {"type": "legacy"} + }"#, + ) + .expect("seed config"); + + bt_command() + .env("HOME", home.path()) + .env("PATH", bin_dir.path()) + .env("AGENT_SETUP_LOG", &log) + .env("BT_DAEMON_CONFIG", &config) + .args(["trace", "setup", "codex", "--project", "agent-traces"]) + .assert() + .success() + .stdout(predicate::str::contains( + "The Braintrust tracing plugin is installed for Codex", + )); + + let calls = fs::read_to_string(log).expect("read fake CLI calls"); + assert!(calls.contains("plugin marketplace add braintrustdata/braintrust-codex-plugin")); + assert!(calls.contains("plugin add trace-codex@braintrust-codex-plugins")); + + let settings: serde_json::Value = + serde_json::from_slice(&fs::read(config).expect("read config")).expect("parse config"); + assert_eq!(settings["traceToBraintrust"], true); + assert_eq!(settings["project"], "agent-traces"); + assert_eq!(settings["flushOnTurnEnd"], true); + assert_eq!(settings["additionalMetadata"]["team"], "sdk"); + assert_eq!(settings["apiKey"], "legacy-secret"); + assert_eq!(settings["apiUrl"], "https://legacy.example"); + assert_eq!(settings["auth"]["type"], "legacy"); +} + +#[cfg(unix)] +#[test] +fn trace_setup_claude_installs_plugin_and_creates_default_settings() { + let home = tempfile::tempdir().expect("home tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + let state_dir = tempfile::tempdir().expect("state tempdir"); + let log = state_dir.path().join("claude.log"); + let config = state_dir.path().join("config.json"); + write_agent_cli(&bin_dir.path().join("claude"), "[]", "[]"); + + bt_command() + .env("HOME", home.path()) + .env("PATH", bin_dir.path()) + .env("AGENT_SETUP_LOG", &log) + .env("BT_DAEMON_CONFIG", &config) + .args(["trace", "setup", "claude"]) + .assert() + .success() + .stdout(predicate::str::contains( + "The Braintrust tracing plugin is installed for Claude Code", + )); + + let calls = fs::read_to_string(log).expect("read fake CLI calls"); + assert!(calls.contains("plugin marketplace add braintrustdata/braintrust-claude-plugin")); + assert!(calls.contains("plugin install trace-claude-code@braintrust-claude-plugin")); + + let settings: serde_json::Value = + serde_json::from_slice(&fs::read(config).expect("read config")).expect("parse config"); + assert_eq!(settings["traceToBraintrust"], true); + assert_eq!(settings["project"], "coding-agents"); +} + +#[cfg(unix)] +#[test] +fn trace_setup_claude_enables_an_existing_disabled_plugin() { + let home = tempfile::tempdir().expect("home tempdir"); + let bin_dir = tempfile::tempdir().expect("bin tempdir"); + let state_dir = tempfile::tempdir().expect("state tempdir"); + let log = state_dir.path().join("claude.log"); + write_agent_cli( + &bin_dir.path().join("claude"), + r#"[{"name":"braintrust-claude-plugin"}]"#, + r#"[{"id":"trace-claude-code@braintrust-claude-plugin","enabled":false}]"#, + ); + + bt_command() + .env("HOME", home.path()) + .env("PATH", bin_dir.path()) + .env("AGENT_SETUP_LOG", &log) + .args(["trace", "setup", "claude"]) + .assert() + .success(); + + let calls = fs::read_to_string(log).expect("read fake CLI calls"); + assert!(calls.contains("plugin enable trace-claude-code@braintrust-claude-plugin")); + assert!(!calls.contains("plugin marketplace add")); + assert!(!calls.contains("plugin install")); +} + #[test] fn topics_report_help_accepts_global_org_short_conflict_free() { bt_command()