diff --git a/Cargo.lock b/Cargo.lock index 0b42b699..ce8995c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,7 +508,9 @@ dependencies = [ "braintrust-sdk-rust", "chrono", "clap", + "cliclack", "comfy-table", + "console 0.16.3", "crossterm 0.28.1", "dialoguer", "dirs", @@ -518,7 +520,8 @@ dependencies = [ "fuzzy-matcher", "getrandom 0.3.4", "glob", - "indicatif", + "ignore", + "indicatif 0.17.11", "lingua", "oauth2", "open", @@ -531,6 +534,7 @@ dependencies = [ "serde_json 1.0.149", "sha2", "strip-ansi-escapes", + "supports-hyperlinks", "tempfile", "tokio", "toml", @@ -654,6 +658,20 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cliclack" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4529f45438fc25ca048b242d5c48e2d3ce9a521e2a5a9123d9737d8520b030dd" +dependencies = [ + "console 0.16.3", + "indicatif 0.18.4", + "once_cell", + "strsim", + "textwrap", + "zeroize", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -715,6 +733,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.0", + "windows-sys 0.61.2", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -982,7 +1012,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ - "console", + "console 0.15.11", "fuzzy-matcher", "shell-words", "tempfile", @@ -1093,7 +1123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1307,6 +1337,19 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.3.27" @@ -1473,7 +1516,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -1617,6 +1660,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "impl-more" version = "0.1.9" @@ -1652,13 +1711,26 @@ version = "0.17.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" dependencies = [ - "console", + "console 0.15.11", "number_prefix", "portable-atomic", "unicode-width 0.2.0", "web-time", ] +[[package]] +name = "indicatif" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25470f23803092da7d239834776d653104d551bc4d7eacaf31e6837854b8e9eb" +dependencies = [ + "console 0.16.3", + "portable-atomic", + "unicode-width 0.2.0", + "unit-prefix", + "web-time", +] + [[package]] name = "indoc" version = "2.0.7" @@ -2285,7 +2357,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.2", "thiserror 2.0.18", "tokio", "tracing", @@ -2322,9 +2394,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -2592,7 +2664,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2654,6 +2726,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2958,6 +3039,12 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + [[package]] name = "socket2" version = "0.5.10" @@ -3033,6 +3120,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + [[package]] name = "syn" version = "2.0.117" @@ -3074,7 +3167,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3092,6 +3185,17 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width 0.2.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3417,6 +3521,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -3452,6 +3562,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3531,6 +3647,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3721,7 +3847,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4192,6 +4318,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 16c878e1..7feb09d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,10 @@ strip-ansi-escapes = "0.2.0" tokio = { version = "1.40.0", features = ["rt-multi-thread", "macros", "process", "net", "signal"] } unicode-width = "0.1.13" dialoguer = { version = "0.11", features = ["fuzzy-select"] } +cliclack = "0.5.4" +console = "0.16" +ignore = "0.4" +supports-hyperlinks = "3" fuzzy-matcher = "0.3" dotenvy = "0.15" open = "5" diff --git a/src/main.rs b/src/main.rs index 0a34fb4d..295af099 100644 --- a/src/main.rs +++ b/src/main.rs @@ -536,14 +536,7 @@ mod tests { #[test] fn apply_base_output_defaults_keeps_setup_quiet_by_default() { let matches = Cli::command() - .try_get_matches_from([ - "bt", - "setup", - "--no-instrument", - "--global", - "--agent", - "codex", - ]) + .try_get_matches_from(["bt", "setup", "skills", "--global", "--agent", "codex"]) .expect("matches"); let mut cli = Cli::from_arg_matches(&matches).expect("cli"); @@ -573,7 +566,7 @@ mod tests { "bt", "setup", "--verbose", - "--no-instrument", + "skills", "--global", "--agent", "codex", diff --git a/src/setup/mod.rs b/src/setup/mod.rs index a904f214..8b1d79d5 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,5 +1,4 @@ use std::collections::{BTreeMap, BTreeSet}; -use std::ffi::OsStr; use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -25,6 +24,7 @@ use crate::ui::{self, with_spinner}; mod agent_stream; mod docs; mod sdk_install_docs; +mod wizard; pub use docs::DocsArgs; @@ -57,74 +57,17 @@ const ALL_WORKFLOWS: [WorkflowArg; 5] = [ #[derive(Debug, Clone, Args)] #[command(after_help = "\ Examples: - bt setup --agent cursor --workflow observe + bt setup bt setup skills --agent codex --global + bt setup instrument --agent claude bt setup mcp --agent codex ")] pub struct SetupArgs { #[command(subcommand)] command: Option, - /// Also install reusable coding-agent skills (persistent, opt-in) - #[arg(long, conflicts_with = "no_skills")] - skills: bool, - - /// Do not set up reusable coding-agent skills - #[arg(long, visible_alias = "no-skill", conflicts_with = "skills")] - no_skills: bool, - - /// Set up MCP server - #[arg(long, conflicts_with = "no_mcp")] - mcp: bool, - - /// Do not set up MCP server [default] - #[arg(long, conflicts_with = "mcp")] - no_mcp: bool, - - /// Run instrumentation agent [default] - #[arg(long)] - instrument: bool, - - /// Do not run instrumentation agent (skills and MCP are still configured) - #[arg( - long, - conflicts_with = "instrument", - conflicts_with = "tui", - conflicts_with = "background", - conflicts_with = "yolo" - )] - no_instrument: bool, - - /// Run the agent in interactive TUI mode [default] - #[arg(long, conflicts_with = "background", conflicts_with = "no_instrument")] - tui: bool, - - /// Run the agent in background (non-interactive) mode. Use --verbose to see the agent output - #[arg(long, conflicts_with = "tui", conflicts_with = "no_instrument")] - 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, - conflicts_with = "no_instrument" - )] - languages: Vec, - - /// Run the interactive setup wizard, prompting for every choice not already - /// specified as a flag. - #[arg(long, short = 'i')] - interactive: bool, - - /// Deprecated: use --no-skills --no-mcp instead - #[arg(long, hide = true, conflicts_with = "skills", conflicts_with = "mcp")] - no_mcp_skill: bool, - #[command(flatten)] - agents: AgentsSetupArgs, + wizard: wizard::WizardArgs, } #[derive(Debug, Clone, Subcommand)] @@ -515,23 +458,7 @@ struct SkillsAliasResult { path: PathBuf, } -pub async fn run_setup_top(base: BaseArgs, mut args: SetupArgs) -> Result<()> { - // Deprecated flag: --no-mcp-skill is equivalent to --no-skills --no-mcp - if args.no_mcp_skill { - args.no_skills = true; - args.no_mcp = true; - } - if base.json && args.instrument { - bail!("--json conflicts with --instrument: JSON mode implies --no-instrument"); - } - if base.json && args.tui { - bail!( - "--json conflicts with --tui: JSON output is not compatible with interactive TUI mode" - ); - } - if base.json { - args.no_instrument = true; - } +pub async fn run_setup_top(base: BaseArgs, args: SetupArgs) -> Result<()> { match args.command { Some(SetupSubcommand::Skills(setup)) => run_setup(base, setup).await, Some(SetupSubcommand::Instrument(mut instrument)) => { @@ -540,578 +467,16 @@ pub async fn run_setup_top(base: BaseArgs, mut args: SetupArgs) -> Result<()> { } Some(SetupSubcommand::Mcp(mcp)) => run_mcp_setup(base, mcp).await, Some(SetupSubcommand::Doctor(doctor)) => run_doctor(base, doctor), - None => { - let wizard_flags = WizardFlags { - yolo: args.agents.permissions.yolo, - skills: args.skills, - no_skills: args.no_skills, - mcp: args.mcp, - no_mcp: args.no_mcp, - local: args.agents.local, - global: args.agents.global, - instrument: args.instrument, - no_instrument: args.no_instrument, - tui: args.tui, - background: args.background, - agent: args.agents.agent, - workflows: args.agents.workflows.clone(), - no_workflow: args.agents.no_workflow, - languages: args.languages.clone(), - }; - if args.interactive { - run_setup_wizard(base, wizard_flags).await - } else { - run_default_setup(base, args).await - } - } + None => wizard::run(base, args.wizard).await, } } pub use docs::run_docs_top; -struct WizardFlags { - yolo: bool, - skills: bool, - no_skills: bool, - mcp: bool, - no_mcp: bool, - local: bool, - global: bool, - instrument: bool, - no_instrument: bool, - tui: bool, - background: bool, - agent: Option, - workflows: Vec, - no_workflow: bool, - languages: Vec, -} - -async fn run_setup_wizard(mut base: BaseArgs, flags: WizardFlags) -> Result<()> { - let WizardFlags { - yolo, - skills: flag_skills, - no_skills: flag_no_skills, - mcp: flag_mcp, - no_mcp: flag_no_mcp, - local: flag_local, - global: flag_global, - instrument: flag_instrument, - no_instrument: flag_no_instrument, - tui: flag_tui, - background: flag_background, - agent: flag_agent, - workflows: flag_workflows, - no_workflow: flag_no_workflow, - languages: flag_languages, - } = flags; - print_setup_banner(&base); - eprintln!("Set up Braintrust SDK tracing"); - eprintln!( - "Braintrust will use the coding agent you choose to add SDK tracing to this app and verify it works.\n" - ); - - let mut had_failures = false; - let verbose = base.verbose; - let home = home_dir().ok_or_else(|| anyhow!("failed to resolve HOME/USERPROFILE"))?; - let git_root = find_git_root(); - let will_instrument = !flag_no_instrument; - if git_root.is_none() && !base.json { - eprintln!( - "{} Not inside a git repository — the agent may edit files in the current directory.", - style("!").yellow() - ); - } - - // ── Step 1: Auth ── - if verbose { - print_wizard_step(1, "Auth"); - } - let project_flag = will_instrument.then(|| base.project.clone()).flatten(); - let mut setup_auth = if will_instrument { - Some(ensure_setup_auth(&mut base, !flag_no_instrument, !flag_no_instrument).await?) - } else { - None - }; - let org = setup_auth - .as_ref() - .map(|auth| auth.client.org_name().to_string()); - - if verbose { - if let Some(org) = org.as_deref() { - eprintln!(" {} Using org '{}'", style("✓").green(), org); - } else { - eprintln!(" {}", style("Skipped").dim()); - } - } - - // ── Step 2: Project ── - if verbose { - print_wizard_step(2, "Project"); - } - if project_flag.is_none() && ui::can_prompt() { - eprintln!("First, select a project, or create a new one."); - eprintln!("Projects organize AI features in your application. Each project contains logs, experiments, datasets, prompts, and other functions."); - } - let project = if let Some(auth) = setup_auth.as_ref() { - select_project_with_skip(&auth.client, project_flag.as_deref(), !verbose).await? - } else { - None - }; - if verbose { - if let Some(ref project) = project { - if let Some(org) = org.as_deref() { - maybe_init(org, project)?; - eprintln!( - " {} Linked to {}/{}", - style("✓").green(), - org, - project.name - ); - } - } else { - eprintln!(" {}", style("Skipped").dim()); - } - } else if let Some(ref project) = project { - if let Some(org) = org.as_deref() { - maybe_init(org, project)?; - } - } - - // ── Step 3: Agent tools (skills + MCP) ── - if verbose { - print_wizard_step(3, "Agents"); - } - let multiselect_hint_shown = false; - let (wants_skills, wants_mcp) = if flag_no_skills && flag_no_mcp { - if verbose { - eprintln!( - "{} What would you like to set up? · {}", - style("✔").green(), - style("(none)").dim() - ); - } - (false, false) - } else if flag_no_skills { - (false, flag_mcp && !flag_no_mcp) - } else if flag_no_mcp { - (flag_skills && !flag_no_skills, false) - } else if flag_skills || flag_mcp { - if verbose { - 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) - } else { - // Default setup is intentionally ephemeral: persistent skills/MCP are only - // installed when the user asks for them explicitly (or accepts the - // post-success skills prompt). - (false, false) - }; - - let setup_context = if wants_skills || wants_mcp { - let scope = if flag_local { - if verbose { - eprintln!( - "{} Select install scope · {}", - style("✔").green(), - style("local (current git repo)").green() - ); - } - InstallScope::Local - } else if flag_global { - if verbose { - eprintln!( - "{} Select install scope · {}", - style("✔").green(), - style("global (user-wide)").green() - ); - } - InstallScope::Global - } else { - prompt_scope_selection("Select install scope")? - .ok_or_else(|| anyhow!("setup cancelled"))? - }; - let local_root = resolve_local_root_for_scope(scope)?; - let detected = detect_agents(local_root.as_deref(), &home); - if !flag_no_instrument - && should_print_agent_selection_intro(&base, flag_agent.is_some(), false) - { - print_coding_agent_selection_intro(); - } - let selected_agent = - resolve_default_agent_selection(flag_agent, &detected, "Select coding agent", true)?; - if verbose && flag_agent.is_some() { - eprintln!( - "{} Select agent to configure · {}", - style("✔").green(), - style(selected_agent.as_str()).green() - ); - } - Some((scope, selected_agent, home.clone())) - } else if !flag_no_instrument { - let root = git_root - .clone() - .unwrap_or(std::env::current_dir().context("failed to get current directory")?); - let detected = detect_agents(Some(&root), &home); - if should_print_agent_selection_intro(&base, flag_agent.is_some(), false) { - print_coding_agent_selection_intro(); - } - let selected_agent = - resolve_default_agent_selection(flag_agent, &detected, "Select coding agent", true)?; - if verbose && flag_agent.is_some() { - eprintln!( - "{} Select coding agent · {}", - style("✔").green(), - style(selected_agent.as_str()).green() - ); - } - Some((InstallScope::Local, selected_agent, home.clone())) - } else { - None - }; - - if wants_skills { - if verbose { - eprintln!(" {}", style("Skills:").bold()); - } - if let Some((scope, selected_agent, _)) = setup_context.as_ref() { - let args = AgentsSetupArgs { - agent: Some(map_agent_to_agent_arg(*selected_agent)), - local: matches!(*scope, InstallScope::Local), - global: matches!(*scope, InstallScope::Global), - workflows: Vec::new(), - no_workflow: true, - yes: true, - refresh_docs: false, - workers: crate::sync::default_workers(), - permissions: InstrumentPermissionArgs { yolo: false }, - }; - let outcome = execute_skills_setup(&base, &args, true).await?; - for r in &outcome.results { - if verbose { - print_wizard_agent_result(r); - } - if matches!(r.status, InstallStatus::Failed) { - had_failures = true; - } - } - } - } - - if wants_mcp { - if verbose { - eprintln!(" {}", style("MCP:").bold()); - } - if setup_auth.is_none() { - setup_auth = Some(ensure_setup_auth(&mut base, false, true).await?); - } - if let (Some((scope, selected_agent, home)), Some(auth)) = - (setup_context.as_ref(), setup_auth.as_ref()) - { - let local_root = resolve_local_root_for_scope(*scope)?; - let api_url = auth.client.url(""); - let outcome = execute_mcp_install( - *scope, - local_root.as_deref(), - home, - &[*selected_agent], - &auth.api_key, - &mcp_url_from_api_url(&api_url), - ); - for r in &outcome.results { - if verbose { - 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 && verbose { - eprintln!(" {}", style("Skipped").dim()); - } - - // ── Step 4: Instrument ── - if verbose { - print_wizard_step(4, "Instrument"); - } - { - let instrument = if flag_no_instrument { - if verbose { - eprintln!( - "Run instrumentation agent to set up tracing in this repo? {}", - style("no").dim() - ); - } - false - } else if flag_instrument { - if verbose { - eprintln!( - "Run instrumentation agent to set up tracing in this repo? {}", - style("yes").green() - ); - } - true - } else { - let term = ui::prompt_term().ok_or_else(|| anyhow!("interactive mode requires TTY"))?; - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Run instrumentation agent to set up tracing in this repo?") - .default(true) - .interact_on(&term)? - }; - if instrument { - let instrument_agent = setup_context - .as_ref() - .map(|(_, agent, _)| map_agent_to_instrument_agent_arg(*agent)) - .or_else(|| { - flag_agent.map(|arg| map_agent_to_instrument_agent_arg(map_agent_arg(arg))) - }) - .or_else(|| determine_wizard_instrument_agent(flag_agent)); - let selected_workflows = if flag_no_workflow { - Vec::new() - } else if !flag_workflows.is_empty() { - resolve_prompted_instrument_workflows(flag_workflows.clone()) - } else if ui::can_prompt() { - prompt_instrument_workflow_selection()?.ok_or_else(|| anyhow!("setup cancelled"))? - } else { - vec![WorkflowArg::Instrument, WorkflowArg::Observe] - }; - let selected_languages = if !flag_languages.is_empty() { - flag_languages.clone() - } else if ui::can_prompt() { - let defaults = detect_languages_from_dir(&std::env::current_dir()?); - prompt_instrument_language_selection(&defaults)? - .ok_or_else(|| anyhow!("setup cancelled"))? - } else { - Vec::new() - }; - let (run_tui, yolo_mode) = if flag_tui { - (true, yolo) - } else if flag_background { - (false, yolo) - } else if ui::can_prompt() { - let term = - ui::prompt_term().ok_or_else(|| anyhow!("interactive mode requires TTY"))?; - let run_tui = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("How do you want to run the agent?") - .items(&["Interactive (TUI)", "Background"]) - .default(0) - .interact_on(&term)? - == 0; - let yolo_mode = if yolo { - true - } else { - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Grant agent full permissions? (bypass permission prompts)") - .default(false) - .interact_on(&term)? - }; - (run_tui, yolo_mode) - } else { - let (run_interactive, bypass_permissions) = resolve_instrument_run_mode( - &InstrumentSetupArgs { - agent: instrument_agent, - agent_cmd: None, - workflows: selected_workflows.clone(), - no_workflow: flag_no_workflow, - yes: false, - refresh_docs: false, - workers: crate::sync::default_workers(), - languages: selected_languages.clone(), - tui: false, - background: false, - permissions: InstrumentPermissionArgs { yolo }, - prompt_for_missing_options: false, - }, - ui::can_prompt(), - ); - (run_interactive, bypass_permissions) - }; - run_instrument_setup( - base, - InstrumentSetupArgs { - agent: instrument_agent, - agent_cmd: None, - workflows: selected_workflows, - no_workflow: flag_no_workflow, - yes: false, - refresh_docs: false, - workers: crate::sync::default_workers(), - languages: selected_languages, - tui: run_tui, - background: !run_tui, - permissions: InstrumentPermissionArgs { yolo: yolo_mode }, - prompt_for_missing_options: false, - }, - !multiselect_hint_shown, - !wants_skills, - ) - .await?; - } else if verbose { - eprintln!(" {}", style("Skipped").dim()); - } - } - - // ── Done ── - if verbose { - print_wizard_done(had_failures); - } - if had_failures { - bail!("setup completed with failures"); - } - Ok(()) -} - -async fn run_default_setup(mut base: BaseArgs, args: SetupArgs) -> Result<()> { - if !base.json { - print_setup_banner(&base); - } - - let will_instrument = !args.no_instrument; - if will_instrument && find_git_root().is_none() && !base.json { - eprintln!( - "{} Not inside a git repository — the agent may edit files in the current directory.", - style("!").yellow() - ); - } - let wants_skills = args.skills && !args.no_skills; - let wants_mcp = args.mcp && !args.no_mcp; - if will_instrument { - let project_flag = base.project.clone(); - let auth = ensure_setup_auth(&mut base, false, true).await?; - let org = auth.client.org_name().to_string(); - let project = select_project_with_skip(&auth.client, project_flag.as_deref(), true).await?; - if let Some(ref project) = project { - maybe_init(&org, project)?; - } - } - - if !wants_skills && !wants_mcp && !will_instrument { - return Ok(()); - } - - let scope = default_setup_scope(&args.agents); - 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); - if will_instrument - && should_print_agent_selection_intro(&base, args.agents.agent.is_some(), false) - { - print_coding_agent_selection_intro(); - } - let selected_agent = resolve_default_agent_selection( - args.agents.agent, - &detected, - "Select coding agent", - ui::can_prompt(), - )?; - - if wants_skills { - run_setup( - base.clone(), - AgentsSetupArgs { - agent: Some(map_agent_to_agent_arg(selected_agent)), - local: matches!(scope, InstallScope::Local), - global: matches!(scope, InstallScope::Global), - workflows: args.agents.workflows.clone(), - no_workflow: args.agents.no_workflow, - yes: true, - refresh_docs: args.agents.refresh_docs, - workers: args.agents.workers, - permissions: args.agents.permissions.clone(), - }, - ) - .await?; - } - - if wants_mcp { - run_mcp_setup( - base.clone(), - AgentsMcpSetupArgs { - agent: Some(map_agent_to_agent_arg(selected_agent)), - local: matches!(scope, InstallScope::Local), - global: matches!(scope, InstallScope::Global), - yes: true, - }, - ) - .await?; - } - - if will_instrument { - run_instrument_setup( - base, - InstrumentSetupArgs { - agent: Some(map_agent_to_instrument_agent_arg(selected_agent)), - agent_cmd: None, - workflows: args.agents.workflows, - no_workflow: args.agents.no_workflow, - yes: false, - refresh_docs: args.agents.refresh_docs, - workers: args.agents.workers, - languages: args.languages, - tui: args.tui, - background: args.background, - permissions: args.agents.permissions, - prompt_for_missing_options: false, - }, - false, - !wants_skills, - ) - .await?; - } - - Ok(()) -} - fn in_ci() -> bool { std::env::var_os("CI").is_some() } -fn setup_banner_color_enabled(base: &BaseArgs) -> bool { - if base.no_color || std::env::var_os("NO_COLOR").is_some() { - return false; - } - - !matches!(std::env::var_os("TERM"), Some(term) if term == OsStr::new("dumb")) -} - -fn print_setup_banner(base: &BaseArgs) { - let color_enabled = setup_banner_color_enabled(base); - eprintln!( - "{}", - style(crate::BANNER) - .for_stderr() - .blue() - .force_styling(color_enabled) - ); - eprintln!( - "{}", - style("Braintrust") - .for_stderr() - .blue() - .force_styling(color_enabled) - ); - eprintln!(); -} - fn print_coding_agent_selection_intro() { eprintln!( "Braintrust will ask a coding agent to add SDK tracing, run your app, and verify data reaches Braintrust." @@ -1822,82 +1187,6 @@ async fn maybe_create_api_key_for_oauth(base: &BaseArgs, client: &ApiClient) -> Ok(created.key) } -async fn select_project_for_setup( - client: &ApiClient, - project_name: Option<&str>, -) -> Result { - if let Some(name) = project_name { - let projects = with_spinner( - "Loading projects...", - crate::projects::api::list_projects(client), - ) - .await?; - if let Some(project) = projects.into_iter().find(|project| project.name == name) { - return Ok(project); - } - bail!( - "project '{}' not found in org '{}'", - name, - client.org_name() - ) - } - - if !ui::can_prompt() { - bail!( - "project choice required in non-interactive mode; pass --project or set BRAINTRUST_DEFAULT_PROJECT" - ); - } - - ui::select_project( - client, - None, - Some("Select project"), - ui::ProjectSelectMode::AllowCreateWithDefaultProjectNote, - ) - .await -} - -async fn select_project_with_skip( - client: &ApiClient, - project_name: Option<&str>, - quiet: bool, -) -> Result> { - let project = select_project_for_setup(client, project_name).await?; - if !quiet { - eprintln!("{} Select project · {}", style("✔").green(), project.name); - } - Ok(Some(project)) -} - -fn maybe_init(org: &str, project: &crate::projects::api::Project) -> Result<()> { - let config_path = std::env::current_dir()?.join(".bt").join("config.json"); - let mut cfg = if config_path.exists() { - let existing = config::load_file(&config_path); - let matches = existing.org.as_deref() == Some(org) - && existing.project.as_deref() == Some(project.name.as_str()); - if matches && existing.project_id.as_deref() == Some(project.id.as_str()) { - return Ok(()); - } - existing - } else { - config::Config::default() - }; - - cfg.org = Some(org.to_string()); - cfg.project = Some(project.name.clone()); - cfg.project_id = Some(project.id.clone()); - config::save_local(&cfg, true)?; - Ok(()) -} - -fn default_setup_scope(args: &AgentsSetupArgs) -> InstallScope { - if args.local { - InstallScope::Local - } else { - InstallScope::Global - } -} - async fn run_setup(base: BaseArgs, args: AgentsSetupArgs) -> Result<()> { let outcome = execute_skills_setup(&base, &args, false).await?; if base.json { @@ -2260,12 +1549,34 @@ async fn run_instrument_setup( } else if base.verbose { eprintln!(); for result in &results { - print_wizard_agent_result(result); + let (indicator, status_text) = match result.status { + InstallStatus::Installed => (style("✓").green(), "installed"), + InstallStatus::Skipped => (style("—").dim(), "already configured"), + InstallStatus::Failed => (style("✗").red(), "failed"), + }; + eprintln!( + " {} {} — {}", + indicator, + result.agent.display_name(), + status_text + ); } for warning in &warnings { eprintln!(" {} {}", style("!").dim(), style(warning).dim()); } - print_wizard_done(!status.success()); + if status.success() { + eprintln!( + "\n{} {}", + style("✓").green(), + style("Setup complete").bold() + ); + } else { + eprintln!( + "\n{} {}", + style("!").dim(), + style("Setup complete (with warnings)").bold() + ); + } } if !status.success() { @@ -2475,18 +1786,6 @@ fn detect_languages_from_dir(dir: &std::path::Path) -> Vec { found.into_iter().collect() } -fn resolve_prompted_instrument_workflows(mut workflows: Vec) -> Vec { - if workflows.is_empty() { - return workflows; - } - if !workflows.contains(&WorkflowArg::Instrument) { - workflows.push(WorkflowArg::Instrument); - } - workflows.sort(); - workflows.dedup(); - workflows -} - fn map_agent_to_agent_arg(agent: Agent) -> AgentArg { match agent { Agent::Claude => AgentArg::Claude, @@ -2499,18 +1798,6 @@ fn map_agent_to_agent_arg(agent: Agent) -> AgentArg { } } -fn map_agent_to_instrument_agent_arg(agent: Agent) -> InstrumentAgentArg { - match agent { - Agent::Claude => InstrumentAgentArg::Claude, - Agent::Codex => InstrumentAgentArg::Codex, - Agent::Copilot => InstrumentAgentArg::Copilot, - Agent::Cursor => InstrumentAgentArg::Cursor, - Agent::Gemini => InstrumentAgentArg::Gemini, - Agent::Opencode => InstrumentAgentArg::Opencode, - Agent::Qwen => InstrumentAgentArg::Qwen, - } -} - fn skill_config_path( agent: Agent, scope: InstallScope, @@ -3832,18 +3119,6 @@ fn pick_agent_mode_target(candidates: &[Agent]) -> Option { candidates.first().copied() } -fn determine_wizard_instrument_agent(flag_agent: Option) -> Option { - if let Some(arg) = flag_agent { - return Some(map_agent_to_instrument_agent_arg(map_agent_arg(arg))); - } - - let runnable_agents = detect_runnable_agents(); - if runnable_agents.len() == 1 { - return Some(map_agent_to_instrument_agent_arg(runnable_agents[0])); - } - None -} - fn promptable_instrument_agents( runnable_agents: &[Agent], detected: &[DetectionSignal], @@ -3870,14 +3145,6 @@ fn detected_agents_on_path(detected: &[DetectionSignal]) -> Vec { agents.into_iter().collect() } -fn detect_runnable_agents() -> Vec { - ALL_AGENTS - .iter() - .copied() - .filter(|agent| command_exists(agent.metadata().binary)) - .collect() -} - fn resolve_workflow_selection(requested: &[WorkflowArg]) -> Vec { if requested.is_empty() || requested.contains(&WorkflowArg::All) { return ALL_WORKFLOWS.to_vec(); @@ -4789,42 +4056,6 @@ 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(), "already configured"), - 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, @@ -5599,15 +4830,6 @@ mod tests { assert!(!task.contains("{INSTALL_SDK_CONTEXT}")); } - #[test] - fn prompted_instrument_workflows_always_include_instrument_when_non_empty() { - let resolved = resolve_prompted_instrument_workflows(vec![WorkflowArg::Evaluate]); - assert_eq!( - resolved, - vec![WorkflowArg::Instrument, WorkflowArg::Evaluate] - ); - } - #[test] fn pick_agent_mode_target_prefers_codex() { let selected = pick_agent_mode_target(&[Agent::Claude, Agent::Codex, Agent::Cursor]); @@ -6357,58 +5579,6 @@ mod tests { assert!(matches!(scope, InstallScope::Global)); } - #[test] - fn maybe_init_preserves_existing_extra_config_fields() { - let _guard = cwd_test_lock().lock().expect("lock cwd test"); - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos(); - let root = env::temp_dir().join(format!("bt-maybe-init-extra-{unique}")); - let bt_dir = root.join(".bt"); - fs::create_dir_all(&bt_dir).expect("create .bt dir"); - fs::write( - bt_dir.join("config.json"), - r#"{ - "org": "old-org", - "project": "old-project", - "project_id": "old-project-id", - "custom_flag": true, - "nested": { - "keep": "me" - } -} -"#, - ) - .expect("write config"); - - let old = env::current_dir().expect("cwd"); - env::set_current_dir(&root).expect("cd root"); - - let project = crate::projects::api::Project { - id: "project-id".to_string(), - name: "new-project".to_string(), - org_id: "org-id".to_string(), - description: None, - }; - maybe_init("new-org", &project).expect("maybe init"); - - env::set_current_dir(old).expect("restore cwd"); - - let saved = config::load_file(&bt_dir.join("config.json")); - assert_eq!(saved.org.as_deref(), Some("new-org")); - assert_eq!(saved.project.as_deref(), Some("new-project")); - assert_eq!(saved.project_id.as_deref(), Some("project-id")); - assert_eq!( - saved.extra.get("custom_flag"), - Some(&serde_json::Value::Bool(true)) - ); - assert_eq!( - saved.extra.get("nested"), - Some(&serde_json::json!({ "keep": "me" })) - ); - } - #[test] fn install_mcp_for_agent_writes_opencode_local_config_file() { let unique = SystemTime::now() diff --git a/src/setup/wizard/agents.rs b/src/setup/wizard/agents.rs new file mode 100644 index 00000000..b792ecbc --- /dev/null +++ b/src/setup/wizard/agents.rs @@ -0,0 +1,751 @@ +// Vendored from https://github.com/vercel-labs/skills (MIT, v1.5.7), `src/agents.ts` +// via spark-raindrop's `packages/spark/src/skills-vendor/agents.ts`. License: MIT. + +use std::path::{Path, PathBuf}; + +pub struct AgentConfig { + pub name: &'static str, + pub display_name: &'static str, + pub skills_dir: &'static str, + pub global_skills_dir: GlobalSkillsDir, + pub detect: Detect, +} + +#[allow(dead_code)] +pub enum GlobalSkillsDir { + Join(&'static [DirPart]), + OpenClaw, + None, +} + +pub enum DirPart { + Home, + ConfigHome, + ClaudeHome, + CodexHome, + VibeHome, + Sub(&'static str), +} + +#[allow(dead_code)] +pub enum Detect { + PathExists(&'static [DirPart]), + OpenClaw, + Codex, + Cwd(&'static [&'static str]), + HomeOrCwd { + home: &'static [DirPart], + cwd_segments: &'static [&'static str], + }, + AnyHome(&'static [&'static [DirPart]]), + Never, +} + +#[derive(Clone)] +pub struct Resolved { + pub home: PathBuf, + pub config_home: PathBuf, + pub claude_home: PathBuf, + pub codex_home: PathBuf, + pub vibe_home: PathBuf, +} + +impl Resolved { + pub fn new(home: PathBuf) -> Self { + let config_home = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".config")); + let claude_home = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".claude")); + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".codex")); + let vibe_home = std::env::var_os("VIBE_HOME") + .map(PathBuf::from) + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or_else(|| home.join(".vibe")); + Self { + home, + config_home, + claude_home, + codex_home, + vibe_home, + } + } + + fn resolve_parts(&self, parts: &[DirPart]) -> PathBuf { + let mut out = PathBuf::new(); + for part in parts { + match part { + DirPart::Home => out.push(&self.home), + DirPart::ConfigHome => out.push(&self.config_home), + DirPart::ClaudeHome => out.push(&self.claude_home), + DirPart::CodexHome => out.push(&self.codex_home), + DirPart::VibeHome => out.push(&self.vibe_home), + DirPart::Sub(s) => out.push(s), + } + } + out + } + + pub fn openclaw_global_skills_dir(&self) -> PathBuf { + if self.home.join(".openclaw").exists() { + return self.home.join(".openclaw/skills"); + } + if self.home.join(".clawdbot").exists() { + return self.home.join(".clawdbot/skills"); + } + if self.home.join(".moltbot").exists() { + return self.home.join(".moltbot/skills"); + } + self.home.join(".openclaw/skills") + } +} + +impl AgentConfig { + pub fn global_skills_dir(&self, resolved: &Resolved) -> Option { + match self.global_skills_dir { + GlobalSkillsDir::Join(parts) => Some(resolved.resolve_parts(parts)), + GlobalSkillsDir::OpenClaw => Some(resolved.openclaw_global_skills_dir()), + GlobalSkillsDir::None => None, + } + } + + pub fn detect_installed(&self, resolved: &Resolved, cwd: &Path) -> bool { + match self.detect { + Detect::PathExists(parts) => resolved.resolve_parts(parts).exists(), + Detect::OpenClaw => { + resolved.home.join(".openclaw").exists() + || resolved.home.join(".clawdbot").exists() + || resolved.home.join(".moltbot").exists() + } + Detect::Codex => resolved.codex_home.exists() || Path::new("/etc/codex").exists(), + Detect::Cwd(segments) => exists_in(cwd, segments), + Detect::HomeOrCwd { home, cwd_segments } => { + exists_in(cwd, cwd_segments) || resolved.resolve_parts(home).exists() + } + Detect::AnyHome(options) => options + .iter() + .any(|parts| resolved.resolve_parts(parts).exists()), + Detect::Never => false, + } + } +} + +fn exists_in(base: &Path, segments: &[&str]) -> bool { + let mut path = base.to_path_buf(); + for s in segments { + path.push(s); + } + path.exists() +} + +pub fn detect_installed_agents(resolved: &Resolved, cwd: &Path) -> Vec<&'static AgentConfig> { + AGENTS + .iter() + .filter(|a| a.detect_installed(resolved, cwd)) + .collect() +} + +#[cfg(test)] +pub fn find_agent(name: &str) -> Option<&'static AgentConfig> { + AGENTS.iter().find(|a| a.name == name) +} + +macro_rules! parts { + ($($e:expr),* $(,)?) => { &[$($e),*] }; +} + +pub static AGENTS: &[AgentConfig] = &[ + AgentConfig { + name: "aider-desk", + display_name: "AiderDesk", + skills_dir: ".aider-desk/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".aider-desk/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".aider-desk")]), + }, + AgentConfig { + name: "amp", + display_name: "Amp", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("agents/skills") + ]), + detect: Detect::PathExists(parts![DirPart::ConfigHome, DirPart::Sub("amp")]), + }, + AgentConfig { + name: "antigravity", + display_name: "Antigravity", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".gemini/antigravity/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".gemini/antigravity")]), + }, + AgentConfig { + name: "augment", + display_name: "Augment", + skills_dir: ".augment/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".augment/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".augment")]), + }, + AgentConfig { + name: "bob", + display_name: "IBM Bob", + skills_dir: ".bob/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".bob/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".bob")]), + }, + AgentConfig { + name: "claude-code", + display_name: "Claude Code", + skills_dir: ".claude/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ClaudeHome, + DirPart::Sub("skills") + ]), + detect: Detect::PathExists(parts![DirPart::ClaudeHome]), + }, + AgentConfig { + name: "openclaw", + display_name: "OpenClaw", + skills_dir: "skills", + global_skills_dir: GlobalSkillsDir::OpenClaw, + detect: Detect::OpenClaw, + }, + AgentConfig { + name: "cline", + display_name: "Cline", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".agents/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".cline")]), + }, + AgentConfig { + name: "codearts-agent", + display_name: "CodeArts Agent", + skills_dir: ".codeartsdoer/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".codeartsdoer/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".codeartsdoer")]), + }, + AgentConfig { + name: "codebuddy", + display_name: "CodeBuddy", + skills_dir: ".codebuddy/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".codebuddy/skills") + ]), + detect: Detect::HomeOrCwd { + home: parts![DirPart::Home, DirPart::Sub(".codebuddy")], + cwd_segments: &[".codebuddy"], + }, + }, + AgentConfig { + name: "codemaker", + display_name: "Codemaker", + skills_dir: ".codemaker/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".codemaker/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".codemaker")]), + }, + AgentConfig { + name: "codestudio", + display_name: "Code Studio", + skills_dir: ".codestudio/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".codestudio/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".codestudio")]), + }, + AgentConfig { + name: "codex", + display_name: "Codex", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::CodexHome, + DirPart::Sub("skills") + ]), + detect: Detect::Codex, + }, + AgentConfig { + name: "command-code", + display_name: "Command Code", + skills_dir: ".commandcode/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".commandcode/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".commandcode")]), + }, + AgentConfig { + name: "continue", + display_name: "Continue", + skills_dir: ".continue/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".continue/skills") + ]), + detect: Detect::HomeOrCwd { + home: parts![DirPart::Home, DirPart::Sub(".continue")], + cwd_segments: &[".continue"], + }, + }, + AgentConfig { + name: "cortex", + display_name: "Cortex Code", + skills_dir: ".cortex/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".snowflake/cortex/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".snowflake/cortex")]), + }, + AgentConfig { + name: "crush", + display_name: "Crush", + skills_dir: ".crush/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".config/crush/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".config/crush")]), + }, + AgentConfig { + name: "cursor", + display_name: "Cursor", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".cursor/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".cursor")]), + }, + AgentConfig { + name: "deepagents", + display_name: "Deep Agents", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".deepagents/agent/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".deepagents")]), + }, + AgentConfig { + name: "devin", + display_name: "Devin for Terminal", + skills_dir: ".devin/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("devin/skills") + ]), + detect: Detect::PathExists(parts![DirPart::ConfigHome, DirPart::Sub("devin")]), + }, + AgentConfig { + name: "dexto", + display_name: "Dexto", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".agents/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".dexto")]), + }, + AgentConfig { + name: "droid", + display_name: "Droid", + skills_dir: ".factory/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".factory/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".factory")]), + }, + AgentConfig { + name: "firebender", + display_name: "Firebender", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".firebender/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".firebender")]), + }, + AgentConfig { + name: "forgecode", + display_name: "ForgeCode", + skills_dir: ".forge/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".forge/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".forge")]), + }, + AgentConfig { + name: "gemini-cli", + display_name: "Gemini CLI", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".gemini/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".gemini")]), + }, + AgentConfig { + name: "github-copilot", + display_name: "GitHub Copilot", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".copilot/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".copilot")]), + }, + AgentConfig { + name: "goose", + display_name: "Goose", + skills_dir: ".goose/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("goose/skills") + ]), + detect: Detect::PathExists(parts![DirPart::ConfigHome, DirPart::Sub("goose")]), + }, + AgentConfig { + name: "hermes-agent", + display_name: "Hermes Agent", + skills_dir: ".hermes/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".hermes/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".hermes")]), + }, + AgentConfig { + name: "junie", + display_name: "Junie", + skills_dir: ".junie/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".junie/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".junie")]), + }, + AgentConfig { + name: "iflow-cli", + display_name: "iFlow CLI", + skills_dir: ".iflow/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".iflow/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".iflow")]), + }, + AgentConfig { + name: "kilo", + display_name: "Kilo Code", + skills_dir: ".kilocode/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".kilocode/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".kilocode")]), + }, + AgentConfig { + name: "kimi-cli", + display_name: "Kimi Code CLI", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".config/agents/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".kimi")]), + }, + AgentConfig { + name: "kiro-cli", + display_name: "Kiro CLI", + skills_dir: ".kiro/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".kiro/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".kiro")]), + }, + AgentConfig { + name: "kode", + display_name: "Kode", + skills_dir: ".kode/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".kode/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".kode")]), + }, + AgentConfig { + name: "mcpjam", + display_name: "MCPJam", + skills_dir: ".mcpjam/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".mcpjam/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".mcpjam")]), + }, + AgentConfig { + name: "mistral-vibe", + display_name: "Mistral Vibe", + skills_dir: ".vibe/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![DirPart::VibeHome, DirPart::Sub("skills")]), + detect: Detect::PathExists(parts![DirPart::VibeHome]), + }, + AgentConfig { + name: "mux", + display_name: "Mux", + skills_dir: ".mux/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".mux/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".mux")]), + }, + AgentConfig { + name: "opencode", + display_name: "OpenCode", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("opencode/skills") + ]), + detect: Detect::PathExists(parts![DirPart::ConfigHome, DirPart::Sub("opencode")]), + }, + AgentConfig { + name: "openhands", + display_name: "OpenHands", + skills_dir: ".openhands/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".openhands/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".openhands")]), + }, + AgentConfig { + name: "pi", + display_name: "Pi", + skills_dir: ".pi/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".pi/agent/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".pi/agent")]), + }, + AgentConfig { + name: "qoder", + display_name: "Qoder", + skills_dir: ".qoder/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".qoder/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".qoder")]), + }, + AgentConfig { + name: "qwen-code", + display_name: "Qwen Code", + skills_dir: ".qwen/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".qwen/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".qwen")]), + }, + AgentConfig { + name: "replit", + display_name: "Replit", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("agents/skills") + ]), + detect: Detect::Cwd(&[".replit"]), + }, + AgentConfig { + name: "rovodev", + display_name: "Rovo Dev", + skills_dir: ".rovodev/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".rovodev/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".rovodev")]), + }, + AgentConfig { + name: "roo", + display_name: "Roo Code", + skills_dir: ".roo/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".roo/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".roo")]), + }, + AgentConfig { + name: "tabnine-cli", + display_name: "Tabnine CLI", + skills_dir: ".tabnine/agent/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".tabnine/agent/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".tabnine")]), + }, + AgentConfig { + name: "trae", + display_name: "Trae", + skills_dir: ".trae/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".trae/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".trae")]), + }, + AgentConfig { + name: "trae-cn", + display_name: "Trae CN", + skills_dir: ".trae/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".trae-cn/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".trae-cn")]), + }, + AgentConfig { + name: "warp", + display_name: "Warp", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".agents/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".warp")]), + }, + AgentConfig { + name: "windsurf", + display_name: "Windsurf", + skills_dir: ".windsurf/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".codeium/windsurf/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".codeium/windsurf")]), + }, + AgentConfig { + name: "zencoder", + display_name: "Zencoder", + skills_dir: ".zencoder/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".zencoder/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".zencoder")]), + }, + AgentConfig { + name: "neovate", + display_name: "Neovate", + skills_dir: ".neovate/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".neovate/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".neovate")]), + }, + AgentConfig { + name: "pochi", + display_name: "Pochi", + skills_dir: ".pochi/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".pochi/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".pochi")]), + }, + AgentConfig { + name: "adal", + display_name: "AdaL", + skills_dir: ".adal/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::Home, + DirPart::Sub(".adal/skills") + ]), + detect: Detect::PathExists(parts![DirPart::Home, DirPart::Sub(".adal")]), + }, + AgentConfig { + name: "universal", + display_name: "Universal", + skills_dir: ".agents/skills", + global_skills_dir: GlobalSkillsDir::Join(parts![ + DirPart::ConfigHome, + DirPart::Sub("agents/skills") + ]), + detect: Detect::Never, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn detect_claude_code_via_claude_home() { + let home = tempdir().unwrap(); + std::fs::create_dir_all(home.path().join(".claude")).unwrap(); + let resolved = Resolved::new(home.path().to_path_buf()); + let cwd = tempdir().unwrap(); + let claude = find_agent("claude-code").unwrap(); + assert!(claude.detect_installed(&resolved, cwd.path())); + let dir = claude.global_skills_dir(&resolved).unwrap(); + assert!(dir.ends_with(".claude/skills")); + } + + #[test] + fn detect_replit_via_cwd_marker() { + let home = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + std::fs::write(cwd.path().join(".replit"), "").unwrap(); + let resolved = Resolved::new(home.path().to_path_buf()); + let replit = find_agent("replit").unwrap(); + assert!(replit.detect_installed(&resolved, cwd.path())); + } + + #[test] + fn universal_never_detected() { + let home = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let resolved = Resolved::new(home.path().to_path_buf()); + let universal = find_agent("universal").unwrap(); + assert!(!universal.detect_installed(&resolved, cwd.path())); + } +} diff --git a/src/setup/wizard/auth.rs b/src/setup/wizard/auth.rs new file mode 100644 index 00000000..375f6720 --- /dev/null +++ b/src/setup/wizard/auth.rs @@ -0,0 +1,149 @@ +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use reqwest::Client; +use serde::Deserialize; +use tokio::time::sleep; + +const POLL_INTERVAL: Duration = Duration::from_secs(2); +const SLOW_DOWN_INCREMENT: Duration = Duration::from_secs(1); +const MAX_POLL_INTERVAL: Duration = Duration::from_secs(30); +const POLL_HARD_TIMEOUT: Duration = Duration::from_secs(3 * 60); +const CREATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); +const POLL_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Deserialize)] +pub struct WizardSessionCreateResponse { + pub session_token: String, + pub poll_token: String, + #[allow(dead_code)] + pub expires_at: String, + pub login_path: String, + pub verification_code: String, +} + +#[derive(Debug, Clone)] +pub struct WizardSessionComplete { + pub api_key: String, + #[allow(dead_code)] + pub org_id: String, + pub org_name: String, + pub project_id: String, + pub project_name: String, +} + +pub struct WizardSessionAuthClient { + http: Client, + app_url: String, +} + +impl WizardSessionAuthClient { + pub fn new(http: Client, app_url: impl Into) -> Self { + let mut app_url = app_url.into(); + while app_url.ends_with('/') { + app_url.pop(); + } + Self { http, app_url } + } + + pub async fn create_session(&self) -> Result { + let url = format!("{}/api/cli/wizard-session/create", self.app_url); + let res = self + .http + .post(&url) + .header("Accept", "application/json") + .timeout(CREATE_REQUEST_TIMEOUT) + .send() + .await + .with_context(|| format!("POST {url}"))?; + if !res.status().is_success() { + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + bail!("Wizard session create failed: {status} {body}"); + } + res.json::() + .await + .context("parsing wizard session create response") + } + + pub fn build_login_url(&self, session: &WizardSessionCreateResponse) -> String { + let path = if session.login_path.starts_with('/') { + session.login_path.clone() + } else { + format!("/{}", session.login_path) + }; + format!("{}{}", self.app_url, path) + } + + pub async fn poll_session( + &self, + session_token: &str, + poll_token: &str, + ) -> Result { + let url = format!( + "{}/api/cli/wizard-session/poll?session_token={}", + self.app_url, + urlencoding::encode(session_token) + ); + let deadline = std::time::Instant::now() + POLL_HARD_TIMEOUT; + let mut interval = POLL_INTERVAL; + + while std::time::Instant::now() < deadline { + sleep(interval).await; + let res = self + .http + .get(&url) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {poll_token}")) + .timeout(POLL_REQUEST_TIMEOUT) + .send() + .await + .with_context(|| format!("GET {url}"))?; + if res.status().as_u16() == 429 { + interval = (interval + SLOW_DOWN_INCREMENT).min(MAX_POLL_INTERVAL); + continue; + } + let status = res.status(); + let body = res.text().await.unwrap_or_default(); + if !status.is_success() { + bail!("Wizard session poll failed: {status} {body}"); + } + let json: serde_json::Value = serde_json::from_str(&body) + .with_context(|| format!("parsing poll response: {body}"))?; + match json.get("status").and_then(|v| v.as_str()) { + Some("pending") => {} + Some("expired") => bail!("Wizard session expired before approval."), + Some("claimed") => bail!("Wizard session was already claimed by another client."), + Some("complete") => return parse_complete(&json), + other => bail!( + "Unexpected wizard session status: {} (body: {body})", + other.unwrap_or("") + ), + } + } + Err(anyhow!("Wizard session timed out.")) + } +} + +fn parse_complete(json: &serde_json::Value) -> Result { + let api_key = require_string(json, "api_key")?; + let org_id = require_string(json, "org_id")?; + let org_name = require_string(json, "org_name")?; + let project_id = require_string(json, "project_id")?; + let project_name = require_string(json, "project_name")?; + Ok(WizardSessionComplete { + api_key, + org_id, + org_name, + project_id, + project_name, + }) +} + +fn require_string(json: &serde_json::Value, key: &str) -> Result { + json.get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow!("Wizard session complete response missing field `{key}`")) +} diff --git a/src/setup/wizard/copy.rs b/src/setup/wizard/copy.rs new file mode 100644 index 00000000..435efa08 --- /dev/null +++ b/src/setup/wizard/copy.rs @@ -0,0 +1,52 @@ +pub const WIZARD_TITLE: &str = "Braintrust Setup"; + +pub const NOT_GIT_REPO_WARNING: &str = + "Heads up: this folder is not a git repository. The wizard may edit files; consider running it inside a checked-in repo."; + +pub const DOCS_URL: &str = "https://www.braintrust.dev/docs"; + +pub const WIZARD_CANCEL_MESSAGE: &str = "Setup cancelled."; + +pub fn terminal_hyperlink(url: &str) -> String { + // Emit an OSC 8 hyperlink when the terminal advertises support; otherwise + // print the URL as plain text. Detection is via `supports-hyperlinks`. + if supports_hyperlinks::on(supports_hyperlinks::Stream::Stderr) { + format!("\x1b]8;;{url}\x1b\\{url}\x1b]8;;\x1b\\") + } else { + url.to_string() + } +} + +pub fn wizard_login_prompt(verification_code: &str) -> String { + let code = dialoguer::console::style(verification_code) + .color256(231) + .bold(); + format!( + "Open the URL above in your browser to finish signing in.\n\nAfter signing in, verify this code matches the one shown in your browser: {code}\n\nPick the org and project you want to use; the wizard will resume here." + ) +} + +pub fn skill_next_step_hint(agent_display_name: Option<&str>) -> String { + let action = dialoguer::console::style("run the /instrument-code skill") + .red() + .bright(); + match agent_display_name { + Some(name) => format!("Open {name} in this repo and {action}."), + None => format!("Open your coding agent in this repo and {action}."), + } +} + +pub fn no_agent_fallback_note(path: &str) -> String { + format!("No coding agent detected on this machine. Wrote the instrument-code prompt to:\n {path}\nPaste it into your agent of choice.") +} + +pub fn build_cleanup_message(docs_url: &str) -> String { + let mut lines = vec![ + "Setup complete.".to_string(), + String::new(), + "For production runs, set the BRAINTRUST_API_KEY environment variable.".to_string(), + format!("Docs: {docs_url}"), + ]; + lines.retain(|_| true); + lines.join("\n") +} diff --git a/src/setup/wizard/env_file.rs b/src/setup/wizard/env_file.rs new file mode 100644 index 00000000..320bfc91 --- /dev/null +++ b/src/setup/wizard/env_file.rs @@ -0,0 +1,197 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use ignore::gitignore::GitignoreBuilder; +use serde::Serialize; + +const ENV_FILENAME: &str = ".env.braintrust"; +const BT_DIR: &str = ".bt"; +const BT_CONFIG_FILE: &str = "config.json"; + +pub struct EnvFileWriteResult { + pub env_file_path: PathBuf, + #[allow(dead_code)] + pub gitignore_path: PathBuf, + pub added_to_gitignore: bool, + pub already_covered: bool, +} + +pub fn write_env_braintrust(git_root: &Path, api_key: &str) -> Result { + let env_file_path = git_root.join(ENV_FILENAME); + write_file_mode_600( + &env_file_path, + format!("BRAINTRUST_API_KEY={api_key}\n").as_bytes(), + ) + .with_context(|| format!("writing {}", env_file_path.display()))?; + + let gitignore_path = git_root.join(".gitignore"); + let existing = read_to_string_if_exists(&gitignore_path)?; + + if gitignore_covers(&existing, ENV_FILENAME) { + return Ok(EnvFileWriteResult { + env_file_path, + gitignore_path, + added_to_gitignore: false, + already_covered: true, + }); + } + + let sep = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + fs::write(&gitignore_path, format!("{existing}{sep}{ENV_FILENAME}\n")) + .with_context(|| format!("writing {}", gitignore_path.display()))?; + + Ok(EnvFileWriteResult { + env_file_path, + gitignore_path, + added_to_gitignore: true, + already_covered: false, + }) +} + +#[derive(Debug, Serialize)] +pub struct BtConfig<'a> { + pub org: &'a str, + pub project: &'a str, + pub project_id: &'a str, +} + +pub fn write_bt_config(git_root: &Path, config: &BtConfig<'_>) -> Result<()> { + let dir = git_root.join(BT_DIR); + fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?; + let config_path = dir.join(BT_CONFIG_FILE); + + let payload = serde_json::json!({ + "profile": serde_json::Value::Null, + "org": config.org, + "project": config.project, + "project_id": config.project_id, + }); + let mut body = + serde_json::to_string_pretty(&payload).context("serializing .bt/config.json payload")?; + body.push('\n'); + fs::write(&config_path, body).with_context(|| format!("writing {}", config_path.display()))?; + + let gitignore_path = git_root.join(".gitignore"); + let existing = read_to_string_if_exists(&gitignore_path)?; + let target = format!("{BT_DIR}/{BT_CONFIG_FILE}"); + if !gitignore_covers(&existing, &target) { + let sep = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + fs::write(&gitignore_path, format!("{existing}{sep}{BT_DIR}/\n")) + .with_context(|| format!("writing {}", gitignore_path.display()))?; + } + + Ok(()) +} + +pub fn gitignore_covers(content: &str, filename: &str) -> bool { + let mut builder = GitignoreBuilder::new(""); + for line in content.lines() { + let _ = builder.add_line(None, line); + } + let Ok(gi) = builder.build() else { + return false; + }; + // matched_path_or_any_parents walks parent dirs so a directory pattern + // like `.bt/` correctly covers `.bt/config.json`. + gi.matched_path_or_any_parents(filename, false).is_ignore() +} + +fn read_to_string_if_exists(path: &Path) -> Result { + match fs::read_to_string(path) { + Ok(s) => Ok(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(e).with_context(|| format!("reading {}", path.display())), + } +} + +#[cfg(unix)] +fn write_file_mode_600(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + use std::os::unix::fs::OpenOptionsExt; + let mut file = fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(path)?; + file.write_all(bytes) +} + +#[cfg(not(unix))] +fn write_file_mode_600(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + fs::write(path, bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn write_env_braintrust_creates_file_and_appends_gitignore() { + let dir = tempdir().unwrap(); + let result = write_env_braintrust(dir.path(), "sk-test").unwrap(); + let contents = fs::read_to_string(&result.env_file_path).unwrap(); + assert_eq!(contents, "BRAINTRUST_API_KEY=sk-test\n"); + let gi = fs::read_to_string(&result.gitignore_path).unwrap(); + assert!(gi.contains(".env.braintrust")); + assert!(result.added_to_gitignore); + assert!(!result.already_covered); + } + + #[test] + fn write_env_braintrust_respects_existing_gitignore_coverage() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".gitignore"), "*.braintrust\n").unwrap(); + let result = write_env_braintrust(dir.path(), "sk-test").unwrap(); + assert!(result.already_covered); + assert!(!result.added_to_gitignore); + } + + #[test] + fn write_bt_config_writes_json_and_ignores_dir() { + let dir = tempdir().unwrap(); + let cfg = BtConfig { + org: "acme", + project: "demo", + project_id: "p_123", + }; + write_bt_config(dir.path(), &cfg).unwrap(); + let body = fs::read_to_string(dir.path().join(".bt/config.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(parsed["org"], "acme"); + assert_eq!(parsed["project"], "demo"); + assert_eq!(parsed["project_id"], "p_123"); + assert!(parsed["profile"].is_null()); + let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + assert!(gi.contains(".bt/")); + } + + #[test] + fn write_bt_config_is_idempotent_against_gitignore() { + let dir = tempdir().unwrap(); + let cfg = BtConfig { + org: "acme", + project: "demo", + project_id: "p_123", + }; + write_bt_config(dir.path(), &cfg).unwrap(); + write_bt_config(dir.path(), &cfg).unwrap(); + write_bt_config(dir.path(), &cfg).unwrap(); + let gi = fs::read_to_string(dir.path().join(".gitignore")).unwrap(); + let count = gi.matches(".bt/").count(); + assert_eq!( + count, 1, + "gitignore should contain .bt/ exactly once, got:\n{gi}" + ); + } +} diff --git a/src/setup/wizard/language.rs b/src/setup/wizard/language.rs new file mode 100644 index 00000000..4f014c6a --- /dev/null +++ b/src/setup/wizard/language.rs @@ -0,0 +1,151 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum DetectedLanguage { + Python, + Typescript, + Go, + Java, + Ruby, + Csharp, +} + +impl DetectedLanguage { + pub fn slug(self) -> &'static str { + match self { + DetectedLanguage::Python => "python", + DetectedLanguage::Typescript => "typescript", + DetectedLanguage::Go => "go", + DetectedLanguage::Java => "java", + DetectedLanguage::Ruby => "ruby", + DetectedLanguage::Csharp => "csharp", + } + } + + pub fn display(self) -> &'static str { + match self { + DetectedLanguage::Python => "Python", + DetectedLanguage::Typescript => "TypeScript", + DetectedLanguage::Go => "Go", + DetectedLanguage::Java => "Java", + DetectedLanguage::Ruby => "Ruby", + DetectedLanguage::Csharp => "C#", + } + } + + pub fn all() -> &'static [DetectedLanguage] { + &[ + DetectedLanguage::Python, + DetectedLanguage::Typescript, + DetectedLanguage::Go, + DetectedLanguage::Java, + DetectedLanguage::Ruby, + DetectedLanguage::Csharp, + ] + } +} + +const FILENAME_INDICATORS: &[(&str, DetectedLanguage)] = &[ + ("pyproject.toml", DetectedLanguage::Python), + ("setup.py", DetectedLanguage::Python), + ("requirements.txt", DetectedLanguage::Python), + ("package.json", DetectedLanguage::Typescript), + ("tsconfig.json", DetectedLanguage::Typescript), + ("go.mod", DetectedLanguage::Go), + ("pom.xml", DetectedLanguage::Java), + ("build.gradle", DetectedLanguage::Java), + ("build.gradle.kts", DetectedLanguage::Java), + ("Gemfile", DetectedLanguage::Ruby), +]; + +const EXTENSION_INDICATORS: &[(&str, DetectedLanguage)] = &[ + (".csproj", DetectedLanguage::Csharp), + (".sln", DetectedLanguage::Csharp), + (".gemspec", DetectedLanguage::Ruby), +]; + +pub fn detect_languages(dir: &Path) -> Vec { + let mut found: BTreeSet = BTreeSet::new(); + scan(dir, &mut found); + if found.is_empty() { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) { + scan(&entry.path(), &mut found); + } + } + } + } + found.into_iter().collect() +} + +fn scan(dir: &Path, found: &mut BTreeSet) { + let Ok(entries) = fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_file() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { continue }; + let lower = name.to_ascii_lowercase(); + for (indicator, lang) in FILENAME_INDICATORS { + if lower == indicator.to_ascii_lowercase() { + found.insert(*lang); + } + } + for (ext, lang) in EXTENSION_INDICATORS { + if lower.ends_with(&ext.to_ascii_lowercase()) { + found.insert(*lang); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn detects_python_via_pyproject() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("pyproject.toml"), "").unwrap(); + let langs = detect_languages(dir.path()); + assert_eq!(langs, vec![DetectedLanguage::Python]); + } + + #[test] + fn detects_typescript_and_go_together() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("package.json"), "{}").unwrap(); + fs::write(dir.path().join("go.mod"), "module x").unwrap(); + let langs = detect_languages(dir.path()); + assert!(langs.contains(&DetectedLanguage::Typescript)); + assert!(langs.contains(&DetectedLanguage::Go)); + } + + #[test] + fn recurses_one_level_when_top_level_empty() { + let dir = tempdir().unwrap(); + let sub = dir.path().join("svc"); + fs::create_dir_all(&sub).unwrap(); + fs::write(sub.join("Gemfile"), "").unwrap(); + let langs = detect_languages(dir.path()); + assert_eq!(langs, vec![DetectedLanguage::Ruby]); + } + + #[test] + fn detects_csharp_via_extension() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("MyApp.csproj"), "").unwrap(); + let langs = detect_languages(dir.path()); + assert_eq!(langs, vec![DetectedLanguage::Csharp]); + } +} diff --git a/src/setup/wizard/mod.rs b/src/setup/wizard/mod.rs new file mode 100644 index 00000000..e44c748f --- /dev/null +++ b/src/setup/wizard/mod.rs @@ -0,0 +1,316 @@ +use std::collections::HashSet; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{anyhow, bail, Context, Result}; +use clap::Args; + +use crate::args::{BaseArgs, DEFAULT_API_URL, DEFAULT_APP_URL}; +use crate::http::build_http_client; +use dialoguer::console::style; + +mod agents; +mod auth; +mod copy; +mod env_file; +mod language; +mod prompt; +mod skill_install; + +use auth::{WizardSessionAuthClient, WizardSessionComplete}; +use copy::{ + build_cleanup_message, no_agent_fallback_note, skill_next_step_hint, terminal_hyperlink, + wizard_login_prompt, DOCS_URL, NOT_GIT_REPO_WARNING, WIZARD_CANCEL_MESSAGE, WIZARD_TITLE, +}; +use env_file::{write_bt_config, write_env_braintrust, BtConfig}; +use language::{detect_languages, DetectedLanguage}; +use skill_install::{ + install_instrument_code_skill, write_fallback_prompt, InstallOptions, InstallResult, +}; + +const POLL_TIMEOUT_MARGIN: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, Args)] +pub struct WizardArgs { + /// Skip installing the instrument-code skill; write a fallback prompt file instead. + #[arg(long, env = "BRAINTRUST_SETUP_NO_INSTALL_SKILL")] + pub no_install_skill: bool, + + /// Agent id(s) to exclude from skill install. Repeatable. + #[arg( + long = "skip-agent", + env = "BRAINTRUST_SETUP_SKIP_AGENTS", + value_delimiter = ',' + )] + pub skip_agents: Vec, + + /// Override the skill install directory. Writes one SKILL.md into /instrument-code/. + #[arg(long, env = "BRAINTRUST_SETUP_SKILL_DIR")] + pub skill_dir: Option, + + /// Pair with --api-key to skip device-code login (CI path). + #[arg(long, env = "BRAINTRUST_SETUP_PROJECT_ID")] + pub project_id: Option, +} + +pub async fn run(base: BaseArgs, args: WizardArgs) -> Result<()> { + if base.json { + bail!("`bt setup` is interactive and incompatible with --json. use: a subcommand (skills, instrument, mcp, doctor) or run without --json."); + } + + let cwd = std::env::current_dir().context("resolving current directory")?; + 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 app_url = strip_trailing_slash(&app_url).to_string(); + let api_url = strip_trailing_slash(&api_url).to_string(); + + cliclack::set_theme(WizardTheme); + cliclack::intro(WIZARD_TITLE).context("rendering intro")?; + + let git_root = find_git_root(&cwd); + if git_root.is_none() { + cliclack::log::warning(NOT_GIT_REPO_WARNING).ok(); + } + + let session = match login(&base, &args, &api_url, &app_url).await { + Ok(s) => s, + Err(err) => { + cliclack::outro_cancel(format!("{WIZARD_CANCEL_MESSAGE} {err}")).ok(); + return Err(err); + } + }; + + cliclack::log::success(format!( + "Browser setup complete.\n org: {}\n project: {}", + style(&session.org_name).green().bright(), + style(&session.project_name).green().bright() + )) + .ok(); + + if let Some(root) = &git_root { + let env_result = write_env_braintrust(root, &session.api_key)?; + cliclack::log::info(format!( + "Wrote BRAINTRUST_API_KEY to {}.", + display_relative(&env_result.env_file_path, &cwd) + )) + .ok(); + if env_result.added_to_gitignore { + cliclack::log::info("Added .env.braintrust to .gitignore.").ok(); + } else if !env_result.already_covered { + cliclack::log::info(".gitignore unchanged.").ok(); + } + + let cfg = BtConfig { + org: &session.org_name, + project: &session.project_name, + project_id: &session.project_id, + }; + write_bt_config(root, &cfg)?; + } else { + cliclack::log::info(format!( + "BRAINTRUST_API_KEY={}\nNot in a git repo — set this in your environment manually.", + session.api_key + )) + .ok(); + } + + let languages = detect_languages(&cwd); + + if args.no_install_skill { + match write_fallback_prompt(&languages) { + Ok(fallback) => { + cliclack::note( + "Skill", + no_agent_fallback_note(&fallback.path.display().to_string()), + ) + .ok(); + } + Err(err) => { + cliclack::log::warning(format!("Couldn't write fallback prompt: {err}")).ok(); + } + } + } else { + let home = dirs::home_dir().ok_or_else(|| anyhow!("failed to resolve HOME"))?; + let install = install_instrument_code_skill(InstallOptions { + cwd: &cwd, + home, + languages: &languages, + skip_agents: &args.skip_agents, + override_dir: args.skill_dir.as_deref(), + })?; + report_install(&install, &languages)?; + } + + cliclack::outro(build_cleanup_message(DOCS_URL)).ok(); + Ok(()) +} + +async fn login( + base: &BaseArgs, + args: &WizardArgs, + api_url: &str, + app_url: &str, +) -> Result { + if let (Some(api_key), Some(project_id)) = (base.api_key.as_deref(), args.project_id.as_deref()) + { + return login_ci(api_url, api_key, project_id).await; + } + if base.no_input { + bail!("credentials required for --no-input mode. use: --api-key and --project-id (env: BRAINTRUST_API_KEY, BRAINTRUST_SETUP_PROJECT_ID), or run from a TTY."); + } + if !std::io::stdin().is_terminal() { + bail!("TTY required for interactive login. use: --api-key and --project-id for non-interactive use, or run from a TTY."); + } + + let http = build_http_client(Duration::from_secs(60))?; + let client = WizardSessionAuthClient::new(http, app_url); + let session = client.create_session().await?; + let login_url = client.build_login_url(&session); + + cliclack::log::info(terminal_hyperlink(&login_url)).ok(); + cliclack::note("Login", wizard_login_prompt(&session.verification_code)).ok(); + let _ = open::that_detached(&login_url); + + let spinner = cliclack::spinner(); + spinner.start("Waiting for browser login…"); + let result = tokio::time::timeout( + Duration::from_secs(3 * 60) + POLL_TIMEOUT_MARGIN, + client.poll_session(&session.session_token, &session.poll_token), + ) + .await; + match result { + Ok(Ok(complete)) => { + spinner.stop("Logged in."); + Ok(complete) + } + Ok(Err(err)) => { + spinner.error("Login failed."); + Err(err) + } + Err(_) => { + spinner.error("Login timed out."); + Err(anyhow!("Wizard session timed out.")) + } + } +} + +async fn login_ci(api_url: &str, api_key: &str, project_id: &str) -> Result { + let http = build_http_client(crate::http::DEFAULT_HTTP_TIMEOUT)?; + let project: serde_json::Value = http + .get(format!( + "{api_url}/v1/project/{}", + urlencoding::encode(project_id) + )) + .bearer_auth(api_key) + .send() + .await + .with_context(|| format!("GET {api_url}/v1/project/{project_id}"))? + .error_for_status() + .with_context(|| format!("looking up project {project_id}"))? + .json() + .await + .context("parsing project response")?; + let org_id = project + .get("org_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("project response missing org_id"))?; + let project_name = project + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("project response missing name"))? + .to_string(); + let org: serde_json::Value = http + .get(format!( + "{api_url}/v1/organization/{}", + urlencoding::encode(org_id) + )) + .bearer_auth(api_key) + .send() + .await + .with_context(|| format!("GET {api_url}/v1/organization/{org_id}"))? + .error_for_status() + .with_context(|| format!("looking up organization {org_id}"))? + .json() + .await + .context("parsing organization response")?; + let org_name = org + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("organization response missing name"))? + .to_string(); + Ok(WizardSessionComplete { + api_key: api_key.to_string(), + org_id: org_id.to_string(), + org_name, + project_id: project_id.to_string(), + project_name, + }) +} + +fn report_install(result: &InstallResult, languages: &[DetectedLanguage]) -> Result<()> { + if result.written.is_empty() { + let fallback = write_fallback_prompt(languages)?; + cliclack::note( + "Skill", + no_agent_fallback_note(&fallback.path.display().to_string()), + ) + .ok(); + return Ok(()); + } + let project_agents: HashSet<&'static str> = result + .written + .iter() + .filter(|w| w.scope == skill_install::InstallScope::Project) + .map(|w| w.display_name) + .collect::>(); + let single_agent = if project_agents.len() == 1 { + project_agents.iter().next().copied() + } else { + None + }; + cliclack::log::info(skill_next_step_hint(single_agent)).ok(); + Ok(()) +} + +fn find_git_root(start: &Path) -> Option { + let mut current = start.to_path_buf(); + loop { + if current.join(".git").exists() { + return Some(current); + } + if !current.pop() { + return None; + } + } +} + +fn strip_trailing_slash(url: &str) -> &str { + url.trim_end_matches('/') +} + +/// Theme override: cliclack's default dims note bodies via `input_style`, which +/// flattens our own foreground colors (verification code, etc.) to grey. This +/// theme keeps every default behavior except that dim. +struct WizardTheme; + +impl cliclack::Theme for WizardTheme { + fn input_style(&self, state: &cliclack::ThemeState) -> console::Style { + match state { + cliclack::ThemeState::Cancel => console::Style::new().strikethrough(), + _ => console::Style::new(), + } + } +} + +fn display_relative(path: &Path, cwd: &Path) -> String { + pathdiff::diff_paths(path, cwd) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| path.display().to_string()) +} diff --git a/src/setup/wizard/prompt.rs b/src/setup/wizard/prompt.rs new file mode 100644 index 00000000..309cd506 --- /dev/null +++ b/src/setup/wizard/prompt.rs @@ -0,0 +1,198 @@ +use super::language::DetectedLanguage; + +const SDK_INSTALL_DOCS_BASE: &str = "https://www.braintrust.dev/docs/instrument/trace-llm-calls"; + +const TEMPLATE: &str = r"# Braintrust SDK Installation (Agent Instructions) + +## Hard Rules + +- **Only add Braintrust code.** Do not refactor or modify unrelated code. +- **One language, one service per install run.** If the repo has more than one candidate, ask the user which one to instrument before starting. Do not instrument multiple languages or services in the same run. +- **If the language is unclear, ask the user.** Do not guess. See Step 2. +- **Install the latest Braintrust SDK.** Do not hard-pin the Braintrust SDK version unless the user asks for it -- use the package manager's normal install (which may produce an exact or a ranged version, whichever is idiomatic for that ecosystem). Build-time dependencies (e.g. Orchestrion for Go) must still be pinned to an exact version -- see the language-specific resource. +- **Set the project name in code.** Do NOT configure project name via env vars. +- **App must run without Braintrust.** If `BRAINTRUST_API_KEY` is missing at runtime, do not crash. +- **Do not guess APIs.** Use official documentation/examples only. +- **Do not add eval code** unless explicitly requested. +- **Do not add manual flush/shutdown logic** unless the app is a short-lived script, serverless function, Lambda, or CLI that exits immediately after LLM calls -- in which case a single `flush()` (or language equivalent) right before exit is correct, since otherwise traces get dropped. Do not add flush/shutdown for long-running processes (servers, daemons, workers). +- **If SDK is already installed/configured, do not duplicate work.** +- **Do not create setup-only files or directories in the repo.** Do not write `.bt/setup/`, `.bt/skills/docs/`, agent skill directories, or setup task files unless explicitly asked by the user. + +--- + +## Execution Requirements + +Before writing any code: + +1. Create a **checklist** from the steps below. +2. Execute each step in order. +3. Do not skip steps. + +--- + +## Steps + +{LANGUAGE_CONTEXT} + +--- + +{INSTALL_SDK_CONTEXT} + +--- + +### 4. Resolve Target Org/Project + +The repo contains `.bt/config.json` with `{ org, project, project_id }` set by the `bt setup` wizard. Read the **project name** from `bt status --json` (preferred) or fall back to reading `.bt/config.json` directly. Use that project name when configuring the SDK in code. Do not guess the project name from context. + +--- + +### 5. Verify Installation (MANDATORY) + +- If the SDK relies on build-time or launch-time auto-instrumentation, make sure the project's normal build/run path now uses it. A one-off verification command is not sufficient. +- Run the application. +- Confirm at least one log/trace is emitted to Braintrust. +- Confirm no runtime errors. +- Confirm the app still runs if `BRAINTRUST_API_KEY` is unset. + +If you do not know how to run the app, ask the user and wait for the response before proceeding. + +--- + +### 6. Final Summary + +Summarize: + +- What SDK version was installed +- Where code was modified +- What logs/traces were emitted +- The Braintrust permalink (required) + +## Latest Braintrust Setup Docs + +Use the canonical Braintrust docs at https://www.braintrust.dev/docs as the source of truth for SDK setup behavior. Prefer local `bt` CLI commands over direct API calls when verifying state. +"; + +const INSTALL_SDK_REQUIREMENTS: &str = "- Install the latest Braintrust SDK via the language's package manager. Do not hard-pin the SDK version unless the user asks. Build-time dependencies called out by the language-specific resource (e.g. Orchestrion for Go) must still be pinned to an exact version. +- Modify only dependency files, a minimal application entry point (e.g., main/bootstrap), and any existing build/run scripts or checked-in env/config that must change to keep auto-instrumentation active in normal use. Auto-instrument the app (except for Java and C# which don't support auto-instrumentation). +- Do not change unrelated code."; + +const DETECT_LANGUAGE_BLOCK: &str = "### 2. Detect Language + +**Instrument exactly one language/service per install run.** Do not install Braintrust for multiple languages or multiple services in the same run, even if the repo contains more than one. If more than one candidate exists, stop and ask the user which single service to instrument before doing anything else. + +Determine the project language using concrete signals: + +- `package.json` -> TypeScript +- `requirements.txt`, `setup.py` or `pyproject.toml` -> Python +- `pom.xml` or `build.gradle` -> Java +- `go.mod` -> Go +- `Gemfile` -> Ruby +- `.csproj` -> C# + +**If exactly one of these matches at the repo root and there is no ambiguity, proceed with that language.** + +In every other case, **stop and ask the user** before continuing. Do not guess, do not pick the \"most likely\" language, and do not instrument more than one."; + +pub const SKILL_NAME: &str = "instrument-code"; +pub const SKILL_DESCRIPTION: &str = + "Install the Braintrust SDK in this repo and verify a trace lands in Braintrust."; +pub const SKILL_WHEN_TO_USE: &str = + "User says \"instrument this repo\", \"set up Braintrust\", \"add traces\", or just ran `bt setup`."; + +pub fn render_skill_body(languages: &[DetectedLanguage]) -> String { + let (language_context, install_sdk_context) = match languages.len() { + 0 => { + let rows = DetectedLanguage::all() + .iter() + .map(|lang| { + format!( + "| {} | `{}#{}` |", + lang.display(), + SDK_INSTALL_DOCS_BASE, + lang.slug() + ) + }) + .collect::>() + .join("\n"); + let install = format!( + "### 3. Install SDK (Language-Specific)\n\nRead the install guide for the detected language from the canonical docs:\n\n| Language | Doc URL |\n| -------- | ------- |\n{rows}\n\nRequirements:\n\n{INSTALL_SDK_REQUIREMENTS}", + ); + (DETECT_LANGUAGE_BLOCK.to_string(), install) + } + 1 => { + let lang = languages[0]; + let context = format!( + "### 2. Language\n\nThe target language has been specified: **{}**.", + lang.display() + ); + let install = format!( + "### 3. Install SDK\n\nRead the install guide from the canonical docs: `{}#{}`\n\nRequirements:\n\n{INSTALL_SDK_REQUIREMENTS}", + SDK_INSTALL_DOCS_BASE, + lang.slug() + ); + (context, install) + } + _ => { + let list = languages + .iter() + .map(|l| format!("**{}**", l.display())) + .collect::>() + .join(", "); + let context = format!( + "### 2. Language\n\nCandidate languages detected: {list}. Pick exactly one with the user before proceeding.", + ); + let rows = languages + .iter() + .map(|l| { + format!( + "| {} | `{}#{}` |", + l.display(), + SDK_INSTALL_DOCS_BASE, + l.slug() + ) + }) + .collect::>() + .join("\n"); + let install = format!( + "### 3. Install SDK\n\nRead the install guide for the chosen language from the canonical docs:\n\n| Language | Doc URL |\n| -------- | ------- |\n{rows}\n\nRequirements:\n\n{INSTALL_SDK_REQUIREMENTS}", + ); + (context, install) + } + }; + + TEMPLATE + .replace("{LANGUAGE_CONTEXT}", &language_context) + .replace("{INSTALL_SDK_CONTEXT}", &install_sdk_context) +} + +pub fn render_skill_markdown(languages: &[DetectedLanguage]) -> String { + let frontmatter = format!( + "---\nname: {SKILL_NAME}\ndescription: {SKILL_DESCRIPTION}\nwhen_to_use: {SKILL_WHEN_TO_USE}\n---\n\n" + ); + frontmatter + &render_skill_body(languages) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn includes_yaml_frontmatter() { + let body = render_skill_markdown(&[]); + assert!(body.starts_with("---\nname: instrument-code\n")); + } + + #[test] + fn includes_language_specific_url_when_one_language() { + let body = render_skill_markdown(&[DetectedLanguage::Python]); + assert!(body.contains("#python")); + assert!(body.contains("Python")); + } + + #[test] + fn lists_multiple_languages() { + let body = render_skill_markdown(&[DetectedLanguage::Go, DetectedLanguage::Typescript]); + assert!(body.contains("**Go**")); + assert!(body.contains("**TypeScript**")); + } +} diff --git a/src/setup/wizard/skill_install.rs b/src/setup/wizard/skill_install.rs new file mode 100644 index 00000000..a0ac6d47 --- /dev/null +++ b/src/setup/wizard/skill_install.rs @@ -0,0 +1,173 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; + +use super::agents::{detect_installed_agents, AgentConfig, Resolved}; +use super::language::DetectedLanguage; +use super::prompt::{render_skill_markdown, SKILL_NAME}; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum InstallScope { + Global, + Project, +} + +#[derive(Debug, Clone)] +pub struct WrittenSkill { + #[allow(dead_code)] + pub agent: &'static str, + pub display_name: &'static str, + pub scope: InstallScope, + #[allow(dead_code)] + pub path: PathBuf, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[allow(dead_code)] +pub enum SkipReason { + NoGlobalDir, + SkippedByUser, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub struct SkippedSkill { + pub agent: &'static str, + pub display_name: &'static str, + pub scope: InstallScope, + pub reason: SkipReason, +} + +#[derive(Debug, Default)] +pub struct InstallResult { + pub written: Vec, + pub skipped: Vec, + pub detected_agents: Vec<&'static str>, +} + +pub struct InstallOptions<'a> { + pub cwd: &'a Path, + pub home: PathBuf, + pub languages: &'a [DetectedLanguage], + pub skip_agents: &'a [String], + pub override_dir: Option<&'a Path>, +} + +pub fn install_instrument_code_skill(opts: InstallOptions<'_>) -> Result { + let mut result = InstallResult::default(); + + if let Some(dir) = opts.override_dir { + let target = dir.join(SKILL_NAME); + let path = write_skill(&target, opts.languages)?; + result.written.push(WrittenSkill { + agent: "universal", + display_name: "Universal", + scope: InstallScope::Global, + path, + }); + return Ok(result); + } + + let resolved = Resolved::new(opts.home); + let detected: Vec<&'static AgentConfig> = detect_installed_agents(&resolved, opts.cwd); + let skip: HashSet<&str> = opts.skip_agents.iter().map(String::as_str).collect(); + + for cfg in &detected { + result.detected_agents.push(cfg.name); + } + + for cfg in detected { + if skip.contains(cfg.name) { + result.skipped.push(SkippedSkill { + agent: cfg.name, + display_name: cfg.display_name, + scope: InstallScope::Global, + reason: SkipReason::SkippedByUser, + }); + continue; + } + + // Global scope: language-agnostic body. + match cfg.global_skills_dir(&resolved) { + Some(global) => { + let path = write_skill(&global.join(SKILL_NAME), &[])?; + result.written.push(WrittenSkill { + agent: cfg.name, + display_name: cfg.display_name, + scope: InstallScope::Global, + path, + }); + } + None => { + result.skipped.push(SkippedSkill { + agent: cfg.name, + display_name: cfg.display_name, + scope: InstallScope::Global, + reason: SkipReason::NoGlobalDir, + }); + } + } + + // Project scope: tailored body. + let project_dir = opts.cwd.join(cfg.skills_dir).join(SKILL_NAME); + let path = write_skill(&project_dir, opts.languages)?; + result.written.push(WrittenSkill { + agent: cfg.name, + display_name: cfg.display_name, + scope: InstallScope::Project, + path, + }); + } + + Ok(result) +} + +fn write_skill(dir: &Path, languages: &[DetectedLanguage]) -> Result { + fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let path = dir.join("SKILL.md"); + fs::write(&path, render_skill_markdown(languages)) + .with_context(|| format!("writing {}", path.display()))?; + Ok(path) +} + +pub struct FallbackPrompt { + pub path: PathBuf, +} + +pub fn write_fallback_prompt(languages: &[DetectedLanguage]) -> Result { + let dir = tempfile::Builder::new() + .prefix("bt-setup-") + .tempdir() + .context("creating fallback prompt temp dir")?; + let path = dir.path().join("instrument-code.md"); + fs::write(&path, render_skill_markdown(languages)) + .with_context(|| format!("writing {}", path.display()))?; + // Persist the tempdir so the file survives this process; the user reads it after exit. + let _ = dir.keep(); + Ok(FallbackPrompt { path }) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn override_dir_writes_single_skill() { + let dir = tempdir().unwrap(); + let cwd = tempdir().unwrap(); + let home = tempdir().unwrap(); + let result = install_instrument_code_skill(InstallOptions { + cwd: cwd.path(), + home: home.path().to_path_buf(), + languages: &[], + skip_agents: &[], + override_dir: Some(dir.path()), + }) + .unwrap(); + assert_eq!(result.written.len(), 1); + assert!(result.written[0].path.exists()); + } +} diff --git a/src/ui/select.rs b/src/ui/select.rs index bcfd02e2..837e7b11 100644 --- a/src/ui/select.rs +++ b/src/ui/select.rs @@ -19,6 +19,7 @@ use crate::{ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProjectSelectMode { ExistingOnly, + #[allow(dead_code)] AllowCreateWithDefaultProjectNote, } diff --git a/tests/cli.rs b/tests/cli.rs index c3ef8caa..f2e3ae34 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -93,9 +93,9 @@ fn setup_quiet_and_verbose_conflict() { bt_command() .args([ "setup", + "skills", "--quiet", "--verbose", - "--no-instrument", "--global", "--agent", "codex", @@ -280,7 +280,7 @@ fn setup_verbose_prints_agent_summary() { } #[test] -fn setup_no_instrument_does_not_require_auth_in_git_repo() { +fn setup_skills_does_not_require_auth_in_git_repo() { let repo = make_git_repo(); let nested = repo.path().join("nested"); fs::create_dir_all(&nested).expect("create nested"); @@ -298,55 +298,17 @@ fn setup_no_instrument_does_not_require_auth_in_git_repo() { .env("PATH", bin_dir.path()) .args([ "setup", + "skills", "--global", - "--no-instrument", "--no-workflow", "--no-input", - ]) - .assert() - .success(); -} - -#[test] -fn setup_interactive_no_instrument_does_not_require_auth_in_git_repo() { - let repo = make_git_repo(); - let nested = repo.path().join("nested"); - fs::create_dir_all(&nested).expect("create nested"); - - let home = tempfile::tempdir().expect("home tempdir"); - let config_home = tempfile::tempdir().expect("config tempdir"); - let bin_dir = tempfile::tempdir().expect("bin tempdir"); - write_executable(&bin_dir.path().join("codex")); - - let mut cmd = bt_command(); - clear_braintrust_auth_env(&mut cmd); - cmd.current_dir(&nested) - .env("HOME", home.path()) - .env("XDG_CONFIG_HOME", config_home.path()) - .env("PATH", bin_dir.path()) - .args([ - "setup", - "--interactive", - "--global", "--agent", "codex", - "--skills", - "--no-mcp", - "--no-instrument", - "--no-input", ]) .assert() .success(); } -#[test] -fn setup_accepts_no_skill_alias() { - bt_command() - .args(["setup", "--no-skill", "--help"]) - .assert() - .success(); -} - #[test] fn setup_mcp_only_requires_auth_in_non_interactive_mode() { let repo = make_git_repo(); @@ -365,14 +327,7 @@ fn setup_mcp_only_requires_auth_in_non_interactive_mode() { .env("HOME", home.path()) .env("XDG_CONFIG_HOME", config_home.path()) .env("PATH", bin_dir.path()) - .args([ - "setup", - "--global", - "--mcp", - "--no-skills", - "--no-instrument", - "--no-input", - ]) + .args(["setup", "mcp", "--global", "--agent", "codex", "--no-input"]) .assert() .failure() .stderr(predicate::str::contains( diff --git a/tests/setup_cli.rs b/tests/setup_cli.rs index b39c80b6..59ac9efd 100644 --- a/tests/setup_cli.rs +++ b/tests/setup_cli.rs @@ -13,40 +13,31 @@ fn setup_accepts_deprecated_quiet_flag() { } #[test] -fn setup_prints_banner_without_interactive_flag() { +fn setup_help_lists_subcommands() { let mut cmd = Command::cargo_bin("bt").expect("bt binary"); - cmd.env("TERM", "xterm-256color") - .env_remove("NO_COLOR") - .args([ - "setup", - "--no-instrument", - "--no-skills", - "--no-mcp", - "--agent", - "codex", - ]); + cmd.args(["setup", "--help"]); cmd.assert() .success() - .stderr(contains("Braintrust")) - .stderr(contains("\u{1b}[34m")); + .stdout(contains("skills")) + .stdout(contains("instrument")) + .stdout(contains("mcp")) + .stdout(contains("doctor")); } #[test] -fn setup_no_color_disables_banner_styling() { +fn setup_with_no_input_requires_credentials() { let mut cmd = Command::cargo_bin("bt").expect("bt binary"); - cmd.env("TERM", "xterm-256color") - .env_remove("NO_COLOR") - .args([ - "setup", - "--no-color", - "--no-instrument", - "--no-skills", - "--no-mcp", - "--agent", - "codex", - ]); + cmd.args(["setup", "--no-input"]); cmd.assert() - .success() - .stderr(contains("Braintrust")) - .stderr(contains("\u{1b}[").not()); + .failure() + .stderr(contains("credentials required").or(contains("TTY required"))); +} + +#[test] +fn setup_with_json_is_rejected() { + let mut cmd = Command::cargo_bin("bt").expect("bt binary"); + cmd.args(["setup", "--json"]); + cmd.assert() + .failure() + .stderr(contains("interactive").and(contains("--json"))); }