diff --git a/src/auth.rs b/src/auth.rs index fe253e14..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( @@ -816,6 +781,199 @@ 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_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 profile_name = resolve_profile_name( + base.profile.as_deref(), + selected_org.as_ref().map(|org| org.name.as_str()), + false, + )?; + + 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) +} + +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(); + + 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 { + 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_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 profile_name = resolve_profile_name( + base.profile.as_deref(), + selected_org.as_ref().map(|org| org.name.as_str()), + false, + )?; + + 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, &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(api_url), + app_url: Some(app_url), + org_name, + 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) +} + fn oauth_ignored_api_key_warning(base: &BaseArgs) -> Option { let api_key = base.api_key.as_deref()?.trim(); if api_key.is_empty() { @@ -1395,6 +1553,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>, @@ -1530,8 +1731,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/agent_stream.rs b/src/setup/agent_stream.rs new file mode 100644 index 00000000..c7b6de43 --- /dev/null +++ b/src/setup/agent_stream.rs @@ -0,0 +1,562 @@ +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.chars().count() <= max { + s.to_string() + } else { + let end = s.char_indices().nth(max).map(|(i, _)| i).unwrap_or(s.len()); + format!("{}…", &s[..end]) + } +} + +// --------------------------------------------------------------------------- +// 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 ac7ab2a4..7bde4ea0 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -6,14 +6,20 @@ use std::process::Stdio; use anyhow::{anyhow, bail, Context, Result}; use clap::{Args, Subcommand, ValueEnum}; -use dialoguer::{theme::ColorfulTheme, 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; 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 agent_stream; mod docs; pub use docs::DocsArgs; @@ -135,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)] @@ -176,6 +186,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)] @@ -320,40 +339,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,8 +349,226 @@ 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 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 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)? { + eprintln!(" {} Linked to {}/{}", style("✓").green(), org, project); + } + } else { + eprintln!(" {}", style("Skipped").dim()); + } + + // ── 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()) + .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 + }; + + 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 { + 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; + } + } + } + } + + 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); + 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; + } + } + } + + if !wants_skills && !wants_mcp { + eprintln!(" {}", style("Skipped").dim()); + } + + // ── 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?") + .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(), + quiet: false, + }, + ) + .await?; + } else { + eprintln!(" {}", style("Skipped").dim()); + } + } else { + eprintln!(" {}", style("Skipped").dim()); + } + + // ── Done ── + print_wizard_done(had_failures); + if had_failures { + bail!("setup completed with failures"); + } + 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]; + 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())) + } +} + +/// 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(true); + } + let update = Confirm::new() + .with_prompt(format!("Update .bt/config.json to {org}/{project}?")) + .default(true) + .interact()?; + if !update { + return Ok(false); + } + } + + let cfg = config::Config { + org: Some(org.to_string()), + project: Some(project.to_string()), + ..Default::default() + }; + config::save_local(&cfg, true)?; + Ok(true) +} + +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 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(), @@ -399,6 +603,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)?; @@ -410,7 +615,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"); @@ -483,14 +688,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 +710,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(|| { @@ -591,7 +768,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); @@ -617,7 +794,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, @@ -648,17 +834,18 @@ 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() { + let _ = fs::remove_file(&task_path); bail!("agent instrumentation command failed"); } Ok(()) @@ -815,6 +1002,7 @@ enum InstrumentInvocation { args: Vec, stdin_file: Option, prompt_file_arg: Option, + stream_json: bool, }, Shell(String), } @@ -838,6 +1026,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(), @@ -849,15 +1038,19 @@ 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, + 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(), @@ -870,6 +1063,7 @@ fn resolve_instrument_invocation( ], stdin_file: None, prompt_file_arg: Some(task_path.to_path_buf()), + stream_json: true, }, }; Ok(invocation) @@ -898,6 +1092,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); @@ -920,11 +1115,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())) } } } @@ -956,19 +1164,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) @@ -994,13 +1206,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!( @@ -1009,10 +1238,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"); } @@ -1417,7 +1646,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) @@ -2156,6 +2385,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, @@ -2413,6 +2678,7 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), + quiet: false, }; let selected = @@ -2432,6 +2698,7 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), + quiet: false, }; let selected = @@ -2466,11 +2733,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"), } @@ -2488,6 +2757,7 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "claude"); assert_eq!( @@ -2500,10 +2770,13 @@ 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)); assert_eq!(prompt_file_arg, None); + assert!(stream_json); } InstrumentInvocation::Shell(_) => panic!("expected program invocation"), } @@ -2521,11 +2794,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"), } @@ -2543,6 +2818,7 @@ mod tests { args, stdin_file, prompt_file_arg, + stream_json, } => { assert_eq!(program, "cursor-agent"); assert_eq!( @@ -2557,6 +2833,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"), }