From bf03da0a31e63772670edc5886a4da2ddf69e56d Mon Sep 17 00:00:00 2001 From: Stephen Belanger Date: Thu, 13 Aug 2026 03:51:21 +0800 Subject: [PATCH] Delegate trace commands to plugin runtime --- Cargo.lock | 10 +- Cargo.toml | 2 +- src/agents.rs | 539 ---------------------------------------------- src/main.rs | 8 +- src/trace_host.rs | 165 ++++++++++++++ tests/cli.rs | 27 --- 6 files changed, 177 insertions(+), 574 deletions(-) delete mode 100644 src/agents.rs create mode 100644 src/trace_host.rs diff --git a/Cargo.lock b/Cargo.lock index 8a8440d4..08746940 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -594,7 +594,7 @@ dependencies = [ [[package]] name = "bt-daemon" version = "0.1.0" -source = "git+https://github.com/braintrustdata/braintrust-coding-agent-plugins?rev=30e4b53f373940ea26c7ca943bd56c51fa431e13#30e4b53f373940ea26c7ca943bd56c51fa431e13" +source = "git+https://github.com/braintrustdata/braintrust-coding-agent-plugins?rev=5fbfb5e2f322522a2b50cf0e089081b42a9b1559#5fbfb5e2f322522a2b50cf0e089081b42a9b1559" dependencies = [ "anyhow", "async-trait", @@ -605,9 +605,11 @@ dependencies = [ "serde", "serde_json 1.0.151", "sha2", + "tempfile", "thiserror 2.0.19", "tokio", "tracing", + "tracing-subscriber", "uuid", ] @@ -1180,7 +1182,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]] @@ -2678,7 +2680,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2691,7 +2693,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c7995149..e7b564d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +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 = "30e4b53f373940ea26c7ca943bd56c51fa431e13" } +bt-daemon = { git = "https://github.com/braintrustdata/braintrust-coding-agent-plugins", rev = "5fbfb5e2f322522a2b50cf0e089081b42a9b1559" } async-trait = "0.1" clap = { version = "4.5.20", features = ["derive", "env"] } crossterm = "0.28.1" diff --git a/src/agents.rs b/src/agents.rs deleted file mode 100644 index fcd2b599..00000000 --- a/src/agents.rs +++ /dev/null @@ -1,539 +0,0 @@ -//! `bt trace` — manages coding-agent tracing integrations. -//! -//! Hooks forward only profile, organization, and destination selection. The -//! long-lived daemon host resolves those selections through `bt`'s auth store, -//! including OAuth refresh and keychain access. - -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::sync::Arc; - -use anyhow::{bail, Context}; -use async_trait::async_trait; -use clap::{Args, Subcommand}; -use serde_json::{Map, Value}; - -use bt_daemon::wire::{ - AuthSelection, BackendAuth, FlushMode, SessionConfig, SessionRoute, TraceDestination, -}; -use bt_daemon::{ - braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, run_traced, - shutdown_daemon, AuthLease, AuthProvider, AuthResolveReason, BraintrustSinkConfig, HookArgs, - HostInfo, ImportArgs, OutputFormat, Registry, RunArgs, RunHookCommand, ServeArgs, ServeOptions, - StatusArgs, TraceCommandOutput, -}; - -use crate::args::BaseArgs; - -#[derive(Debug, Clone, Args)] -pub struct TraceArgs { - #[command(subcommand)] - command: TraceCommand, -} - -#[derive(Debug, Clone, Subcommand)] -// Clap argument structs are parsed once; keeping their natural shapes is -// clearer than boxing individual command variants for stack-size savings. -#[allow(clippy::large_enum_variant)] -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), - /// Launch a coding agent with tracing enabled for this invocation. - Run(RunArgs), -} - -#[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, - /// Configure the published OpenCode tracing plugin. - #[command(name = "opencode", alias = "open-code")] - OpenCode, - /// Install the published Pi tracing extension. - Pi, -} - -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"; -const OPENCODE_PLUGIN: &str = "@braintrust/trace-opencode@^1"; -const PI_PLUGIN: &str = "npm:@braintrust/pi-extension@^1"; - -/// 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, and -/// `bt`'s profile-aware auth resolver. -fn serve_options(base: BaseArgs) -> ServeOptions { - let cfg = BraintrustSinkConfig { - api_url: None, - app_url: None, - version: crate::CLI_VERSION.to_string(), - }; - let mut options = braintrust_serve_options( - crate::CLI_VERSION, - cfg, - Arc::new(Registry::default_agents()), - ); - options.auth_provider = Some(Arc::new(BtAuthProvider { base })); - options -} - -#[derive(Clone)] -struct BtAuthProvider { - base: BaseArgs, -} - -#[async_trait] -impl AuthProvider for BtAuthProvider { - async fn resolve( - &self, - selection: &AuthSelection, - _reason: AuthResolveReason, - ) -> anyhow::Result { - let mut base = self.base.clone(); - base.no_input = true; - if let Some(profile) = &selection.profile { - base.profile = Some(profile.clone()); - base.profile_explicit = true; - base.prefer_profile = true; - } - if let Some(org_name) = &selection.org_name { - base.org_name = Some(org_name.clone()); - } - let resolved = crate::auth::resolve_auth(&base) - .await - .map_err(|error| anyhow::anyhow!("resolve auth: {error}"))?; - let token = resolved - .api_key - .ok_or_else(|| anyhow::anyhow!("selected Braintrust profile has no credential"))?; - let profile = resolved - .profile - .or_else(|| selection.profile.clone()) - .unwrap_or_else(|| "environment".into()); - let expires_at_ms = resolved.is_oauth.then(|| { - chrono::Utc::now() - .timestamp_millis() - .saturating_add(5 * 60 * 1000) - }); - Ok(AuthLease { - profile, - auth: BackendAuth { - token, - api_url: resolved.api_url, - app_url: resolved.app_url, - org_name: resolved.org_name, - org_id: None, - }, - expires_at_ms, - }) - } -} - -fn session_route(base: &BaseArgs) -> SessionRoute { - SessionRoute { - auth: AuthSelection { - profile: base.profile.clone(), - org_name: base.org_name.clone(), - }, - destination: base - .project - .clone() - .map(|project_name| TraceDestination::ProjectLogs { - project_id: None, - project_name: Some(project_name), - }), - flush_mode: FlushMode::FireAndForget, - additional_metadata: None, - } -} - -async fn resolve_trace_project(mut base: BaseArgs) -> anyhow::Result { - if base - .project - .as_deref() - .is_some_and(|project| !project.trim().is_empty()) - { - return Ok(base); - } - - if let Some(project) = - crate::config::configured_project_for_context(&base, base.org_name.as_deref()) - { - base.project = Some(project); - return Ok(base); - } - - if !crate::ui::is_interactive() { - bail!( - "project choice required in non-interactive mode; pass --project or set BRAINTRUST_DEFAULT_PROJECT" - ); - } - - let auth = crate::auth::resolve_auth(&base) - .await - .map_err(|error| anyhow::anyhow!("resolve auth: {error}"))?; - if let Some(profile) = auth.profile.clone() { - base.profile = Some(profile); - base.profile_explicit = true; - base.prefer_profile = true; - } - if base.org_name.is_none() { - base.org_name = auth.org_name.clone(); - } - if let Some(project) = - crate::config::configured_project_for_context(&base, auth.org_name.as_deref()) - { - base.project = Some(project); - return Ok(base); - } - - let login = crate::auth::login_read_only(&base).await?; - let client = crate::http::ApiClient::new(&login)?; - let project = crate::ui::select_project( - &client, - None, - Some("Select a project for coding-agent traces"), - crate::ui::ProjectSelectMode::ExistingOnly, - ) - .await?; - base.project = Some(project.name); - Ok(base) -} - -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 opencode_config_path() -> PathBuf { - let config_home = std::env::var_os("XDG_CONFIG_HOME") - .filter(|path| !path.is_empty()) - .map(PathBuf::from) - .or_else(|| dirs::home_dir().map(|home| home.join(".config"))) - .unwrap_or_else(|| PathBuf::from(".config")); - config_home.join("opencode").join("opencode.json") -} - -fn setup_opencode() -> anyhow::Result<()> { - let path = opencode_config_path(); - let mut config = load_settings(&path)?; - let plugins = config - .entry("plugin") - .or_insert_with(|| Value::Array(Vec::new())) - .as_array_mut() - .ok_or_else(|| { - anyhow::anyhow!( - "OpenCode `plugin` config must be an array: {}", - path.display() - ) - })?; - plugins.retain(|plugin| { - plugin.as_str().is_none_or(|plugin| { - plugin != "@braintrust/trace-opencode" - && !plugin.starts_with("@braintrust/trace-opencode@") - }) - }); - plugins.push(Value::String(OPENCODE_PLUGIN.into())); - - let mut encoded = serde_json::to_string_pretty(&Value::Object(config))?; - encoded.push('\n'); - crate::utils::write_text_atomic(&path, &encoded)?; - Ok(()) -} - -fn setup_pi() -> anyhow::Result<()> { - run_command("pi", &["install", PI_PLUGIN]) -} - -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 JSON configuration: {}", path.display()))?; - value.as_object().cloned().ok_or_else(|| { - anyhow::anyhow!("configuration 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 configuration: {}", path.display())) - } - } -} - -fn enable_tracing(source: &str, route: SessionRoute) -> anyhow::Result { - let path = paths::agent_settings_path(source, None); - let mut settings = load_settings(&path)?; - settings.insert("trace_to_braintrust".into(), Value::Bool(true)); - settings.insert("route".into(), serde_json::to_value(route)?); - settings.remove("traceToBraintrust"); - settings.remove("project"); - - 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 agent settings: {}", path.display()))?; - } - Ok(path) -} - -async fn run_setup(base: BaseArgs, args: SetupArgs, format: OutputFormat) -> anyhow::Result<()> { - let base = resolve_trace_project(base).await?; - - let (source, label) = match args.agent { - SetupAgent::Codex => { - setup_codex()?; - ("codex", "Codex") - } - SetupAgent::Claude => { - setup_claude()?; - ("claude", "Claude Code") - } - SetupAgent::OpenCode => { - setup_opencode()?; - ("opencode", "OpenCode") - } - SetupAgent::Pi => { - setup_pi()?; - ("pi", "Pi") - } - }; - let settings_path = enable_tracing(source, session_route(&base))?; - println!( - "{}", - TraceCommandOutput::setup(source, label, settings_path).render(format)? - ); - Ok(()) -} - -/// Resolve `bt`'s auth for one-shot imports. -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, - }, - destination: session_route(base).destination, - flush_mode: FlushMode::FireAndForget, - additional_metadata: None, - }) -} - -pub async fn run(base: BaseArgs, args: TraceArgs) -> anyhow::Result<()> { - let format = OutputFormat::from(base.login.json); - match args.command { - TraceCommand::Setup(setup_args) => run_setup(base, setup_args, format).await, - TraceCommand::Daemon(serve_args) => { - init_daemon_logging(base.verbose); - run_serve(serve_args, serve_options(base)).await - } - TraceCommand::Hook(hook_args) => { - // A hook must NEVER fail the agent's turn. It forwards only - // non-secret routing selection; the daemon resolves credentials. - if let Err(e) = run_hook(hook_args, session_route(&base), host_info()).await { - eprintln!("bt trace hook (non-fatal): {e}"); - } - Ok(()) - } - TraceCommand::Status(status_args) => { - let output = TraceCommandOutput::status(run_status(status_args).await?); - println!("{}", output.render(format)?); - 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!("{}", TraceCommandOutput::stop(false, false).render(format)?); - return Ok(()); - } - shutdown_daemon(&socket).await?; - println!("{}", TraceCommandOutput::stop(true, true).render(format)?); - Ok(()) - } - TraceCommand::Import(import_args) => { - let base = if import_args.destination.is_none() && import_args.parent.is_none() { - resolve_trace_project(base).await? - } else { - base - }; - let config = session_config(&base).await?; - run_import(import_args, serve_options(base), Some(config)).await - } - TraceCommand::Run(run_args) => { - let base = resolve_trace_project(base).await?; - let exe = std::env::current_exe() - .map(OsString::from) - .unwrap_or_else(|_| OsString::from("bt")); - let hook_command = RunHookCommand { - program: exe, - args: vec![OsString::from("trace"), OsString::from("hook")], - }; - let status = run_traced(run_args, hook_command, session_route(&base)).await?; - if status.success() { - Ok(()) - } else { - bail!("coding agent exited with {status}") - } - } - } -} diff --git a/src/main.rs b/src/main.rs index c51151fb..e7e3eb28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,6 @@ 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)] @@ -31,6 +30,7 @@ mod switch; mod sync; mod tools; mod topics; +mod trace_host; mod traces; mod ui; mod util_cmd; @@ -171,7 +171,7 @@ enum Commands { /// Show current identity, org, and project context Status(CLIArgs), /// Manage coding-agent tracing - Trace(CLIArgs), + Trace(CLIArgs), // /// View and modify config // Config(CLIArgs), } @@ -349,7 +349,9 @@ 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?, + Commands::Trace(cmd) => { + bt_daemon::run_trace(cmd.args, trace_host::context(cmd.base)).await? + } } Ok(()) }); diff --git a/src/trace_host.rs b/src/trace_host.rs new file mode 100644 index 00000000..44e675ed --- /dev/null +++ b/src/trace_host.rs @@ -0,0 +1,165 @@ +//! Braintrust host services for the mounted coding-agent trace runtime. +//! +//! The plugin runtime owns all trace commands and agent behavior. This module +//! only adapts `bt`'s profile store and project picker to its host-service +//! interface. + +use std::ffi::OsString; +use std::sync::Arc; + +use async_trait::async_trait; +use bt_daemon::wire::{AuthSelection, BackendAuth, FlushMode, SessionRoute, TraceDestination}; +use bt_daemon::{ + AuthLease, AuthResolveReason, OutputFormat, RunHookCommand, TraceHostContext, TraceHostServices, +}; + +use crate::args::BaseArgs; + +#[derive(Clone)] +struct BtTraceHost { + base: BaseArgs, +} + +fn session_route(base: &BaseArgs) -> SessionRoute { + SessionRoute { + auth: AuthSelection { + profile: base.profile.clone(), + org_name: base.org_name.clone(), + }, + destination: base + .project + .clone() + .map(|project_name| TraceDestination::ProjectLogs { + project_id: None, + project_name: Some(project_name), + }), + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } +} + +async fn resolve_trace_project(mut base: BaseArgs) -> anyhow::Result { + if base + .project + .as_deref() + .is_some_and(|project| !project.trim().is_empty()) + { + return Ok(base); + } + + if let Some(project) = + crate::config::configured_project_for_context(&base, base.org_name.as_deref()) + { + base.project = Some(project); + return Ok(base); + } + + if !crate::ui::is_interactive() { + anyhow::bail!( + "project choice required in non-interactive mode; pass --project or set BRAINTRUST_DEFAULT_PROJECT" + ); + } + + let auth = crate::auth::resolve_auth(&base) + .await + .map_err(|error| anyhow::anyhow!("resolve auth: {error}"))?; + if let Some(profile) = auth.profile.clone() { + base.profile = Some(profile); + base.profile_explicit = true; + base.prefer_profile = true; + } + if base.org_name.is_none() { + base.org_name = auth.org_name.clone(); + } + if let Some(project) = + crate::config::configured_project_for_context(&base, auth.org_name.as_deref()) + { + base.project = Some(project); + return Ok(base); + } + + let login = crate::auth::login_read_only(&base).await?; + let client = crate::http::ApiClient::new(&login)?; + let project = crate::ui::select_project( + &client, + None, + Some("Select a project for coding-agent traces"), + crate::ui::ProjectSelectMode::ExistingOnly, + ) + .await?; + base.project = Some(project.name); + Ok(base) +} + +#[async_trait] +impl TraceHostServices for BtTraceHost { + async fn resolve_route(&self, destination_required: bool) -> anyhow::Result { + let base = if destination_required { + resolve_trace_project(self.base.clone()).await? + } else { + self.base.clone() + }; + Ok(session_route(&base)) + } + + async fn resolve_auth( + &self, + selection: &AuthSelection, + _reason: AuthResolveReason, + ) -> anyhow::Result { + let mut base = self.base.clone(); + base.no_input = true; + if let Some(profile) = &selection.profile { + base.profile = Some(profile.clone()); + base.profile_explicit = true; + base.prefer_profile = true; + } + if let Some(org_name) = &selection.org_name { + base.org_name = Some(org_name.clone()); + } + let resolved = crate::auth::resolve_auth(&base) + .await + .map_err(|error| anyhow::anyhow!("resolve auth: {error}"))?; + let token = resolved + .api_key + .ok_or_else(|| anyhow::anyhow!("selected Braintrust profile has no credential"))?; + let profile = resolved + .profile + .or_else(|| selection.profile.clone()) + .unwrap_or_else(|| "environment".into()); + let expires_at_ms = resolved.is_oauth.then(|| { + chrono::Utc::now() + .timestamp_millis() + .saturating_add(5 * 60 * 1000) + }); + Ok(AuthLease { + profile, + auth: BackendAuth { + token, + api_url: resolved.api_url, + app_url: resolved.app_url, + org_name: resolved.org_name, + org_id: None, + }, + expires_at_ms, + }) + } +} + +pub fn context(base: BaseArgs) -> TraceHostContext { + let output_format = OutputFormat::from(base.login.json); + let verbose = base.verbose; + let executable = std::env::current_exe() + .map(OsString::from) + .unwrap_or_else(|_| OsString::from("bt")); + TraceHostContext { + version: crate::CLI_VERSION.to_string(), + output_format, + verbose, + command: RunHookCommand { + program: executable, + args: vec![OsString::from("trace")], + }, + services: Arc::new(BtTraceHost { base }), + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 71fadf60..21d4ca83 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -639,33 +639,6 @@ fn trace_setup_claude_installs_plugin_and_writes_selected_project() { ); } -#[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", "--project", "coding-agents"]) - .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 trace_setup_opencode_configures_the_npm_plugin_and_selected_route() { let home = tempfile::tempdir().expect("home tempdir");