From bc51581de272e9ed0ffa75fd07746bd2f5ff2974 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 17:46:49 -0800 Subject: [PATCH 1/5] feat(setup): add interactive setup wizard with auth and project selection --- src/auth.rs | 190 ++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 259 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 385 insertions(+), 64 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index fe253e14..39c39b12 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -816,6 +816,196 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { Ok(()) } +pub async fn login_interactive(base: &mut BaseArgs) -> Result { + let methods = ["OAuth (browser)", "API key"]; + let selected = ui::fuzzy_select("Select login method", &methods, 0)?; + + if selected == 0 { + login_interactive_oauth(base).await + } else { + login_interactive_api_key(base).await + } +} + +async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { + let api_key = prompt_api_key()?; + + let login_app_url = base + .app_url + .clone() + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); + let login_orgs = fetch_login_orgs(&api_key, &login_app_url).await?; + let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), true)?; + let selected_api_url = + resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; + let mut store = load_auth_store()?; + let profile_name = resolve_profile_name( + base.profile.as_deref(), + selected_org.as_ref().map(|org| org.name.as_str()), + true, + )?; + + save_profile_secret(&profile_name, &api_key)?; + let _ = delete_profile_oauth_refresh_token(&profile_name); + let _ = delete_profile_oauth_access_token(&profile_name); + + store.profiles.insert( + profile_name.to_string(), + AuthProfile { + auth_kind: AuthKind::ApiKey, + api_url: Some(selected_api_url), + app_url: base.app_url.clone(), + org_name: selected_org.as_ref().map(|org| org.name.clone()), + oauth_client_id: None, + oauth_access_expires_at: None, + user_name: None, + email: None, + api_key_hint: Some(obscure_api_key(&api_key)), + }, + ); + save_auth_store(&store)?; + + if let Some(org) = selected_org.as_ref() { + ui::print_command_status( + ui::CommandStatus::Success, + &format!( + "Logged in to org '{}' with profile '{}'", + org.name, profile_name + ), + ); + } else { + ui::print_command_status( + ui::CommandStatus::Success, + &format!("Logged in with profile '{profile_name}'"), + ); + } + + base.profile = Some(profile_name.clone()); + Ok(profile_name) +} + +async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { + let api_url = base + .api_url + .clone() + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let app_url = base + .app_url + .clone() + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); + let provisional_profile = base + .profile + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or("default"); + let client_id = default_oauth_client_id(provisional_profile); + + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + let state = generate_random_token(32)?; + + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .context("failed to bind oauth callback listener")?; + let callback_port = listener + .local_addr() + .context("failed to read callback listener address")? + .port(); + let redirect_uri = format!("http://127.0.0.1:{callback_port}/callback"); + let oauth_client = build_oauth_client(&api_url, &client_id, Some(&redirect_uri))?; + let (authorize_url, _) = oauth_client + .authorize_url(|| CsrfToken::new(state.clone())) + .add_scope(Scope::new(OAUTH_SCOPE.to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + let authorize_url = authorize_url.to_string(); + + eprintln!("Opening browser for OAuth authorization..."); + eprintln!("If it does not open, visit:\n{authorize_url}"); + if let Err(err) = open::that(&authorize_url) { + eprintln!("warning: failed to open browser automatically: {err}"); + } + + let callback = collect_oauth_callback(listener, is_ssh_session()).await?; + if let Some(error) = callback.error { + bail!("oauth authorization failed: {error}"); + } + let auth_code = callback + .code + .ok_or_else(|| anyhow::anyhow!("no authorization code received"))?; + if callback.state.is_none() { + bail!("oauth callback missing state; paste the full callback URL (or code=...&state=...)"); + } + if callback.state.as_deref() != Some(state.as_str()) { + bail!("oauth state mismatch; please try again"); + } + + let oauth_tokens = exchange_oauth_authorization_code( + &api_url, + &client_id, + &redirect_uri, + &auth_code, + pkce_verifier, + ) + .await?; + + let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; + let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), true)?; + let selected_api_url = + resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; + let mut store = load_auth_store()?; + let profile_name = resolve_profile_name( + base.profile.as_deref(), + selected_org.as_ref().map(|org| org.name.as_str()), + true, + )?; + + let refresh_token = oauth_tokens.refresh_token.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "oauth token response did not include a refresh_token; cannot create persistent oauth profile" + ) + })?; + save_profile_oauth_refresh_token(&profile_name, refresh_token)?; + save_profile_oauth_access_token(&profile_name, &oauth_tokens.access_token)?; + let _ = delete_profile_secret(&profile_name); + let oauth_access_expires_at = determine_oauth_access_expiry_epoch(&oauth_tokens); + let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); + + store.profiles.insert( + profile_name.to_string(), + AuthProfile { + auth_kind: AuthKind::Oauth, + api_url: Some(selected_api_url), + app_url: Some(app_url), + org_name: selected_org.as_ref().map(|org| org.name.clone()), + oauth_client_id: Some(client_id), + oauth_access_expires_at, + user_name: jwt_id.name, + email: jwt_id.email, + api_key_hint: None, + }, + ); + save_auth_store(&store)?; + + if let Some(org) = selected_org.as_ref() { + ui::print_command_status( + ui::CommandStatus::Success, + &format!( + "Logged in with OAuth to org '{}' with profile '{}'", + org.name, profile_name + ), + ); + } else { + ui::print_command_status( + ui::CommandStatus::Success, + &format!("Logged in with OAuth using profile '{profile_name}'"), + ); + } + + base.profile = Some(profile_name.clone()); + Ok(profile_name) +} + fn oauth_ignored_api_key_warning(base: &BaseArgs) -> Option { let api_key = base.api_key.as_deref()?.trim(); if api_key.is_empty() { diff --git a/src/setup/mod.rs b/src/setup/mod.rs index ac7ab2a4..9e232534 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -6,13 +6,17 @@ use std::process::Stdio; use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Subcommand, ValueEnum}; -use dialoguer::{theme::ColorfulTheme, FuzzySelect, MultiSelect}; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, MultiSelect}; use serde::Serialize; use serde_json::{Map, Value}; use tokio::process::Command; use crate::args::BaseArgs; -use crate::ui::with_spinner; +use crate::auth; +use crate::auth::LoginContext; +use crate::config; +use crate::http::ApiClient; +use crate::ui::{self, with_spinner}; mod docs; @@ -320,40 +324,7 @@ pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { Some(SetupSubcommand::Doctor(doctor)) => run_doctor(base, doctor), None => { if should_prompt_setup_action(&base, &args.agents) { - match prompt_setup_action()? { - Some(SetupAction::Instrument) => { - run_instrument_setup( - base, - InstrumentSetupArgs { - agent: None, - agent_cmd: None, - workflows: Vec::new(), - yes: false, - refresh_docs: false, - workers: crate::sync::default_workers(), - }, - ) - .await - } - Some(SetupAction::Skills) => run_setup(base, args.agents).await, - Some(SetupAction::Mcp) => run_mcp_setup( - base, - AgentsMcpSetupArgs { - agents: Vec::new(), - local: false, - global: false, - yes: false, - }, - ), - Some(SetupAction::Doctor) => run_doctor( - base, - AgentsDoctorArgs { - local: false, - global: false, - }, - ), - None => bail!("setup cancelled by user"), - } + run_setup_wizard(base).await } else { run_setup(base, args.agents).await } @@ -363,6 +334,194 @@ pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { pub use docs::run_docs_top; +async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { + let login_ctx = ensure_auth(&mut base).await?; + + let client = ApiClient::new(&login_ctx)?; + let project = select_project_with_skip(&client).await?; + let org = client.org_name().to_string(); + + if let Some(ref project) = project { + if find_git_root().is_some() { + maybe_init(&org, project)?; + } + } + + prompt_and_run_agent_setup(&base).await?; + + if find_git_root().is_some() { + let instrument = Confirm::new() + .with_prompt("Run instrumentation agent to set up tracing in this repo?") + .default(false) + .interact()?; + if instrument { + run_instrument_setup( + base, + InstrumentSetupArgs { + agent: None, + agent_cmd: None, + workflows: Vec::new(), + yes: false, + refresh_docs: false, + workers: crate::sync::default_workers(), + }, + ) + .await?; + } + } + + Ok(()) +} + +async fn ensure_auth(base: &mut BaseArgs) -> Result { + if base.api_key.is_some() { + return auth::login(base).await; + } + + let profiles = auth::list_profiles()?; + match profiles.len() { + 0 => { + eprintln!("No auth profiles found. Let's set one up.\n"); + auth::login_interactive(base).await?; + auth::login(base).await + } + 1 => { + let p = &profiles[0]; + let label = p.org_name.as_deref().unwrap_or(&p.name); + eprintln!("Using org: {label}\n"); + base.profile = Some(p.name.clone()); + auth::login(base).await + } + _ => { + let name = auth::select_profile_interactive(None)? + .ok_or_else(|| anyhow!("no profile selected"))?; + base.profile = Some(name); + auth::login(base).await + } + } +} + +async fn select_project_with_skip(client: &ApiClient) -> Result> { + let mut projects = with_spinner( + "Loading projects...", + crate::projects::api::list_projects(client), + ) + .await?; + + if projects.is_empty() { + bail!("no projects found in org '{}'", client.org_name()); + } + + projects.sort_by(|a, b| a.name.cmp(&b.name)); + let mut labels: Vec = projects.iter().map(|p| p.name.clone()).collect(); + labels.push("Skip (not recommended)".to_string()); + + let selection = ui::fuzzy_select("Select project", &labels, 0)?; + + if selection == labels.len() - 1 { + Ok(None) + } else { + Ok(Some(projects[selection].name.clone())) + } +} + +fn maybe_init(org: &str, project: &str) -> Result<()> { + let config_path = std::env::current_dir()?.join(".bt").join("config.json"); + + if config_path.exists() { + let existing = config::load_file(&config_path); + if existing.org.as_deref() == Some(org) && existing.project.as_deref() == Some(project) { + return Ok(()); + } + let update = Confirm::new() + .with_prompt(format!("Update .bt/config.json to {org}/{project}?")) + .default(true) + .interact()?; + if !update { + return Ok(()); + } + } + + let cfg = config::Config { + org: Some(org.to_string()), + project: Some(project.to_string()), + ..Default::default() + }; + config::save_local(&cfg, true)?; + crate::ui::print_command_status( + crate::ui::CommandStatus::Success, + &format!("Linked to {org}/{project}"), + ); + Ok(()) +} + +fn agent_to_agent_arg(agent: Agent) -> AgentArg { + match agent { + Agent::Claude => AgentArg::Claude, + Agent::Codex => AgentArg::Codex, + Agent::Cursor => AgentArg::Cursor, + Agent::Opencode => AgentArg::Opencode, + } +} + +async fn prompt_and_run_agent_setup(base: &BaseArgs) -> Result<()> { + let choices = ["Skills", "MCP"]; + let defaults = [true, true]; + let selected = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("What would you like to set up?") + .items(&choices) + .defaults(&defaults) + .interact()?; + + if selected.is_empty() { + return Ok(()); + } + + let wants_skills = selected.contains(&0); + let wants_mcp = selected.contains(&1); + + let scope = prompt_scope_selection("Select install scope")? + .ok_or_else(|| anyhow!("setup cancelled"))?; + + let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; + let local_root = resolve_local_root_for_scope(scope)?; + let detected = detect_agents(local_root.as_deref(), &home); + let agent_defaults = resolve_selected_agents(&[], &detected); + let agents = + prompt_agents_selection(&agent_defaults)?.ok_or_else(|| anyhow!("setup cancelled"))?; + + if agents.is_empty() { + bail!("no agents selected"); + } + + let agent_args: Vec = agents.iter().map(|a| agent_to_agent_arg(*a)).collect(); + + if wants_skills { + let args = AgentsSetupArgs { + agents: agent_args.clone(), + local: matches!(scope, InstallScope::Local), + global: matches!(scope, InstallScope::Global), + workflows: Vec::new(), + yes: false, + no_fetch_docs: false, + refresh_docs: false, + workers: crate::sync::default_workers(), + }; + run_setup(base.clone(), args).await?; + } + if wants_mcp { + let args = AgentsMcpSetupArgs { + agents: agent_args, + local: matches!(scope, InstallScope::Local), + global: matches!(scope, InstallScope::Global), + yes: false, + }; + run_mcp_setup(base.clone(), args)?; + } + + Ok(()) +} + async fn run_setup(base: BaseArgs, args: AgentsSetupArgs) -> Result<()> { let outcome = execute_skills_setup(&base, &args).await?; if base.json { @@ -483,14 +642,6 @@ async fn execute_skills_setup( }) } -#[derive(Debug, Clone, Copy)] -enum SetupAction { - Instrument, - Skills, - Mcp, - Doctor, -} - #[derive(Debug, Clone, Copy, ValueEnum)] enum InstrumentAgentArg { Claude, @@ -513,26 +664,6 @@ fn should_prompt_setup_action(base: &BaseArgs, args: &AgentsSetupArgs) -> bool { && args.workers == crate::sync::default_workers() } -fn prompt_setup_action() -> Result> { - let choices = [ - "instrument (setup skills + use a coding agent to install Braintrust)", - "skills (just setup skills)", - "mcp (configure MCP)", - "doctor (diagnose setup)", - ]; - let idx = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Select setup action") - .items(&choices) - .default(0) - .interact_opt()?; - Ok(idx.map(|value| match value { - 0 => SetupAction::Instrument, - 1 => SetupAction::Skills, - 2 => SetupAction::Mcp, - _ => SetupAction::Doctor, - })) -} - async fn run_instrument_setup(base: BaseArgs, args: InstrumentSetupArgs) -> Result<()> { let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; let root = find_git_root().ok_or_else(|| { From 29167b4d2446c9372f5e3ca55b1349b044e40798 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 19:44:03 -0800 Subject: [PATCH 2/5] refactor(setup): streamline wizard UX and simplify login flow --- src/auth.rs | 99 ++++++++++------- src/setup/mod.rs | 277 +++++++++++++++++++++++++++++++---------------- 2 files changed, 244 insertions(+), 132 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 39c39b12..21fe0815 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -835,14 +835,15 @@ async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { .clone() .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); let login_orgs = fetch_login_orgs(&api_key, &login_app_url).await?; - let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), true)?; + let selected_org = select_login_org_simple(login_orgs.clone(), base.org_name.as_deref())?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; let mut store = load_auth_store()?; + // Auto-generate profile name from org (no prompt) let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), - true, + false, )?; save_profile_secret(&profile_name, &api_key)?; @@ -865,21 +866,6 @@ async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { ); save_auth_store(&store)?; - if let Some(org) = selected_org.as_ref() { - ui::print_command_status( - ui::CommandStatus::Success, - &format!( - "Logged in to org '{}' with profile '{}'", - org.name, profile_name - ), - ); - } else { - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Logged in with profile '{profile_name}'"), - ); - } - base.profile = Some(profile_name.clone()); Ok(profile_name) } @@ -920,11 +906,9 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { .url(); let authorize_url = authorize_url.to_string(); - eprintln!("Opening browser for OAuth authorization..."); - eprintln!("If it does not open, visit:\n{authorize_url}"); - if let Err(err) = open::that(&authorize_url) { - eprintln!("warning: failed to open browser automatically: {err}"); - } + let _ = open::that(&authorize_url); + eprintln!("Complete authorization in your browser."); + eprintln!("{}", dialoguer::console::style(&authorize_url).dim()); let callback = collect_oauth_callback(listener, is_ssh_session()).await?; if let Some(error) = callback.error { @@ -950,14 +934,16 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { .await?; let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; - let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), true)?; + // Wizard uses simplified org selector (names only, no UUIDs/URLs) + let selected_org = select_login_org_simple(login_orgs.clone(), base.org_name.as_deref())?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; let mut store = load_auth_store()?; + // Auto-generate profile name from org (no prompt) let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), - true, + false, )?; let refresh_token = oauth_tokens.refresh_token.as_ref().ok_or_else(|| { @@ -987,21 +973,6 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { ); save_auth_store(&store)?; - if let Some(org) = selected_org.as_ref() { - ui::print_command_status( - ui::CommandStatus::Success, - &format!( - "Logged in with OAuth to org '{}' with profile '{}'", - org.name, profile_name - ), - ); - } else { - ui::print_command_status( - ui::CommandStatus::Success, - &format!("Logged in with OAuth using profile '{profile_name}'"), - ); - } - base.profile = Some(profile_name.clone()); Ok(profile_name) } @@ -1585,6 +1556,49 @@ fn select_login_org( )) } +/// Simplified org selector for the wizard — shows only org names (no UUIDs or API URLs). +/// Auto-selects when there is only one org. Falls back to non-interactive if TTY is unavailable. +fn select_login_org_simple( + mut orgs: Vec, + requested_org_name: Option<&str>, +) -> Result> { + if orgs.is_empty() { + bail!("no organizations found for this credential"); + } + orgs.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + }); + + if let Some(name) = requested_org_name { + let selected = orgs + .iter() + .find(|org| org.name.eq_ignore_ascii_case(name)) + .cloned(); + return selected.map(Some).ok_or_else(|| { + let available = orgs + .iter() + .map(|org| org.name.as_str()) + .collect::>() + .join(", "); + anyhow::anyhow!("org '{name}' not found. Available: {available}") + }); + } + + if orgs.len() == 1 { + return Ok(Some(orgs.into_iter().next().expect("org exists"))); + } + + let labels: Vec<&str> = orgs.iter().map(|o| o.name.as_str()).collect(); + let selection = ui::fuzzy_select("Select organization", &labels, 0)?; + Ok(Some( + orgs.into_iter() + .nth(selection) + .expect("selected index should be in range"), + )) +} + fn resolve_profile_api_url( explicit_api_url: Option, selected_org: Option<&LoginOrgInfo>, @@ -1720,8 +1734,11 @@ async fn collect_oauth_callback( } async fn wait_for_oauth_callback_or_stdin(listener: TcpListener) -> Result { - println!("Waiting for OAuth callback..."); - println!("If localhost callback does not complete, paste code=...&state=... and press Enter."); + eprintln!("Waiting for browser authorization..."); + eprintln!( + "{}", + dialoguer::console::style("Paste code=...&state=... if callback doesn't complete").dim() + ); let callback_fut = wait_for_oauth_callback(listener); tokio::pin!(callback_fut); diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 9e232534..648e74ab 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -6,7 +6,8 @@ use std::process::Stdio; use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Subcommand, ValueEnum}; -use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, MultiSelect}; +use dialoguer::console::style; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, MultiSelect, Select}; use serde::Serialize; use serde_json::{Map, Value}; use tokio::process::Command; @@ -180,6 +181,15 @@ impl Agent { Agent::Opencode => "opencode", } } + + fn display_name(self) -> &'static str { + match self { + Agent::Claude => "Claude", + Agent::Codex => "Codex", + Agent::Cursor => "Cursor", + Agent::Opencode => "Opencode", + } + } } #[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, ValueEnum)] @@ -335,20 +345,104 @@ pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { pub use docs::run_docs_top; async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { - let login_ctx = ensure_auth(&mut base).await?; + let mut had_failures = false; + // ── Step 1: Auth ── + print_wizard_step(1, "Auth"); + let login_ctx = ensure_auth(&mut base).await?; let client = ApiClient::new(&login_ctx)?; - let project = select_project_with_skip(&client).await?; let org = client.org_name().to_string(); + eprintln!(" {} Using org '{}'", style("✓").green(), org); + // ── Step 2: Project ── + print_wizard_step(2, "Project"); + let project = select_project_with_skip(&client).await?; if let Some(ref project) = project { - if find_git_root().is_some() { - maybe_init(&org, project)?; + if find_git_root().is_some() && maybe_init(&org, project)? { + eprintln!(" {} Linked to {}/{}", style("✓").green(), org, project); } + } else { + eprintln!(" {}", style("Skipped").dim()); } - prompt_and_run_agent_setup(&base).await?; + // ── Agent setup prompts (shared for skills + MCP) ── + let choices = ["Skills", "MCP"]; + let defaults = [true, true]; + let selected = MultiSelect::with_theme(&ColorfulTheme::default()) + .with_prompt("What would you like to set up?") + .items(&choices) + .defaults(&defaults) + .interact()?; + + let wants_skills = selected.contains(&0); + let wants_mcp = selected.contains(&1); + + let setup_context = if !selected.is_empty() { + let scope = prompt_scope_selection("Select install scope")? + .ok_or_else(|| anyhow!("setup cancelled"))?; + let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; + let local_root = resolve_local_root_for_scope(scope)?; + let detected = detect_agents(local_root.as_deref(), &home); + let agent_defaults = resolve_selected_agents(&[], &detected); + let agents = + prompt_agents_selection(&agent_defaults)?.ok_or_else(|| anyhow!("setup cancelled"))?; + if agents.is_empty() { + bail!("no agents selected"); + } + Some((scope, agents, home)) + } else { + None + }; + + // ── Step 3: Skills ── + print_wizard_step(3, "Skills"); + if wants_skills { + if let Some((scope, ref agents, _)) = setup_context { + let agent_args: Vec = agents.iter().map(|a| agent_to_agent_arg(*a)).collect(); + let args = AgentsSetupArgs { + agents: agent_args, + local: matches!(scope, InstallScope::Local), + global: matches!(scope, InstallScope::Global), + workflows: Vec::new(), + yes: false, + no_fetch_docs: true, + refresh_docs: false, + workers: crate::sync::default_workers(), + }; + let outcome = execute_skills_setup(&base, &args, true).await?; + for r in &outcome.results { + print_wizard_agent_result(r); + if matches!(r.status, InstallStatus::Failed) { + had_failures = true; + } + } + } + } else { + eprintln!(" {}", style("Skipped").dim()); + } + // ── Step 4: MCP ── + print_wizard_step(4, "MCP"); + if wants_mcp { + if let Some((scope, ref agents, ref home)) = setup_context { + let local_root = resolve_local_root_for_scope(scope)?; + let outcome = execute_mcp_install(scope, local_root.as_deref(), home, agents); + for r in &outcome.results { + print_wizard_agent_result(r); + if matches!(r.status, InstallStatus::Failed) { + had_failures = true; + } + } + if outcome.installed_count == 0 { + had_failures = true; + } + } + } else { + eprintln!(" {}", style("Skipped").dim()); + } + + // ── Step 5: Instrument ── + print_wizard_step(5, "Instrument"); if find_git_root().is_some() { let instrument = Confirm::new() .with_prompt("Run instrumentation agent to set up tracing in this repo?") @@ -367,9 +461,15 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { }, ) .await?; + } else { + eprintln!(" {}", style("Skipped").dim()); } + } else { + eprintln!(" {}", style("Skipped").dim()); } + // ── Done ── + print_wizard_done(had_failures); Ok(()) } @@ -387,8 +487,6 @@ async fn ensure_auth(base: &mut BaseArgs) -> Result { } 1 => { let p = &profiles[0]; - let label = p.org_name.as_deref().unwrap_or(&p.name); - eprintln!("Using org: {label}\n"); base.profile = Some(p.name.clone()); auth::login(base).await } @@ -425,20 +523,21 @@ async fn select_project_with_skip(client: &ApiClient) -> Result> } } -fn maybe_init(org: &str, project: &str) -> Result<()> { +/// Returns `true` if config was written or already matched, `false` if user declined. +fn maybe_init(org: &str, project: &str) -> Result { let config_path = std::env::current_dir()?.join(".bt").join("config.json"); if config_path.exists() { let existing = config::load_file(&config_path); if existing.org.as_deref() == Some(org) && existing.project.as_deref() == Some(project) { - return Ok(()); + return Ok(true); } let update = Confirm::new() .with_prompt(format!("Update .bt/config.json to {org}/{project}?")) .default(true) .interact()?; if !update { - return Ok(()); + return Ok(false); } } @@ -448,11 +547,7 @@ fn maybe_init(org: &str, project: &str) -> Result<()> { ..Default::default() }; config::save_local(&cfg, true)?; - crate::ui::print_command_status( - crate::ui::CommandStatus::Success, - &format!("Linked to {org}/{project}"), - ); - Ok(()) + Ok(true) } fn agent_to_agent_arg(agent: Agent) -> AgentArg { @@ -464,66 +559,8 @@ fn agent_to_agent_arg(agent: Agent) -> AgentArg { } } -async fn prompt_and_run_agent_setup(base: &BaseArgs) -> Result<()> { - let choices = ["Skills", "MCP"]; - let defaults = [true, true]; - let selected = MultiSelect::with_theme(&ColorfulTheme::default()) - .with_prompt("What would you like to set up?") - .items(&choices) - .defaults(&defaults) - .interact()?; - - if selected.is_empty() { - return Ok(()); - } - - let wants_skills = selected.contains(&0); - let wants_mcp = selected.contains(&1); - - let scope = prompt_scope_selection("Select install scope")? - .ok_or_else(|| anyhow!("setup cancelled"))?; - - let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; - let local_root = resolve_local_root_for_scope(scope)?; - let detected = detect_agents(local_root.as_deref(), &home); - let agent_defaults = resolve_selected_agents(&[], &detected); - let agents = - prompt_agents_selection(&agent_defaults)?.ok_or_else(|| anyhow!("setup cancelled"))?; - - if agents.is_empty() { - bail!("no agents selected"); - } - - let agent_args: Vec = agents.iter().map(|a| agent_to_agent_arg(*a)).collect(); - - if wants_skills { - let args = AgentsSetupArgs { - agents: agent_args.clone(), - local: matches!(scope, InstallScope::Local), - global: matches!(scope, InstallScope::Global), - workflows: Vec::new(), - yes: false, - no_fetch_docs: false, - refresh_docs: false, - workers: crate::sync::default_workers(), - }; - run_setup(base.clone(), args).await?; - } - if wants_mcp { - let args = AgentsMcpSetupArgs { - agents: agent_args, - local: matches!(scope, InstallScope::Local), - global: matches!(scope, InstallScope::Global), - yes: false, - }; - run_mcp_setup(base.clone(), args)?; - } - - Ok(()) -} - async fn run_setup(base: BaseArgs, args: AgentsSetupArgs) -> Result<()> { - let outcome = execute_skills_setup(&base, &args).await?; + let outcome = execute_skills_setup(&base, &args, false).await?; if base.json { let report = SetupJsonReport { scope: outcome.scope.as_str().to_string(), @@ -558,6 +595,7 @@ async fn run_setup(base: BaseArgs, args: AgentsSetupArgs) -> Result<()> { async fn execute_skills_setup( base: &BaseArgs, args: &AgentsSetupArgs, + quiet: bool, ) -> Result { let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; let selection = resolve_setup_selection(args, &home)?; @@ -569,7 +607,7 @@ async fn execute_skills_setup( let mut warnings = Vec::new(); let mut notes = Vec::new(); let mut results = Vec::new(); - let show_progress = !base.json; + let show_progress = !base.json && !quiet; if show_progress { println!("Configuring coding agents for Braintrust"); @@ -722,7 +760,7 @@ async fn run_instrument_setup(base: BaseArgs, args: InstrumentSetupArgs) -> Resu refresh_docs: args.refresh_docs, workers: args.workers, }; - let outcome = execute_skills_setup(&base, &setup_args).await?; + let outcome = execute_skills_setup(&base, &setup_args, false).await?; detected = outcome.detected_agents; results.extend(outcome.results); warnings.extend(outcome.warnings); @@ -1087,19 +1125,23 @@ Output: ) } -fn run_mcp_setup(base: BaseArgs, args: AgentsMcpSetupArgs) -> Result<()> { - let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; - let selection = resolve_mcp_selection(&args, &home)?; - let scope = selection.scope; - let local_root = selection.local_root; - let detected = selection.detected; - let selected_agents = selection.selected_agents; +struct McpSetupOutcome { + results: Vec, + warnings: Vec, + installed_count: usize, +} +fn execute_mcp_install( + scope: InstallScope, + local_root: Option<&Path>, + home: &Path, + agents: &[Agent], +) -> McpSetupOutcome { let mut warnings = Vec::new(); let mut results = Vec::new(); - for agent in selected_agents.iter().copied() { - let result = install_mcp_for_agent(agent, scope, local_root.as_deref(), &home); + for agent in agents.iter().copied() { + let result = install_mcp_for_agent(agent, scope, local_root, home); match result { Ok(r) => { if matches!(r.status, InstallStatus::Skipped) @@ -1125,13 +1167,30 @@ fn run_mcp_setup(base: BaseArgs, args: AgentsMcpSetupArgs) -> Result<()> { .filter(|r| matches!(r.status, InstallStatus::Installed)) .count(); + McpSetupOutcome { + results, + warnings, + installed_count, + } +} + +fn run_mcp_setup(base: BaseArgs, args: AgentsMcpSetupArgs) -> Result<()> { + let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; + let selection = resolve_mcp_selection(&args, &home)?; + let scope = selection.scope; + let local_root = selection.local_root; + let detected = selection.detected; + let selected_agents = selection.selected_agents; + + let outcome = execute_mcp_install(scope, local_root.as_deref(), &home, &selected_agents); + if base.json { let report = SetupJsonReport { scope: scope.as_str().to_string(), selected_agents, detected_agents: detected, - results, - warnings, + results: outcome.results, + warnings: outcome.warnings, notes: vec!["Configured MCP only (`bt setup mcp`).".to_string()], }; println!( @@ -1140,10 +1199,10 @@ fn run_mcp_setup(base: BaseArgs, args: AgentsMcpSetupArgs) -> Result<()> { .context("failed to serialize MCP setup report")? ); } else { - print_mcp_human_report(scope, &selected_agents, &results, &warnings); + print_mcp_human_report(scope, &selected_agents, &outcome.results, &outcome.warnings); } - if installed_count == 0 { + if outcome.installed_count == 0 { bail!("no MCP configurations were installed successfully"); } @@ -1548,7 +1607,7 @@ fn resolve_local_root_for_scope(scope: InstallScope) -> Result> fn prompt_scope_selection(prompt: &str) -> Result> { let choices = ["local (current git repo)", "global (user-wide)"]; - let idx = FuzzySelect::with_theme(&ColorfulTheme::default()) + let idx = Select::with_theme(&ColorfulTheme::default()) .with_prompt(prompt) .items(&choices) .default(0) @@ -2287,6 +2346,42 @@ fn command_exists(binary: &str) -> bool { false } +// ── Wizard output helpers ── + +fn print_wizard_step(number: u8, label: &str) { + eprintln!("\n{}. {}", style(number).bold(), style(label).bold()); +} + +fn print_wizard_agent_result(result: &AgentInstallResult) { + let (indicator, status_text) = match result.status { + InstallStatus::Installed => (style("✓").green(), "installed"), + InstallStatus::Skipped => (style("—").dim(), "skipped"), + InstallStatus::Failed => (style("✗").red(), "failed"), + }; + eprintln!( + " {} {} — {}", + indicator, + result.agent.display_name(), + status_text + ); +} + +fn print_wizard_done(had_failures: bool) { + if had_failures { + eprintln!( + "\n{} {}", + style("!").dim(), + style("Setup complete (with warnings)").bold() + ); + } else { + eprintln!( + "\n{} {}", + style("✓").green(), + style("Setup complete").bold() + ); + } +} + fn print_human_report( include_header: bool, scope: InstallScope, From fdc0a327473dc31068e78c0b57f27a3eb5cd6837 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 22:12:58 -0800 Subject: [PATCH 3/5] refactor(setup): consolidate agent tools step in setup wizard --- src/setup/mod.rs | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 648e74ab..e5e3c308 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -365,7 +365,8 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { eprintln!(" {}", style("Skipped").dim()); } - // ── Agent setup prompts (shared for skills + MCP) ── + // ── Step 3: Agent tools (skills + MCP) ── + print_wizard_step(3, "Agents"); let choices = ["Skills", "MCP"]; let defaults = [true, true]; let selected = MultiSelect::with_theme(&ColorfulTheme::default()) @@ -394,9 +395,8 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { None }; - // ── Step 3: Skills ── - print_wizard_step(3, "Skills"); if wants_skills { + eprintln!(" {}", style("Skills:").bold()); if let Some((scope, ref agents, _)) = setup_context { let agent_args: Vec = agents.iter().map(|a| agent_to_agent_arg(*a)).collect(); let args = AgentsSetupArgs { @@ -417,13 +417,10 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { } } } - } else { - eprintln!(" {}", style("Skipped").dim()); } - // ── Step 4: MCP ── - print_wizard_step(4, "MCP"); if wants_mcp { + eprintln!(" {}", style("MCP:").bold()); if let Some((scope, ref agents, ref home)) = setup_context { let local_root = resolve_local_root_for_scope(scope)?; let outcome = execute_mcp_install(scope, local_root.as_deref(), home, agents); @@ -437,12 +434,14 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { had_failures = true; } } - } else { + } + + if !wants_skills && !wants_mcp { eprintln!(" {}", style("Skipped").dim()); } - // ── Step 5: Instrument ── - print_wizard_step(5, "Instrument"); + // ── Step 4: Instrument ── + print_wizard_step(4, "Instrument"); if find_git_root().is_some() { let instrument = Confirm::new() .with_prompt("Run instrumentation agent to set up tracing in this repo?") From 4908213001eebe93777fe34edc32147770389ba8 Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 22:39:28 -0800 Subject: [PATCH 4/5] feat(setup): add streaming agent output display with progress indicators --- src/setup/agent_stream.rs | 561 ++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 55 +++- 2 files changed, 611 insertions(+), 5 deletions(-) create mode 100644 src/setup/agent_stream.rs diff --git a/src/setup/agent_stream.rs b/src/setup/agent_stream.rs new file mode 100644 index 00000000..4b62a9bd --- /dev/null +++ b/src/setup/agent_stream.rs @@ -0,0 +1,561 @@ +use std::collections::HashMap; +use std::io::{IsTerminal, Write as _}; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use dialoguer::console::style; +use indicatif::{ProgressBar, ProgressStyle}; +use regex::Regex; +use serde::Deserialize; +use serde_json::Value; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Child; + +// --------------------------------------------------------------------------- +// Serde types for Claude Code / Cursor stream-json JSONL +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum StreamLine { + #[serde(rename = "stream_event")] + StreamEvent { + event: StreamEvent, + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "assistant")] + Assistant { + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "user")] + User { + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "result")] + Result { + #[serde(flatten)] + extra: Value, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum StreamEvent { + #[serde(rename = "content_block_start")] + ContentBlockStart { + index: u32, + content_block: ContentBlock, + }, + #[serde(rename = "content_block_delta")] + ContentBlockDelta { index: u32, delta: Delta }, + #[serde(rename = "content_block_stop")] + ContentBlockStop { index: u32 }, + #[serde(rename = "message_delta")] + MessageDelta { + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "message_stop")] + MessageStop, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum ContentBlock { + #[serde(rename = "text")] + Text { + #[serde(flatten)] + _extra: Value, + }, + #[serde(rename = "tool_use")] + ToolUse { + name: String, + #[serde(flatten)] + _extra: Value, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type")] +enum Delta { + #[serde(rename = "text_delta")] + TextDelta { text: String }, + #[serde(rename = "input_json_delta")] + InputJsonDelta { partial_json: String }, + #[serde(other)] + Unknown, +} + +// --------------------------------------------------------------------------- +// Display state machine +// --------------------------------------------------------------------------- + +enum BlockState { + Text, + ToolUse { name: String, partial_input: String }, +} + +struct AgentStreamDisplay { + blocks: HashMap, + spinner: Option, + has_text_output: bool, + is_tty: bool, +} + +impl AgentStreamDisplay { + fn new() -> Self { + Self { + blocks: HashMap::new(), + spinner: None, + has_text_output: false, + is_tty: std::io::stderr().is_terminal(), + } + } + + fn handle(&mut self, line: StreamLine) { + match line { + StreamLine::StreamEvent { event, .. } => self.handle_event(event), + StreamLine::Assistant { .. } | StreamLine::User { .. } | StreamLine::Unknown => {} + StreamLine::Result { .. } => {} + } + } + + fn handle_event(&mut self, event: StreamEvent) { + match event { + StreamEvent::ContentBlockStart { + index, + content_block, + } => match content_block { + ContentBlock::Text { .. } => { + self.blocks.insert(index, BlockState::Text); + } + ContentBlock::ToolUse { name, .. } => { + self.clear_spinner(); + if self.has_text_output { + eprintln!(); + self.has_text_output = false; + } + self.start_spinner(&tool_display(&name, "")); + self.blocks.insert( + index, + BlockState::ToolUse { + name, + partial_input: String::new(), + }, + ); + } + ContentBlock::Unknown => {} + }, + StreamEvent::ContentBlockDelta { index, delta } => match delta { + Delta::TextDelta { text } => { + if self.spinner.is_some() { + self.suspend_spinner(); + } + eprint!("{}", style(&text).dim()); + let _ = std::io::stderr().flush(); + self.has_text_output = true; + } + Delta::InputJsonDelta { partial_json } => { + if let Some(BlockState::ToolUse { + name, + partial_input, + }) = self.blocks.get_mut(&index) + { + partial_input.push_str(&partial_json); + let msg = tool_display(name, partial_input); + if let Some(sp) = &self.spinner { + sp.set_message(msg); + } + } + } + Delta::Unknown => {} + }, + StreamEvent::ContentBlockStop { index } => { + if let Some(block) = self.blocks.remove(&index) { + match block { + BlockState::Text => { + if self.has_text_output { + eprintln!(); + self.has_text_output = false; + } + } + BlockState::ToolUse { + name, + partial_input, + } => { + let done_msg = tool_done_display(&name, &partial_input); + self.finish_spinner_with(&done_msg); + } + } + } + } + StreamEvent::MessageDelta { .. } | StreamEvent::MessageStop | StreamEvent::Unknown => {} + } + } + + fn start_spinner(&mut self, message: &str) { + self.clear_spinner(); + if !self.is_tty { + eprintln!(" {} {}", style("…").dim(), style(message).dim()); + return; + } + let sp = ProgressBar::new_spinner(); + sp.set_style( + ProgressStyle::default_spinner() + .tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", " "]) + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + sp.set_message(message.to_string()); + sp.enable_steady_tick(Duration::from_millis(80)); + self.spinner = Some(sp); + } + + fn suspend_spinner(&mut self) { + if let Some(sp) = &self.spinner { + sp.set_draw_target(indicatif::ProgressDrawTarget::hidden()); + } + } + + fn clear_spinner(&mut self) { + if let Some(sp) = self.spinner.take() { + sp.finish_and_clear(); + } + } + + fn finish_spinner_with(&mut self, done_msg: &str) { + if let Some(sp) = self.spinner.take() { + sp.finish_and_clear(); + } + eprintln!(" {} {}", style("✓").green(), style(done_msg).dim()); + } + + fn finish(&mut self) { + self.clear_spinner(); + if self.has_text_output { + eprintln!(); + } + } +} + +// --------------------------------------------------------------------------- +// Tool display helpers +// --------------------------------------------------------------------------- + +fn tool_display(name: &str, partial_input: &str) -> String { + let target = extract_target(partial_input); + let action = match name { + "Read" => "Reading", + "Write" => "Writing", + "Edit" | "MultiEdit" => "Editing", + "Bash" => { + return match target { + Some(cmd) => format!("Running: {cmd}"), + None => "Running command".to_string(), + } + } + "Grep" => "Searching", + "Glob" => "Finding files", + "LSP" => "Analyzing", + "Task" => "Running task", + "WebFetch" => "Fetching", + "WebSearch" => "Searching web", + "NotebookEdit" => "Editing notebook", + other => other, + }; + match target { + Some(t) => format!("{action} {t}"), + None => action.to_string(), + } +} + +fn tool_done_display(name: &str, partial_input: &str) -> String { + let target = extract_target(partial_input); + let action = match name { + "Read" => "Read", + "Write" => "Wrote", + "Edit" | "MultiEdit" => "Edited", + "Bash" => { + return match target { + Some(cmd) => format!("Ran: {cmd}"), + None => "Ran command".to_string(), + } + } + "Grep" => "Searched", + "Glob" => "Found files", + "LSP" => "Analyzed", + "Task" => "Ran task", + "WebFetch" => "Fetched", + "WebSearch" => "Searched web", + "NotebookEdit" => "Edited notebook", + other => other, + }; + match target { + Some(t) => format!("{action} {t}"), + None => action.to_string(), + } +} + +fn extract_target(partial_json: &str) -> Option { + if let Ok(obj) = serde_json::from_str::>(partial_json) { + if let Some(Value::String(p)) = obj.get("file_path") { + return Some(short_path(p)); + } + if let Some(Value::String(c)) = obj.get("command") { + return Some(truncate(c, 50)); + } + if let Some(Value::String(p)) = obj.get("pattern") { + return Some(format!("/{}/", truncate(p, 30))); + } + } + let re = Regex::new(r#""file_path"\s*:\s*"([^"]+)"#).ok()?; + re.captures(partial_json) + .and_then(|c| c.get(1)) + .map(|m| short_path(m.as_str())) +} + +fn short_path(path: &str) -> String { + let p = std::path::Path::new(path); + let components: Vec<_> = p + .components() + .rev() + .take(2) + .collect::>() + .into_iter() + .rev() + .collect(); + components + .iter() + .map(|c| c.as_os_str().to_string_lossy().to_string()) + .collect::>() + .join("/") +} + +fn truncate(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("{}…", &s[..max]) + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +pub async fn stream_agent_output( + mut child: Child, + repo_root: &Path, +) -> Result { + let stdout = child + .stdout + .take() + .context("agent process stdout not captured")?; + + if let Some(stderr) = child.stderr.take() { + tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("{}", style(&line).dim()); + } + }); + } + + let mut display = AgentStreamDisplay::new(); + let mut lines = BufReader::new(stdout).lines(); + let mut result_json: Option = None; + let mut interrupted = false; + + loop { + tokio::select! { + biased; + _ = tokio::signal::ctrl_c() => { + interrupted = true; + display.finish(); + eprintln!("{}", style("Stopping agent…").dim()); + let _ = child.kill().await; + break; + } + line = lines.next_line() => { + match line? { + Some(line) if line.is_empty() => continue, + Some(line) => match serde_json::from_str::(&line) { + Ok(StreamLine::Result { extra, .. }) => { + result_json = Some(extra); + } + Ok(parsed) => display.handle(parsed), + Err(_) => {} + }, + None => break, + } + } + } + } + + display.finish(); + + if let Some(result) = result_json { + let result_path = repo_root.join(".bt").join("last_instrument.json"); + if let Ok(json) = serde_json::to_string_pretty(&result) { + let _ = std::fs::write(&result_path, json); + } + } + + if interrupted { + use std::process::ExitStatus; + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + return Ok(ExitStatus::from_raw(130)); + } + #[cfg(not(unix))] + { + return Ok(ExitStatus::default()); + } + } + + child.wait().await.context("agent process failed") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_stream_event_text_delta() { + let json = r#"{"type":"stream_event","event":{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}},"session_id":"abc"}"#; + let parsed: StreamLine = serde_json::from_str(json).unwrap(); + match parsed { + StreamLine::StreamEvent { + event: StreamEvent::ContentBlockDelta { index, delta }, + .. + } => { + assert_eq!(index, 0); + match delta { + Delta::TextDelta { text } => assert_eq!(text, "hello"), + _ => panic!("expected TextDelta"), + } + } + _ => panic!("expected StreamEvent"), + } + } + + #[test] + fn parse_stream_event_tool_use_start() { + let json = r#"{"type":"stream_event","event":{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_abc","name":"Read","input":{},"caller":{"type":"direct"}}},"session_id":"abc"}"#; + let parsed: StreamLine = serde_json::from_str(json).unwrap(); + match parsed { + StreamLine::StreamEvent { + event: + StreamEvent::ContentBlockStart { + index, + content_block, + }, + .. + } => { + assert_eq!(index, 1); + match content_block { + ContentBlock::ToolUse { name, .. } => assert_eq!(name, "Read"), + _ => panic!("expected ToolUse"), + } + } + _ => panic!("expected StreamEvent"), + } + } + + #[test] + fn parse_result_line() { + let json = r#"{"type":"result","session_id":"abc","cost":0.05}"#; + let parsed: StreamLine = serde_json::from_str(json).unwrap(); + match parsed { + StreamLine::Result { extra } => { + assert_eq!(extra.get("session_id").unwrap(), "abc"); + } + _ => panic!("expected Result"), + } + } + + #[test] + fn parse_unknown_event_type() { + let json = r#"{"type":"stream_event","event":{"type":"some_future_event","data":123},"session_id":"abc"}"#; + let parsed: StreamLine = serde_json::from_str(json).unwrap(); + match parsed { + StreamLine::StreamEvent { + event: StreamEvent::Unknown, + .. + } => {} + _ => panic!("expected Unknown event"), + } + } + + #[test] + fn parse_unknown_top_level_type() { + let json = r#"{"type":"system","subtype":"hook","data":123}"#; + let parsed: StreamLine = serde_json::from_str(json).unwrap(); + assert!(matches!(parsed, StreamLine::Unknown)); + } + + #[test] + fn extract_target_from_complete_json() { + let json = r#"{"file_path": "/Users/parker/src/app/lib/ai/providers.ts"}"#; + assert_eq!(extract_target(json), Some("ai/providers.ts".to_string())); + } + + #[test] + fn extract_target_from_partial_json() { + let json = r#"{"file_path": "/Users/parker/src/app/lib/ai/providers.ts"#; + assert_eq!(extract_target(json), Some("ai/providers.ts".to_string())); + } + + #[test] + fn extract_target_command() { + let json = r#"{"command": "npm install braintrust"}"#; + assert_eq!( + extract_target(json), + Some("npm install braintrust".to_string()) + ); + } + + #[test] + fn extract_target_pattern() { + let json = r#"{"pattern": "handleAuth"}"#; + assert_eq!(extract_target(json), Some("/handleAuth/".to_string())); + } + + #[test] + fn short_path_extracts_last_two() { + assert_eq!( + short_path("/Users/parker/src/app/lib/ai/providers.ts"), + "ai/providers.ts" + ); + } + + #[test] + fn short_path_single_component() { + assert_eq!(short_path("file.rs"), "file.rs"); + } + + #[test] + fn truncate_long_string() { + assert_eq!(truncate("hello world this is long", 11), "hello world…"); + } + + #[test] + fn truncate_short_string() { + assert_eq!(truncate("short", 10), "short"); + } +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index e5e3c308..a45c23cb 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -19,6 +19,7 @@ use crate::config; use crate::http::ApiClient; use crate::ui::{self, with_spinner}; +mod agent_stream; mod docs; pub use docs::DocsArgs; @@ -140,6 +141,10 @@ struct InstrumentSetupArgs { /// Number of concurrent workers for docs prefetch/download. #[arg(long, default_value_t = crate::sync::default_workers())] workers: usize, + + /// Suppress streaming agent output; show a spinner and print results at the end + #[arg(long, short = 'q')] + quiet: bool, } #[derive(Debug, Clone, Args)] @@ -457,6 +462,7 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { yes: false, refresh_docs: false, workers: crate::sync::default_workers(), + quiet: false, }, ) .await?; @@ -785,7 +791,16 @@ async fn run_instrument_setup(base: BaseArgs, args: InstrumentSetupArgs) -> Resu task_path.display() )); - let status = run_agent_invocation(&root, &invocation, !base.json).await?; + let show_output = !base.json && !args.quiet; + let status = if args.quiet && !base.json { + with_spinner( + "Running agent instrumentation…", + run_agent_invocation(&root, &invocation, false), + ) + .await? + } else { + run_agent_invocation(&root, &invocation, show_output).await? + }; if status.success() { results.push(AgentInstallResult { agent: selected, @@ -827,6 +842,7 @@ async fn run_instrument_setup(base: BaseArgs, args: InstrumentSetupArgs) -> Resu } if !status.success() { + let _ = fs::remove_file(&task_path); bail!("agent instrumentation command failed"); } Ok(()) @@ -983,6 +999,7 @@ enum InstrumentInvocation { args: Vec, stdin_file: Option, prompt_file_arg: Option, + stream_json: bool, }, Shell(String), } @@ -1006,6 +1023,7 @@ fn resolve_instrument_invocation( args: vec!["exec".to_string(), "-".to_string()], stdin_file: Some(task_path.to_path_buf()), prompt_file_arg: None, + stream_json: false, }, Agent::Claude => InstrumentInvocation::Program { program: "claude".to_string(), @@ -1020,12 +1038,14 @@ fn resolve_instrument_invocation( ], stdin_file: Some(task_path.to_path_buf()), prompt_file_arg: None, + stream_json: true, }, Agent::Opencode => InstrumentInvocation::Program { program: "opencode".to_string(), args: vec!["run".to_string()], stdin_file: None, prompt_file_arg: Some(task_path.to_path_buf()), + stream_json: false, }, Agent::Cursor => InstrumentInvocation::Program { program: "cursor-agent".to_string(), @@ -1038,6 +1058,7 @@ fn resolve_instrument_invocation( ], stdin_file: None, prompt_file_arg: Some(task_path.to_path_buf()), + stream_json: true, }, }; Ok(invocation) @@ -1066,6 +1087,7 @@ async fn run_agent_invocation( args, stdin_file, prompt_file_arg, + stream_json, } => { let mut command = Command::new(program); command.args(args).current_dir(root); @@ -1088,11 +1110,24 @@ async fn run_agent_invocation( if !show_output { command.stdout(Stdio::null()).stderr(Stdio::null()); + return command + .status() + .await + .with_context(|| format!("failed to run agent command in {}", root.display())); + } + + if *stream_json { + command.stdout(Stdio::piped()).stderr(Stdio::piped()); + let child = command + .spawn() + .with_context(|| format!("failed to start {program}"))?; + agent_stream::stream_agent_output(child, root).await + } else { + command + .status() + .await + .with_context(|| format!("failed to run agent command in {}", root.display())) } - command - .status() - .await - .with_context(|| format!("failed to run agent command in {}", root.display())) } } } @@ -2638,6 +2673,7 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), + quiet: false, }; let selected = @@ -2657,6 +2693,7 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), + quiet: false, }; let selected = @@ -2691,11 +2728,13 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "codex"); assert_eq!(args, vec!["exec".to_string(), "-".to_string()]); assert_eq!(stdin_file, Some(task_path)); assert_eq!(prompt_file_arg, None); + assert!(!stream_json); } InstrumentInvocation::Shell(_) => panic!("expected program invocation"), } @@ -2713,6 +2752,7 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "claude"); assert_eq!( @@ -2729,6 +2769,7 @@ mod tests { ); assert_eq!(stdin_file, Some(task_path)); assert_eq!(prompt_file_arg, None); + assert!(stream_json); } InstrumentInvocation::Shell(_) => panic!("expected program invocation"), } @@ -2746,11 +2787,13 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "opencode"); assert_eq!(args, vec!["run".to_string()]); assert_eq!(stdin_file, None); assert_eq!(prompt_file_arg, Some(task_path)); + assert!(!stream_json); } InstrumentInvocation::Shell(_) => panic!("expected program invocation"), } @@ -2768,6 +2811,7 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "cursor-agent"); assert_eq!( @@ -2782,6 +2826,7 @@ mod tests { ); assert_eq!(stdin_file, None); assert_eq!(prompt_file_arg, Some(task_path)); + assert!(stream_json); } InstrumentInvocation::Shell(_) => panic!("expected program invocation"), } From 5cc009323fb560460a731912b6e940b824ac073b Mon Sep 17 00:00:00 2001 From: Parker Henderson Date: Tue, 24 Feb 2026 23:04:28 -0800 Subject: [PATCH 5/5] refactor(auth): extract profile commit logic into helper functions --- src/auth.rs | 169 +++++++++++++++++++------------------- src/setup/agent_stream.rs | 5 +- src/setup/mod.rs | 23 ++++-- 3 files changed, 101 insertions(+), 96 deletions(-) diff --git a/src/auth.rs b/src/auth.rs index 21fe0815..1ff62554 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -597,7 +597,6 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), interactive)?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let mut store = load_auth_store()?; let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), @@ -619,28 +618,13 @@ async fn run_login_set(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { } } - save_profile_secret(&profile_name, &api_key)?; - let _ = delete_profile_oauth_refresh_token(&profile_name); - let _ = delete_profile_oauth_access_token(&profile_name); - - let stored_api_url = Some(selected_api_url.clone()); - let stored_app_url = base.app_url.clone(); - - store.profiles.insert( - profile_name.to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: stored_api_url, - app_url: stored_app_url, - org_name: selected_org.as_ref().map(|org| org.name.clone()), - oauth_client_id: None, - oauth_access_expires_at: None, - user_name: None, - email: None, - api_key_hint: Some(obscure_api_key(&api_key)), - }, - ); - save_auth_store(&store)?; + commit_api_key_profile( + &profile_name, + &api_key, + selected_api_url.clone(), + base.app_url.clone(), + selected_org.as_ref().map(|org| org.name.clone()), + )?; if let Some(org) = selected_org.as_ref() { ui::print_command_status( @@ -747,7 +731,6 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { let selected_org = select_login_org(login_orgs.clone(), base.org_name.as_deref(), true)?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let mut store = load_auth_store()?; let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), @@ -767,32 +750,14 @@ async fn run_login_oauth(base: &BaseArgs, args: AuthLoginArgs) -> Result<()> { bail!("login cancelled"); } - let refresh_token = oauth_tokens.refresh_token.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "oauth token response did not include a refresh_token; cannot create persistent oauth profile" - ) - })?; - save_profile_oauth_refresh_token(&profile_name, refresh_token)?; - save_profile_oauth_access_token(&profile_name, &oauth_tokens.access_token)?; - let _ = delete_profile_secret(&profile_name); - let oauth_access_expires_at = determine_oauth_access_expiry_epoch(&oauth_tokens); - let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); - - store.profiles.insert( - profile_name.to_string(), - AuthProfile { - auth_kind: AuthKind::Oauth, - api_url: Some(selected_api_url.clone()), - app_url: Some(app_url.clone()), - org_name: selected_org.as_ref().map(|org| org.name.clone()), - oauth_client_id: Some(client_id.clone()), - oauth_access_expires_at, - user_name: jwt_id.name, - email: jwt_id.email, - api_key_hint: None, - }, - ); - save_auth_store(&store)?; + commit_oauth_profile( + &profile_name, + &oauth_tokens, + selected_api_url.clone(), + app_url.clone(), + client_id.clone(), + selected_org.as_ref().map(|org| org.name.clone()), + )?; if let Some(org) = selected_org.as_ref() { ui::print_command_status( @@ -838,33 +803,19 @@ async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { let selected_org = select_login_org_simple(login_orgs.clone(), base.org_name.as_deref())?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let mut store = load_auth_store()?; - // Auto-generate profile name from org (no prompt) let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), false, )?; - save_profile_secret(&profile_name, &api_key)?; - let _ = delete_profile_oauth_refresh_token(&profile_name); - let _ = delete_profile_oauth_access_token(&profile_name); - - store.profiles.insert( - profile_name.to_string(), - AuthProfile { - auth_kind: AuthKind::ApiKey, - api_url: Some(selected_api_url), - app_url: base.app_url.clone(), - org_name: selected_org.as_ref().map(|org| org.name.clone()), - oauth_client_id: None, - oauth_access_expires_at: None, - user_name: None, - email: None, - api_key_hint: Some(obscure_api_key(&api_key)), - }, - ); - save_auth_store(&store)?; + commit_api_key_profile( + &profile_name, + &api_key, + selected_api_url, + base.app_url.clone(), + selected_org.as_ref().map(|org| org.name.clone()), + )?; base.profile = Some(profile_name.clone()); Ok(profile_name) @@ -934,36 +885,85 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { .await?; let login_orgs = fetch_login_orgs(&oauth_tokens.access_token, &app_url).await?; - // Wizard uses simplified org selector (names only, no UUIDs/URLs) let selected_org = select_login_org_simple(login_orgs.clone(), base.org_name.as_deref())?; let selected_api_url = resolve_profile_api_url(base.api_url.clone(), selected_org.as_ref(), &login_orgs)?; - let mut store = load_auth_store()?; - // Auto-generate profile name from org (no prompt) let profile_name = resolve_profile_name( base.profile.as_deref(), selected_org.as_ref().map(|org| org.name.as_str()), false, )?; - let refresh_token = oauth_tokens.refresh_token.as_ref().ok_or_else(|| { + commit_oauth_profile( + &profile_name, + &oauth_tokens, + selected_api_url, + app_url, + client_id, + selected_org.as_ref().map(|org| org.name.clone()), + )?; + + base.profile = Some(profile_name.clone()); + Ok(profile_name) +} + +fn commit_api_key_profile( + profile_name: &str, + api_key: &str, + api_url: String, + app_url: Option, + org_name: Option, +) -> Result<()> { + save_profile_secret(profile_name, api_key)?; + let _ = delete_profile_oauth_refresh_token(profile_name); + let _ = delete_profile_oauth_access_token(profile_name); + + let mut store = load_auth_store()?; + store.profiles.insert( + profile_name.to_string(), + AuthProfile { + auth_kind: AuthKind::ApiKey, + api_url: Some(api_url), + app_url, + org_name, + oauth_client_id: None, + oauth_access_expires_at: None, + user_name: None, + email: None, + api_key_hint: Some(obscure_api_key(api_key)), + }, + ); + save_auth_store(&store) +} + +fn commit_oauth_profile( + profile_name: &str, + tokens: &OAuthTokenResponse, + api_url: String, + app_url: String, + client_id: String, + org_name: Option, +) -> Result<()> { + let refresh_token = tokens.refresh_token.as_ref().ok_or_else(|| { anyhow::anyhow!( "oauth token response did not include a refresh_token; cannot create persistent oauth profile" ) })?; - save_profile_oauth_refresh_token(&profile_name, refresh_token)?; - save_profile_oauth_access_token(&profile_name, &oauth_tokens.access_token)?; - let _ = delete_profile_secret(&profile_name); - let oauth_access_expires_at = determine_oauth_access_expiry_epoch(&oauth_tokens); - let jwt_id = decode_jwt_identity(&oauth_tokens.access_token); + save_profile_oauth_refresh_token(profile_name, refresh_token)?; + save_profile_oauth_access_token(profile_name, &tokens.access_token)?; + let _ = delete_profile_secret(profile_name); + + let oauth_access_expires_at = determine_oauth_access_expiry_epoch(tokens); + let jwt_id = decode_jwt_identity(&tokens.access_token); + let mut store = load_auth_store()?; store.profiles.insert( profile_name.to_string(), AuthProfile { auth_kind: AuthKind::Oauth, - api_url: Some(selected_api_url), + api_url: Some(api_url), app_url: Some(app_url), - org_name: selected_org.as_ref().map(|org| org.name.clone()), + org_name, oauth_client_id: Some(client_id), oauth_access_expires_at, user_name: jwt_id.name, @@ -971,10 +971,7 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { api_key_hint: None, }, ); - save_auth_store(&store)?; - - base.profile = Some(profile_name.clone()); - Ok(profile_name) + save_auth_store(&store) } fn oauth_ignored_api_key_warning(base: &BaseArgs) -> Option { diff --git a/src/setup/agent_stream.rs b/src/setup/agent_stream.rs index 4b62a9bd..c7b6de43 100644 --- a/src/setup/agent_stream.rs +++ b/src/setup/agent_stream.rs @@ -342,10 +342,11 @@ fn short_path(path: &str) -> String { } fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { + if s.chars().count() <= max { s.to_string() } else { - format!("{}…", &s[..max]) + let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len()); + format!("{}…", &s[..end]) } } diff --git a/src/setup/mod.rs b/src/setup/mod.rs index a45c23cb..7bde4ea0 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -475,6 +475,9 @@ async fn run_setup_wizard(mut base: BaseArgs) -> Result<()> { // ── Done ── print_wizard_done(had_failures); + if had_failures { + bail!("setup completed with failures"); + } Ok(()) } @@ -831,14 +834,14 @@ async fn run_instrument_setup(base: BaseArgs, args: InstrumentSetupArgs) -> Resu serde_json::to_string_pretty(&report).context("failed to serialize setup report")? ); } else { - print_human_report( - true, - InstallScope::Local, - &[selected], - &results, - &warnings, - ¬es, - ); + eprintln!(); + for result in &results { + print_wizard_agent_result(result); + } + for warning in &warnings { + eprintln!(" {} {}", style("!").dim(), style(warning).dim()); + } + print_wizard_done(!status.success()); } if !status.success() { @@ -1035,6 +1038,8 @@ fn resolve_instrument_invocation( "--output-format".to_string(), "stream-json".to_string(), "--include-partial-messages".to_string(), + "--disallowedTools".to_string(), + "EnterPlanMode".to_string(), ], stdin_file: Some(task_path.to_path_buf()), prompt_file_arg: None, @@ -2765,6 +2770,8 @@ mod tests { "--output-format".to_string(), "stream-json".to_string(), "--include-partial-messages".to_string(), + "--disallowedTools".to_string(), + "EnterPlanMode".to_string(), ] ); assert_eq!(stdin_file, Some(task_path));