diff --git a/src/args.rs b/src/args.rs index 3748e8b..ce39887 100644 --- a/src/args.rs +++ b/src/args.rs @@ -10,9 +10,9 @@ pub struct BaseArgs { #[arg(long, global = true)] pub json: bool, - /// Suppress non-essential output - #[arg(long, short = 'q', env = "BRAINTRUST_QUIET", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] - pub quiet: bool, + /// Verbose mode — set at runtime by subcommands that support it + #[arg(skip)] + pub verbose: bool, /// Disable ANSI color output #[arg(long, env = "BRAINTRUST_NO_COLOR", global = true, value_parser = clap::builder::BoolishValueParser::new(), default_value_t = false)] @@ -44,10 +44,6 @@ pub struct BaseArgs { #[arg(long, global = true)] pub prefer_profile: bool, - /// Disable all interactive prompts - #[arg(long, global = true)] - pub no_input: bool, - /// Override API URL (or via BRAINTRUST_API_URL) #[arg( long, diff --git a/src/auth.rs b/src/auth.rs index 384fd98..7968c29 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -779,17 +779,7 @@ 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 - } -} - +#[allow(dead_code)] async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { let api_key = prompt_api_key()?; @@ -825,7 +815,7 @@ async fn login_interactive_api_key(base: &mut BaseArgs) -> Result { Ok(profile_name) } -async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { +pub(crate) async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { let api_url = base .api_url .clone() @@ -863,7 +853,9 @@ async fn login_interactive_oauth(base: &mut BaseArgs) -> Result { let _ = open::that(&authorize_url); eprintln!("Complete authorization in your browser."); + eprintln!(); eprintln!("{}", dialoguer::console::style(&authorize_url).dim()); + eprintln!(); let callback = collect_oauth_callback(listener, is_ssh_session()).await?; if let Some(error) = callback.error { @@ -1717,6 +1709,7 @@ async fn collect_oauth_callback( let pasted = Input::::new() .with_prompt("Callback URL/query/JSON (press Enter to wait for automatic callback)") .allow_empty(true) + .report(false) .interact_text() .context("failed to read callback URL")?; if pasted.trim().is_empty() { @@ -2580,14 +2573,13 @@ mod tests { fn make_base() -> BaseArgs { BaseArgs { json: false, - quiet: false, + verbose: false, no_color: false, profile: None, project: None, org_name: None, api_key: None, prefer_profile: false, - no_input: false, api_url: None, app_url: None, env_file: None, diff --git a/src/functions/push.rs b/src/functions/push.rs index 12862a3..0a14c2c 100644 --- a/src/functions/push.rs +++ b/src/functions/push.rs @@ -3359,14 +3359,13 @@ mod tests { fn test_base_args() -> BaseArgs { BaseArgs { json: false, - quiet: false, + verbose: false, no_color: false, profile: None, org_name: None, project: None, api_key: None, prefer_profile: false, - no_input: false, api_url: None, app_url: None, env_file: None, diff --git a/src/main.rs b/src/main.rs index db7760b..3d9690c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -80,7 +80,6 @@ Flags --profile Use a saved login profile [env: BRAINTRUST_PROFILE] -o, --org Override active org [env: BRAINTRUST_ORG_NAME] -p, --project Override active project [env: BRAINTRUST_DEFAULT_PROJECT] - -q, --quiet Suppress non-essential output --json Output as JSON --no-color Disable ANSI color output --no-input Disable all interactive prompts @@ -249,14 +248,8 @@ fn configure_output(base: &BaseArgs) { ui::set_animations_enabled(false); } - if base.quiet { - ui::set_quiet(true); - ui::set_animations_enabled(false); - } - - if base.no_input { - ui::set_no_input(true); - } + ui::set_quiet(true); + ui::set_animations_enabled(false); if disable_color { dialoguer::console::set_colors_enabled(false); diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 37f4ed9..0af592e 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -53,21 +53,47 @@ pub struct SetupArgs { #[command(subcommand)] command: Option, - /// Set up coding-agent skills (skips interactive selection in wizard) - #[arg(long)] + /// Set up coding-agent skills [default] + #[arg(long, conflicts_with = "no_skills")] skills: bool, - /// Set up MCP server (skips interactive selection in wizard) - #[arg(long)] + /// Do not set up coding-agent skills + #[arg(long, conflicts_with = "skills")] + no_skills: bool, + + /// Set up MCP server [default] + #[arg(long, conflicts_with = "no_mcp")] mcp: bool, - /// Run instrumentation agent (skips interactive prompt in wizard) + /// Do not set up MCP server + #[arg(long, conflicts_with = "mcp")] + no_mcp: bool, + + /// Run instrumentation agent [default] #[arg(long)] instrument: bool, - /// Skip skills and MCP setup (skips interactive selection in wizard) - #[arg(long, conflicts_with_all = ["skills", "mcp"])] - no_mcp_skill: bool, + /// Do not run instrumentation agent + #[arg(long, conflicts_with = "instrument")] + no_instrument: bool, + + /// Run the agent in interactive TUI mode [default] + #[arg(long, conflicts_with = "background")] + tui: bool, + + /// Run the agent in background (non-interactive) mode + #[arg(long, conflicts_with = "tui")] + background: bool, + + /// Language(s) to instrument (repeatable; case-insensitive). + /// When provided, the agent skips language auto-detection and instruments + /// the specified language(s) directly. + #[arg(long = "language", value_enum, ignore_case = true)] + languages: Vec, + + /// Show additional setup output + #[arg(long, short = 'v')] + verbose: bool, #[command(flatten)] agents: AgentsSetupArgs, @@ -87,24 +113,23 @@ enum SetupSubcommand { #[derive(Debug, Clone, Args)] struct AgentsSetupArgs { - /// Agent(s) to configure (repeatable) - #[arg(long = "agent", value_enum)] - agents: Vec, + /// Agent to configure + #[arg(long, value_enum)] + agent: Option, /// Configure the current git repo root #[arg(long, conflicts_with = "global")] local: bool, - /// Configure user-wide state + /// Configure user-wide state [default] #[arg(long)] global: bool, - /// Workflow docs to prefetch (repeatable) + /// Workflow docs to prefetch (repeatable) [default: all] #[arg(long = "workflow", value_enum)] workflows: Vec, - /// Skip confirmation prompts and use defaults - #[arg(long, short = 'y')] + #[arg(skip)] yes: bool, /// Do not auto-fetch workflow docs during setup @@ -119,28 +144,26 @@ struct AgentsSetupArgs { #[arg(long, default_value_t = crate::sync::default_workers())] workers: usize, - /// Grant the agent full permissions and run it in the background without prompting. - /// Equivalent to choosing "Background" with all tool restrictions lifted. + /// Grant the agent full permissions (bypass permission prompts) #[arg(long)] yolo: bool, } #[derive(Debug, Clone, Args)] struct AgentsMcpSetupArgs { - /// Agent(s) to configure MCP for (repeatable) - #[arg(long = "agent", value_enum)] - agents: Vec, + /// Agent to configure MCP for + #[arg(long, value_enum)] + agent: Option, /// Configure MCP in the current git repo root #[arg(long, conflicts_with = "global")] local: bool, - /// Configure MCP in user-wide state + /// Configure MCP in user-wide state [default] #[arg(long)] global: bool, - /// Skip confirmation prompts and use defaults - #[arg(long, short = 'y')] + #[arg(skip)] yes: bool, } @@ -154,12 +177,11 @@ struct InstrumentSetupArgs { #[arg(long)] agent_cmd: Option, - /// Workflow docs to prefetch alongside instrument (repeatable; always includes instrument) + /// Workflow docs to prefetch alongside instrument (repeatable; always includes instrument) [default: all] #[arg(long = "workflow", value_enum)] workflows: Vec, - /// Skip confirmation prompts and use defaults - #[arg(long, short = 'y')] + #[arg(skip)] yes: bool, /// Refresh prefetched docs by clearing existing output before download @@ -170,10 +192,6 @@ struct InstrumentSetupArgs { #[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, - /// Language(s) to instrument (repeatable; case-insensitive). /// When provided, the agent skips language auto-detection and instruments /// the specified language(s) directly. @@ -181,14 +199,16 @@ struct InstrumentSetupArgs { #[arg(long = "language", value_enum, ignore_case = true)] languages: Vec, - /// Run the agent in interactive mode: inherits the terminal so the user can - /// approve/deny tool uses directly. Conflicts with --quiet and --yolo. - #[arg(long, short = 'i', conflicts_with_all = ["quiet", "yolo"])] - interactive: bool, + /// Run the agent in interactive TUI mode [default] + #[arg(long, conflicts_with = "background")] + tui: bool, - /// Grant the agent full permissions and run it in the background without prompting. - /// Skips the run-mode selection question. Conflicts with --interactive. - #[arg(long, conflicts_with = "interactive")] + /// Run the agent in background (non-interactive) mode + #[arg(long, conflicts_with = "tui")] + background: bool, + + /// Grant the agent full permissions (bypass permission prompts) + #[arg(long)] yolo: bool, } @@ -198,7 +218,7 @@ struct AgentsDoctorArgs { #[arg(long, conflicts_with = "global")] local: bool, - /// Diagnose user-wide setup + /// Diagnose user-wide setup [default] #[arg(long)] global: bool, } @@ -210,7 +230,6 @@ enum AgentArg { Codex, Cursor, Opencode, - All, } #[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Serialize)] @@ -374,7 +393,12 @@ struct SkillsAliasResult { path: PathBuf, } -pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { +pub async fn run_setup_top(mut base: BaseArgs, args: SetupArgs) -> Result<()> { + if args.verbose { + base.verbose = true; + crate::ui::set_quiet(false); + crate::ui::set_animations_enabled(true); + } match args.command { Some(SetupSubcommand::Skills(setup)) => run_setup(base, setup).await, Some(SetupSubcommand::Instrument(instrument)) => { @@ -388,12 +412,17 @@ pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { yolo: args.agents.yolo, skills: args.skills, mcp: args.mcp, + no_skills: args.no_skills, + no_mcp: args.no_mcp, local: args.agents.local, global: args.agents.global, instrument: args.instrument, - agents: args.agents.agents, - no_mcp_skill: args.no_mcp_skill, + no_instrument: args.no_instrument, + tui: args.tui, + background: args.background, + agent: args.agents.agent, workflows: args.agents.workflows, + languages: args.languages, }; run_setup_wizard(base, wizard_flags).await } else { @@ -409,12 +438,17 @@ struct WizardFlags { yolo: bool, skills: bool, mcp: bool, + no_skills: bool, + no_mcp: bool, local: bool, global: bool, instrument: bool, - agents: Vec, - no_mcp_skill: bool, + no_instrument: bool, + tui: bool, + background: bool, + agent: Option, workflows: Vec, + languages: Vec, } async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> { @@ -422,166 +456,116 @@ async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> yolo, skills: flag_skills, mcp: flag_mcp, + no_skills: flag_no_skills, + no_mcp: flag_no_mcp, local: flag_local, - global: flag_global, - instrument: flag_instrument, - agents: flag_agents, - no_mcp_skill: flag_no_mcp_skill, + global: _flag_global, + instrument: _flag_instrument, + no_instrument: flag_no_instrument, + tui: flag_tui, + background: flag_background, + agent: flag_agent, workflows: flag_workflows, + languages: flag_languages, } = flags; let mut had_failures = false; - let quiet = base.quiet; + let verbose = base.verbose; + let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; + let git_root = find_git_root(); // ── Step 1: Auth ── - if !quiet { + if verbose { print_wizard_step(1, "Auth"); } let project_flag = base.project.clone(); let login_ctx = ensure_auth(&mut base).await?; let client = ApiClient::new(&login_ctx)?; let org = client.org_name().to_string(); - if !quiet { + if verbose { eprintln!(" {} Using org '{}'", style("✓").green(), org); } // ── Step 2: Project ── - if !quiet { + if verbose { print_wizard_step(2, "Project"); } - let project = select_project_with_skip(&client, project_flag.as_deref(), quiet).await?; - if !quiet { - if let Some(ref project) = project { - if find_git_root().is_some() && maybe_init(&org, project)? { - eprintln!( - " {} Linked to {}/{}", - style("✓").green(), - org, - project.name - ); - } - } else { - eprintln!(" {}", style("Skipped").dim()); + let project = auto_select_project(&client, project_flag.as_deref()).await?; + if let Some(ref project) = project { + if git_root.is_some() { + let _ = maybe_init(&org, project); } - } else if let Some(ref project) = project { - if find_git_root().is_some() { - let _ = maybe_init(&org, project)?; + if verbose { + eprintln!( + " {} Project: {}/{}", + style("✓").green(), + org, + project.name + ); } + } else if verbose { + eprintln!( + " {} {}", + style("—").dim(), + style("Using existing project(s)").dim() + ); } // ── Step 3: Agent tools (skills + MCP) ── - if !quiet { + if verbose { print_wizard_step(3, "Agents"); } - let mut multiselect_hint_shown = false; - let (wants_skills, wants_mcp) = if flag_no_mcp_skill { - if !quiet { - eprintln!( - "{} What would you like to set up? · {}", - style("✔").green(), - style("(none)").dim() - ); - } - (false, false) - } else if flag_skills || flag_mcp { - if !quiet { - let chosen: Vec<&str> = [("Skills", flag_skills), ("MCP", flag_mcp)] - .iter() - .filter(|(_, v)| *v) - .map(|(s, _)| *s) - .collect(); - let chosen_styled: Vec = chosen - .iter() - .map(|s| style(s).green().to_string()) - .collect(); - eprintln!( - "{} What would you like to set up? · {}", - style("✔").green(), - chosen_styled.join(", ") - ); - } - (flag_skills, flag_mcp) + // Default: install both skills and MCP with global scope. + // --no-skill / --no-mcp / --no-mcp-skill opt out. + let _ = flag_skills; // kept as flag but superseded by default-on behavior + let _ = flag_mcp; // kept as flag but superseded by default-on behavior + let wants_skills = !flag_no_skills; + let wants_mcp = !flag_no_mcp; + + // Scope: default global; --local overrides. + let scope = if flag_local { + InstallScope::Local } else { - if !quiet { - eprintln!( - " {}", - style("(Un)select option with Space, confirm selection with Enter.").dim() - ); - multiselect_hint_shown = true; - } - 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()?; - (selected.contains(&0), selected.contains(&1)) + InstallScope::Global }; - let setup_context = if wants_skills || wants_mcp { - let scope = if flag_local { - if !quiet { - eprintln!( - "{} Select install scope · {}", - style("✔").green(), - style("local (current git repo)").green() - ); - } - InstallScope::Local - } else if flag_global { - if !quiet { - eprintln!( - "{} Select install scope · {}", - style("✔").green(), - style("global (user-wide)").green() - ); - } - InstallScope::Global + let local_root = resolve_local_root_for_scope(scope)?; + let detected = detect_agents(local_root.as_deref(), &home); + let agents = resolve_selected_agents(flag_agent, &detected); + + if verbose { + let scope_label = if flag_local { + "local (current git repo)" } else { - prompt_scope_selection("Select install scope")? - .ok_or_else(|| anyhow!("setup cancelled"))? + "global (user-wide)" }; - 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 agents = resolve_selected_agents(&flag_agents, &detected); - if !quiet && !flag_agents.is_empty() { - let agent_names: Vec = agents - .iter() - .map(|a| style(a.as_str()).green().to_string()) - .collect(); - eprintln!( - "{} Select agents to configure · {}", - style("✔").green(), - agent_names.join(", ") - ); - } - Some((scope, agents, home)) + eprintln!(" {} Scope: {}", style("✓").green(), scope_label); + } + + let setup_context = if wants_skills || wants_mcp { + Some((scope, agents.clone())) } else { None }; if wants_skills { - if !quiet { + if verbose { eprintln!(" {}", style("Skills:").bold()); } - if let Some((scope, ref agents, _)) = setup_context { - let agent_args: Vec = - agents.iter().map(|a| map_agent_to_agent_arg(*a)).collect(); + if let Some((scope, _)) = setup_context { let args = AgentsSetupArgs { - agents: agent_args, + agent: flag_agent, local: matches!(scope, InstallScope::Local), global: matches!(scope, InstallScope::Global), workflows: Vec::new(), - yes: false, + yes: true, no_fetch_docs: true, refresh_docs: false, workers: crate::sync::default_workers(), yolo: false, }; - let outcome = execute_skills_setup(&base, &args, true).await?; + let outcome = execute_skills_setup(&base, &args).await?; for r in &outcome.results { - if !quiet { + if verbose { print_wizard_agent_result(r); } if matches!(r.status, InstallStatus::Failed) { @@ -592,14 +576,14 @@ async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> } if wants_mcp { - if !quiet { + if verbose { eprintln!(" {}", style("MCP:").bold()); } - if let Some((scope, ref agents, ref home)) = setup_context { + if let Some((scope, ref agents)) = setup_context { let local_root = resolve_local_root_for_scope(scope)?; - let outcome = execute_mcp_install(scope, local_root.as_deref(), home, agents); + let outcome = execute_mcp_install(scope, local_root.as_deref(), &home, agents); for r in &outcome.results { - if !quiet { + if verbose { print_wizard_agent_result(r); } if matches!(r.status, InstallStatus::Failed) { @@ -612,66 +596,99 @@ async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> } } - if !wants_skills && !wants_mcp && !quiet { + if !wants_skills && !wants_mcp && verbose { eprintln!(" {}", style("Skipped").dim()); } // ── Step 4: Instrument ── - if !quiet { + if verbose { print_wizard_step(4, "Instrument"); } - if find_git_root().is_some() { - let instrument = if flag_instrument { - if !quiet { - eprintln!( - "Run instrumentation agent to set up tracing in this repo? {}", - style("yes").green() - ); - } - true - } else { - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Run instrumentation agent to set up tracing in this repo?") - .default(false) - .interact()? - }; - if instrument { - let instrument_agent = match flag_agents.as_slice() { - [single] => match single { - AgentArg::Claude => Some(InstrumentAgentArg::Claude), - AgentArg::Codex => Some(InstrumentAgentArg::Codex), - AgentArg::Cursor => Some(InstrumentAgentArg::Cursor), - AgentArg::Opencode => Some(InstrumentAgentArg::Opencode), - AgentArg::All => None, - }, - _ => None, + if git_root.is_some() { + // Default: instrument. --no-instrument opts out. + let do_instrument = !flag_no_instrument; + if do_instrument { + // Determine agent: explicit flag > single detected > ask user + let instrument_agent = + determine_wizard_instrument_agent(flag_agent, git_root.as_deref(), &home); + + let instrument_agent = if instrument_agent.is_none() && ui::is_interactive() { + // Multiple or zero detected agents — ask the user + let detected2 = detect_agents(git_root.as_deref(), &home); + let detected_set: BTreeSet = detected2.iter().map(|s| s.agent).collect(); + let candidates: Vec = if detected_set.is_empty() { + ALL_AGENTS.to_vec() + } else { + detected_set.into_iter().collect() + }; + let default = pick_agent_mode_target(&candidates).unwrap_or(Agent::Claude); + let selected = prompt_instrument_agent(default)?; + Some(match selected { + Agent::Claude => InstrumentAgentArg::Claude, + Agent::Codex => InstrumentAgentArg::Codex, + Agent::Cursor => InstrumentAgentArg::Cursor, + Agent::Opencode => InstrumentAgentArg::Opencode, + }) + } else { + instrument_agent + }; + + // Resolve languages: explicit flag > auto-detect from git root > prompt. + let detected_languages = if !flag_languages.is_empty() { + flag_languages.clone() + } else { + let auto = git_root + .as_deref() + .map(detect_languages_from_dir) + .unwrap_or_default(); + if !auto.is_empty() || !ui::is_interactive() { + auto + } else { + // Could not auto-detect and we have a TTY — ask the user. + prompt_instrument_language_selection(&[])?.unwrap_or_default() + } + }; + + // Default workflows: instrument + observe. --workflow flags override. + let wizard_workflows = if flag_workflows.is_empty() { + vec![WorkflowArg::Instrument, WorkflowArg::Observe] + } else { + let mut selected = resolve_workflow_selection(&flag_workflows); + if !selected.contains(&WorkflowArg::Instrument) { + selected.push(WorkflowArg::Instrument); + selected.sort(); + selected.dedup(); + } + selected }; + run_instrument_setup( base, InstrumentSetupArgs { agent: instrument_agent, agent_cmd: None, - workflows: flag_workflows, - yes: false, + workflows: wizard_workflows, + // yes=true suppresses remaining prompts; all decisions are made above. + yes: true, refresh_docs: false, workers: crate::sync::default_workers(), - quiet: false, - languages: Vec::new(), - interactive: false, + languages: detected_languages, + tui: flag_tui || !flag_background, + background: flag_background, yolo, }, - !multiselect_hint_shown, + false, ) .await?; - } else if !quiet { + } else if verbose { eprintln!(" {}", style("Skipped").dim()); } - } else if !quiet { + } else if verbose { eprintln!(" {}", style("Skipped").dim()); } // ── Done ── - if !quiet { + if verbose { print_wizard_done(had_failures); } if had_failures { @@ -681,78 +698,54 @@ async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> } async fn ensure_auth(base: &mut BaseArgs) -> Result { - if base.api_key.is_some() { + // 1. --api-key flag (or BRAINTRUST_API_KEY env) without --prefer-profile: use directly. + if base.api_key.is_some() && !base.prefer_profile { return auth::login(base).await; } + // 2. --prefer-profile or no api key: try stored profiles first. 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, - project_name: Option<&str>, - quiet: bool, -) -> Result> { - if let Some(name) = project_name { - let project = with_spinner( - "Loading project...", - crate::projects::api::get_project_by_name(client, name), - ) - .await?; - match project { - Some(p) => { - if !quiet { - eprintln!("{} Select project · {}", style("✔").green(), p.name); + if !profiles.is_empty() { + let profile_name = if let Some(ref p) = base.profile { + p.clone() + } else if profiles.len() == 1 { + profiles[0].name.clone() + } else { + auth::select_profile_interactive(None)?.ok_or_else(|| anyhow!("no profile selected"))? + }; + base.profile = Some(profile_name.clone()); + + // Try to login; if credentials are missing/inaccessible, re-auth via OAuth. + match auth::login(base).await { + Ok(ctx) => return Ok(ctx), + Err(err) if is_missing_credential_error(&err) => { + if base.verbose { + eprintln!( + " Profile '{}' credentials inaccessible ({}). Re-authenticating via OAuth...", + profile_name, err + ); } - return Ok(Some(p)); + base.profile = None; + auth::login_interactive_oauth(base).await?; + return auth::login(base).await; } - None => bail!( - "project '{}' not found in org '{}'", - name, - client.org_name() - ), + Err(err) => return Err(err), } } - 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()); + // 3. No profiles: start OAuth flow. + if base.verbose { + eprintln!("No auth profiles found. Starting OAuth login.\n"); } + auth::login_interactive_oauth(base).await?; + auth::login(base).await +} - 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].clone())) - } +fn is_missing_credential_error(err: &anyhow::Error) -> bool { + let msg = err.to_string(); + msg.contains("no keychain credential found") + || msg.contains("oauth refresh token missing") + || msg.contains("no login credentials found") } /// Returns `true` if config was written or already matched, `false` if user declined. @@ -792,8 +785,109 @@ fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result, +) -> Result> { + if let Some(name) = project_name { + let project = with_spinner( + "Loading project...", + crate::projects::api::get_project_by_name(client, name), + ) + .await?; + return match project { + Some(p) => Ok(Some(p)), + None => bail!( + "project '{}' not found in org '{}'", + name, + client.org_name() + ), + }; + } + + let projects = with_spinner( + "Loading projects...", + crate::projects::api::list_projects(client), + ) + .await?; + + // 2+ real projects: leave the user's setup untouched. + if projects.len() >= 2 { + return Ok(None); + } + + // Exactly one project: only proceed if it is the placeholder "My Project". + if projects.len() == 1 && projects[0].name != "My Project" { + return Ok(None); + } + + // 0 projects, or the sole project is the default placeholder: create a real one. + let username = get_whoami_username(); + let new_name = format!("{username}-test"); + let project = with_spinner( + &format!("Creating project '{new_name}'..."), + crate::projects::api::create_project(client, &new_name), + ) + .await?; + Ok(Some(project)) +} + +fn get_whoami_username() -> String { + std::process::Command::new("whoami") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "user".to_string()) +} + +/// Determine which agent to use for instrumentation in the wizard. +/// +/// Returns `Some(agent)` when exactly one agent is unambiguously determined: +/// - A single `--agent` flag +/// - Exactly one agent detected on the system +/// +/// Returns `None` when the choice is ambiguous (0 or multiple detected agents, +/// no explicit `--agent` flag), letting the caller prompt the user. +fn determine_wizard_instrument_agent( + flag_agent: Option, + local_root: Option<&Path>, + home: &Path, +) -> Option { + if let Some(arg) = flag_agent { + return Some(match arg { + AgentArg::Claude => InstrumentAgentArg::Claude, + AgentArg::Codex => InstrumentAgentArg::Codex, + AgentArg::Cursor => InstrumentAgentArg::Cursor, + AgentArg::Opencode => InstrumentAgentArg::Opencode, + }); + } + + // Auto-detect: only use unambiguous single detection. + let detected = detect_agents(local_root, home); + let detected_agents: BTreeSet = detected.iter().map(|s| s.agent).collect(); + if detected_agents.len() == 1 { + return Some(match detected_agents.into_iter().next().unwrap() { + Agent::Claude => InstrumentAgentArg::Claude, + Agent::Codex => InstrumentAgentArg::Codex, + Agent::Cursor => InstrumentAgentArg::Cursor, + Agent::Opencode => InstrumentAgentArg::Opencode, + }); + } + + None +} + async fn run_setup(base: BaseArgs, args: AgentsSetupArgs) -> Result<()> { - let outcome = execute_skills_setup(&base, &args, false).await?; + let outcome = execute_skills_setup(&base, &args).await?; if base.json { let report = SetupJsonReport { scope: outcome.scope.as_str().to_string(), @@ -828,7 +922,6 @@ 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)?; @@ -840,7 +933,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 && !quiet && !base.quiet; + let show_progress = !base.json && base.verbose; if show_progress { println!("Configuring coding agents for Braintrust"); @@ -951,10 +1044,8 @@ impl LanguageArg { } fn should_prompt_setup_action(base: &BaseArgs, args: &AgentsSetupArgs) -> bool { - if base.json || !ui::is_interactive() { - return false; - } - !args.yes + !base.json + && ui::is_interactive() && !args.no_fetch_docs && !args.refresh_docs && args.workers == crate::sync::default_workers() @@ -976,13 +1067,13 @@ async fn run_instrument_setup( let mut selected = if let Some(agent_arg) = args.agent { map_instrument_agent_arg(agent_arg) } else { - pick_agent_mode_target(&resolve_selected_agents(&[], &detected)) + pick_agent_mode_target(&resolve_selected_agents(None, &detected)) .ok_or_else(|| anyhow!("no detected agents available for instrumentation"))? }; if args.agent.is_none() && ui::is_interactive() && !args.yes { selected = prompt_instrument_agent(selected)?; - } else if args.agent.is_some() && !base.quiet { + } else if args.agent.is_some() && base.verbose && !args.yes { eprintln!( "{} Select agent to instrument this repo · {}", style("✔").green(), @@ -990,7 +1081,7 @@ async fn run_instrument_setup( ); } - let mut hint_pending = print_hint && !base.quiet; + let mut hint_pending = print_hint && base.verbose; let selected_workflows = resolve_instrument_workflow_selection(&args, &mut hint_pending)?; let selected_languages: Vec = if !args.languages.is_empty() { @@ -1039,7 +1130,7 @@ async fn run_instrument_setup( .await?; } else { let setup_args = AgentsSetupArgs { - agents: vec![map_agent_to_agent_arg(selected)], + agent: Some(map_agent_to_agent_arg(selected)), local: true, global: false, workflows: selected_workflows.clone(), @@ -1049,7 +1140,7 @@ async fn run_instrument_setup( workers: args.workers, yolo: false, }; - let outcome = execute_skills_setup(&base, &setup_args, false).await?; + let outcome = execute_skills_setup(&base, &setup_args).await?; detected = outcome.detected_agents; results.extend(outcome.results); warnings.extend(outcome.warnings); @@ -1060,16 +1151,13 @@ async fn run_instrument_setup( } // Determine run mode: interactive TUI vs background (autonomous). - // --yolo: background, full bypassPermissions (no restrictions) - // --interactive: interactive TUI - // --yes or non-interactive terminal: background, restricted to language package managers + // --tui: interactive TUI (inherits terminal) + // --background / --yes / non-interactive terminal: background (autonomous) // Otherwise: ask the user. - let (run_interactive, bypass_permissions) = if args.interactive { - (true, false) - } else if args.yolo { - (false, true) - } else if args.yes || !ui::is_interactive() { - (false, false) + let run_interactive = if args.tui { + true + } else if args.background || args.yes || !ui::is_interactive() { + false } else { let pkg_mgrs = package_manager_cmds_for_languages(&selected_languages).join(", "); let background_label = format!( @@ -1089,9 +1177,10 @@ async fn run_instrument_setup( let Some(index) = selection else { bail!("instrument setup cancelled by user"); }; - let interactive = index == 1; - (interactive, false) + index == 1 }; + // --yolo grants full permissions regardless of TUI/background mode. + let bypass_permissions = args.yolo; let docs_output_dir = root.join(".bt").join("skills").join("docs"); sdk_install_docs::write_sdk_install_docs(&docs_output_dir)?; @@ -1132,8 +1221,8 @@ async fn run_instrument_setup( eprintln!(); } - let show_output = !base.json && !args.quiet; - let status = if args.quiet && !base.json { + let show_output = !base.json && base.verbose; + let status = if !base.verbose && !base.json { with_spinner( "Running agent instrumentation…", run_agent_invocation(&root, &invocation, false), @@ -1536,39 +1625,58 @@ fn resolve_instrument_invocation( } let invocation = match agent { - Agent::Codex => InstrumentInvocation::Program { - program: "codex".to_string(), - args: vec!["exec".to_string(), "-".to_string()], - stdin_file: Some(task_path.to_path_buf()), - prompt_file_arg: None, - initial_prompt: None, - stream_json: false, - interactive, - }, + Agent::Codex => { + let mut codex_args = vec![]; + if bypass_permissions { + codex_args.push("--dangerously-bypass-approvals-and-sandbox".to_string()); + } + if interactive { + // TUI mode: `codex ""` opens the interactive TUI with the task pre-loaded. + InstrumentInvocation::Program { + program: "codex".to_string(), + args: codex_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive: true, + } + } else { + // Background mode: `codex exec -` reads the task from stdin. + codex_args.extend(["exec".to_string(), "-".to_string()]); + InstrumentInvocation::Program { + program: "codex".to_string(), + args: codex_args, + stdin_file: Some(task_path.to_path_buf()), + prompt_file_arg: None, + initial_prompt: None, + stream_json: false, + interactive: false, + } + } + } Agent::Claude => { if interactive { - // In interactive mode the full task goes into --append-system-prompt so - // Claude already knows what to do. A short initial user message is passed - // as the positional arg so Claude immediately starts working — the user only - // needs to press Enter once on a short, clear prompt rather than a wall of - // raw task markdown. - let task_content = std::fs::read_to_string(task_path) - .with_context(|| format!("failed to read task file {}", task_path.display()))?; + // TUI mode: pass the full task as a positional arg so Claude loads it + // as the initial user message and starts working immediately. + // --permission-mode acceptEdits overrides any "plan" defaultMode the + // user may have set globally, which would otherwise cause an immediate + // ExitPlanMode tool call with invalid parameters. InstrumentInvocation::Program { program: "claude".to_string(), args: vec![ - "--append-system-prompt".to_string(), - task_content, - "--disallowedTools".to_string(), - "EnterPlanMode".to_string(), + "--permission-mode".to_string(), + if bypass_permissions { + "bypassPermissions".to_string() + } else { + "acceptEdits".to_string() + }, "--name".to_string(), "Braintrust: Instrument".to_string(), ], stdin_file: None, - prompt_file_arg: None, - initial_prompt: Some( - "Please begin the Braintrust instrumentation task.".to_string(), - ), + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, stream_json: false, interactive: true, } @@ -1604,30 +1712,56 @@ fn resolve_instrument_invocation( } } } - 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()), - initial_prompt: None, - stream_json: false, - interactive, - }, - Agent::Cursor => InstrumentInvocation::Program { - program: "cursor-agent".to_string(), - args: vec![ - "-p".to_string(), - "-f".to_string(), - "--output-format".to_string(), - "stream-json".to_string(), - "--stream-partial-output".to_string(), - ], - stdin_file: None, - prompt_file_arg: Some(task_path.to_path_buf()), - initial_prompt: None, - stream_json: true, - interactive, - }, + Agent::Opencode => { + // `opencode` TUI does not accept an initial message (its positional arg is a + // project path). `opencode run [message..]` is the only way to deliver a + // prompt, so we use it for both interactive and background modes. In + // interactive mode we inherit all streams so the user can watch and steer. + InstrumentInvocation::Program { + program: "opencode".to_string(), + args: vec!["run".to_string()], + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive, + } + } + Agent::Cursor => { + let mut cursor_args = vec![]; + if bypass_permissions { + cursor_args.push("--yolo".to_string()); + } + if interactive { + // TUI mode: `cursor-agent ""` opens the interactive TUI with task pre-loaded. + InstrumentInvocation::Program { + program: "cursor-agent".to_string(), + args: cursor_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: false, + interactive: true, + } + } else { + // Background mode: `-p` enables non-interactive print mode. + cursor_args.extend([ + "-p".to_string(), + "--output-format".to_string(), + "stream-json".to_string(), + "--stream-partial-output".to_string(), + ]); + InstrumentInvocation::Program { + program: "cursor-agent".to_string(), + args: cursor_args, + stdin_file: None, + prompt_file_arg: Some(task_path.to_path_buf()), + initial_prompt: None, + stream_json: true, + interactive: false, + } + } + } }; Ok(invocation) } @@ -1887,7 +2021,7 @@ fn resolve_setup_selection(args: &AgentsSetupArgs, home: &Path) -> Result Result { prompted_agents = Some(selected); @@ -1960,7 +2094,7 @@ fn resolve_setup_selection(args: &AgentsSetupArgs, home: &Path) -> Result value, - None => resolve_selected_agents(&args.agents, &detected), + None => resolve_selected_agents(args.agent, &detected), }; if selected_agents.is_empty() { bail!("no agents selected for installation"); @@ -1999,7 +2133,7 @@ fn resolve_mcp_selection(args: &AgentsMcpSetupArgs, home: &Path) -> Result Result { prompted_agents = Some(selected); @@ -2054,7 +2188,7 @@ fn resolve_mcp_selection(args: &AgentsMcpSetupArgs, home: &Path) -> Result value, - None => resolve_selected_agents(&args.agents, &detected), + None => resolve_selected_agents(args.agent, &detected), }; if selected_agents.is_empty() { bail!("no agents selected for MCP setup"); @@ -2354,8 +2488,11 @@ fn resolve_scope_from_flags( }) } -fn resolve_selected_agents(requested: &[AgentArg], detected: &[DetectionSignal]) -> Vec { - if requested.is_empty() { +fn resolve_selected_agents( + requested: Option, + detected: &[DetectionSignal], +) -> Vec { + let Some(arg) = requested else { let mut inferred = BTreeSet::new(); for signal in detected { inferred.insert(signal.agent); @@ -2364,26 +2501,13 @@ fn resolve_selected_agents(requested: &[AgentArg], detected: &[DetectionSignal]) return ALL_AGENTS.to_vec(); } return inferred.into_iter().collect(); - } - - if requested.contains(&AgentArg::All) { - return ALL_AGENTS.to_vec(); - } - - let mut out = BTreeSet::new(); - for value in requested { - let mapped = match value { - AgentArg::Claude => Some(Agent::Claude), - AgentArg::Codex => Some(Agent::Codex), - AgentArg::Cursor => Some(Agent::Cursor), - AgentArg::Opencode => Some(Agent::Opencode), - AgentArg::All => None, - }; - if let Some(agent) = mapped { - out.insert(agent); - } - } - out.into_iter().collect() + }; + vec![match arg { + AgentArg::Claude => Agent::Claude, + AgentArg::Codex => Agent::Codex, + AgentArg::Cursor => Agent::Cursor, + AgentArg::Opencode => Agent::Opencode, + }] } fn map_instrument_agent_arg(agent: InstrumentAgentArg) -> Agent { @@ -3136,23 +3260,13 @@ mod tests { use super::*; use std::time::{SystemTime, UNIX_EPOCH}; - #[test] - fn all_agent_arg_expands_to_all_agents() { - let detected = vec![]; - let resolved = resolve_selected_agents(&[AgentArg::All], &detected); - assert_eq!( - resolved, - vec![Agent::Claude, Agent::Codex, Agent::Cursor, Agent::Opencode] - ); - } - #[test] fn detection_drives_default_selection() { let detected = vec![DetectionSignal { agent: Agent::Codex, reason: "hint".to_string(), }]; - let resolved = resolve_selected_agents(&[], &detected); + let resolved = resolve_selected_agents(None, &detected); assert_eq!(resolved, vec![Agent::Codex]); } @@ -3254,7 +3368,7 @@ mod tests { #[test] fn resolve_setup_selection_honors_no_fetch_docs() { let args = AgentsSetupArgs { - agents: vec![AgentArg::Codex], + agent: Some(AgentArg::Codex), local: false, global: true, workflows: vec![WorkflowArg::Evaluate], @@ -3288,9 +3402,9 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), - quiet: false, languages: Vec::new(), - interactive: false, + tui: false, + background: false, yolo: false, }; @@ -3311,9 +3425,9 @@ mod tests { yes: true, refresh_docs: false, workers: crate::sync::default_workers(), - quiet: false, languages: Vec::new(), - interactive: false, + tui: false, + background: false, yolo: false, }; @@ -3484,29 +3598,22 @@ mod tests { args, stdin_file, prompt_file_arg, - initial_prompt, stream_json, interactive, + .. } => { assert_eq!(program, "claude"); assert!( !args.contains(&"-p".to_string()), "interactive mode must not pass -p" ); - assert!( - args.contains(&"--append-system-prompt".to_string()), - "task should be in system prompt" - ); - assert!(args.contains(&"--disallowedTools".to_string())); + assert!(args.contains(&"--permission-mode".to_string())); assert!(args.contains(&"--name".to_string())); assert_eq!(stdin_file, None); assert_eq!( - prompt_file_arg, None, - "task is in system prompt, not prompt_file_arg" - ); - assert!( - initial_prompt.is_some(), - "short initial message must be set to trigger Claude" + prompt_file_arg, + Some(task_path), + "task passed as positional arg" ); assert!(!stream_json); assert!(interactive); @@ -3562,7 +3669,6 @@ mod tests { args, vec![ "-p".to_string(), - "-f".to_string(), "--output-format".to_string(), "stream-json".to_string(), "--stream-partial-output".to_string(), diff --git a/src/switch.rs b/src/switch.rs index cc9bf3a..2defe95 100644 --- a/src/switch.rs +++ b/src/switch.rs @@ -245,14 +245,13 @@ mod tests { fn base_args(org: Option<&str>, project: Option<&str>) -> BaseArgs { BaseArgs { json: false, - quiet: false, + verbose: false, no_color: false, profile: None, org_name: org.map(String::from), project: project.map(String::from), api_key: None, prefer_profile: false, - no_input: false, api_url: None, app_url: None, env_file: None, diff --git a/src/traces.rs b/src/traces.rs index 201b7f9..9bbd717 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -6058,14 +6058,13 @@ mod tests { fn base_args() -> BaseArgs { BaseArgs { json: false, - quiet: false, + verbose: false, no_color: false, profile: None, org_name: None, project: None, api_key: None, prefer_profile: false, - no_input: false, api_url: None, app_url: None, env_file: None, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 07e999b..6a36782 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,14 +8,9 @@ mod spinner; mod status; mod table; -static NO_INPUT: AtomicBool = AtomicBool::new(false); static QUIET: AtomicBool = AtomicBool::new(false); static ANIMATIONS_ENABLED: AtomicBool = AtomicBool::new(true); -pub fn set_no_input(val: bool) { - NO_INPUT.store(val, Ordering::Relaxed); -} - pub fn set_quiet(val: bool) { QUIET.store(val, Ordering::Relaxed); } @@ -33,7 +28,7 @@ pub fn animations_enabled() -> bool { } pub fn is_interactive() -> bool { - std::io::stdin().is_terminal() && !NO_INPUT.load(Ordering::Relaxed) + std::io::stdin().is_terminal() } pub use pager::print_with_pager;