use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{Context, Result};
use bitrouter_cloud_sdk::auth::commands::{LoginInputs, login as cloud_login};
use bitrouter_cloud_sdk::auth::credentials::default_credentials_path;
use bitrouter_providers::oauth::credential_store::CredentialStore;
use clap::ValueEnum;
use serde::Serialize;
use crate::commands::{ProviderLoginOptions, ScaffoldOutcome, login_provider_with_options};
use crate::output::CliReport;
use crate::output::Output;
use crate::output::human::Human;
use crate::spawn::SpawnAgent;
const PROBE_ENV_VARS: &[(&str, &str)] = &[
("OPENAI_API_KEY", "openai"),
("ANTHROPIC_API_KEY", "anthropic"),
("GEMINI_API_KEY", "google"),
("OPENROUTER_API_KEY", "openrouter"),
("OPENCODE_ZEN_API_KEY", "opencode-zen"),
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ProbeSignals {
pub env_keys: Vec<String>,
pub cloud_session: bool,
pub subscription_providers: Vec<String>,
}
impl ProbeSignals {
pub fn is_configured(&self) -> bool {
!self.env_keys.is_empty() || self.cloud_session || !self.subscription_providers.is_empty()
}
fn env_provider_ids(&self) -> Vec<String> {
self.env_keys
.iter()
.filter_map(|var| {
PROBE_ENV_VARS
.iter()
.find(|(v, _)| *v == var)
.map(|(_, id)| (*id).to_string())
})
.collect()
}
fn already_configured(&self) -> BTreeSet<String> {
let mut set: BTreeSet<String> = self.env_provider_ids().into_iter().collect();
if self.cloud_session {
set.insert(bitrouter_cloud_sdk::provider::PROVIDER_ID.to_string());
}
for id in &self.subscription_providers {
set.insert(id.clone());
}
set
}
}
pub fn probe() -> ProbeSignals {
let env_keys = detected_env_keys(|name| std::env::var(name).ok().filter(|v| !v.is_empty()));
let cloud_session = default_credentials_path()
.map(|p| p.exists())
.unwrap_or(false);
let subscription_providers = CredentialStore::default_path()
.map(|store| store.providers().into_iter().map(String::from).collect())
.unwrap_or_default();
ProbeSignals {
env_keys,
cloud_session,
subscription_providers,
}
}
fn detected_env_keys(lookup: impl Fn(&str) -> Option<String>) -> Vec<String> {
PROBE_ENV_VARS
.iter()
.filter(|(var, _)| lookup(var).is_some())
.map(|(var, _)| (*var).to_string())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum AfterAction {
Launch,
Serve,
Exit,
}
impl AfterAction {
fn as_str(self) -> &'static str {
match self {
AfterAction::Launch => "launch",
AfterAction::Serve => "serve",
AfterAction::Exit => "exit",
}
}
}
#[derive(Debug, Clone)]
pub struct OnboardingFlags {
pub config: PathBuf,
pub yes: bool,
pub force: bool,
pub reset: bool,
pub cloud_login: bool,
pub api_key: Option<String>,
pub providers: Vec<String>,
pub provider_api_keys: Vec<String>,
pub use_detected: bool,
pub harnesses: Vec<SpawnAgent>,
pub no_install: bool,
pub after: Option<AfterAction>,
pub model: Option<String>,
pub write_config: bool,
}
impl Default for OnboardingFlags {
fn default() -> Self {
Self {
config: PathBuf::from("bitrouter.yaml"),
yes: false,
force: false,
reset: false,
cloud_login: false,
api_key: None,
providers: Vec::new(),
provider_api_keys: Vec::new(),
use_detected: false,
harnesses: Vec::new(),
no_install: false,
after: None,
model: None,
write_config: false,
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct Snippet {
pub base_url: String,
pub anthropic: String,
pub openai: String,
pub codex: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct OnboardingReport {
pub action: &'static str,
pub providers_configured: Vec<String>,
pub providers_skipped_interactive: Vec<String>,
pub harnesses_installed: Vec<String>,
pub after: String,
pub snippet: Option<Snippet>,
}
impl CliReport for OnboardingReport {
fn render(&self, h: &mut Human<'_>) -> std::io::Result<()> {
h.line("onboarding complete")?;
h.field(
"providers",
if self.providers_configured.is_empty() {
"(none)".to_string()
} else {
self.providers_configured.join(", ")
},
)?;
if !self.providers_skipped_interactive.is_empty() {
h.field("skipped", self.providers_skipped_interactive.join(", "))?;
}
h.field(
"harnesses",
if self.harnesses_installed.is_empty() {
"(none)".to_string()
} else {
self.harnesses_installed.join(", ")
},
)?;
h.field("after", &self.after)?;
if let Some(snippet) = &self.snippet {
h.blank()?;
h.line(&format!("paste-in wiring ({}):", snippet.base_url))?;
h.blank()?;
h.line(" Anthropic / Claude Code:")?;
for l in snippet.anthropic.lines() {
h.line(&format!(" {l}"))?;
}
h.line(" OpenAI SDK:")?;
for l in snippet.openai.lines() {
h.line(&format!(" {l}"))?;
}
h.line(" Codex:")?;
for l in snippet.codex.lines() {
h.line(&format!(" {l}"))?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OnboardingStatusReport {
pub action: &'static str,
pub configured: bool,
pub signals: Vec<String>,
pub hint: String,
}
impl OnboardingStatusReport {
fn from_signals(signals: &ProbeSignals) -> Self {
let mut parts = Vec::new();
if signals.cloud_session {
parts.push("cloud session".to_string());
}
if !signals.subscription_providers.is_empty() {
parts.push(format!(
"providers: {}",
signals.subscription_providers.join(", ")
));
}
if !signals.env_keys.is_empty() {
parts.push(format!("env: {}", signals.env_keys.join(", ")));
}
Self {
action: "status",
configured: true,
signals: parts,
hint: "run `bitrouter launch` to start a coding session".to_string(),
}
}
}
impl CliReport for OnboardingStatusReport {
fn render(&self, h: &mut Human<'_>) -> std::io::Result<()> {
h.line(&format!(
"bitrouter is configured ({}) — {}",
if self.signals.is_empty() {
"credentials present".to_string()
} else {
self.signals.join("; ")
},
self.hint,
))
}
}
pub async fn entry(output: &Output) -> Result<()> {
let signals = probe();
if signals.is_configured() {
return emit(output, &OnboardingStatusReport::from_signals(&signals));
}
if std::io::stdin().is_terminal() {
run_interactive(OnboardingFlags::default(), &signals, output).await
} else {
print_hint();
emit(output, &empty_report())
}
}
pub async fn run(flags: OnboardingFlags, output: &Output) -> Result<()> {
if flags.reset {
reset_credentials(flags.yes, std::io::stdin().is_terminal()).await?;
}
if flags.yes {
return run_headless(flags, output).await;
}
if std::io::stdin().is_terminal() {
let signals = probe();
run_interactive(flags, &signals, output).await
} else {
run_headless(
OnboardingFlags {
write_config: true,
..flags
},
output,
)
.await
}
}
fn empty_report() -> OnboardingReport {
OnboardingReport {
action: "onboarding",
providers_configured: Vec::new(),
providers_skipped_interactive: Vec::new(),
harnesses_installed: Vec::new(),
after: AfterAction::Exit.as_str().to_string(),
snippet: None,
}
}
async fn run_headless(flags: OnboardingFlags, output: &Output) -> Result<()> {
let signals = probe();
let mut configured = signals.already_configured();
let mut skipped: Vec<String> = Vec::new();
apply_flag_credentials(&flags, &mut configured, &mut skipped, true).await?;
let mut installed: Vec<String> = Vec::new();
for agent in &flags.harnesses {
match crate::spawn::ensure_agent_installed(*agent, true).await {
Ok(_) => installed.push(agent.spec().id.to_string()),
Err(_) => note(&format!(
"harness '{}' is not installed — skipped (install it, or run \
`bitrouter launch -a {}` interactively)",
agent.spec().id,
agent.spec().id
)),
}
}
if flags.yes || flags.write_config {
match crate::commands::write_starter_config(&flags.config, flags.force).await? {
ScaffoldOutcome::Wrote => note(&format!(
"wrote starter config to {}",
flags.config.display()
)),
ScaffoldOutcome::Skipped => note(&format!(
"{} already exists — left untouched (pass --force to overwrite)",
flags.config.display()
)),
}
}
let after = flags.after.unwrap_or(AfterAction::Exit);
let mut report = OnboardingReport {
action: "onboarding",
providers_configured: configured.into_iter().collect(),
providers_skipped_interactive: skipped,
harnesses_installed: installed.clone(),
after: after.as_str().to_string(),
snippet: None,
};
match after {
AfterAction::Launch => {
match pick_launch_harness(&flags.harnesses, &installed) {
Some(agent) => {
finish_launch(
agent,
flags.model.as_deref(),
flags.no_install,
report,
output,
)
.await
}
None => {
note("no requested harness is installed — nothing to launch; exiting");
report.after = AfterAction::Exit.as_str().to_string();
emit(output, &report)
}
}
}
AfterAction::Serve => finish_serve(report, output).await,
AfterAction::Exit => emit(output, &report),
}
}
async fn apply_flag_credentials(
flags: &OnboardingFlags,
configured: &mut BTreeSet<String>,
skipped: &mut Vec<String>,
headless: bool,
) -> Result<()> {
let cloud = bitrouter_cloud_sdk::provider::PROVIDER_ID;
if let Some(key) = flags.api_key.as_deref() {
seed_cloud_api_key(key).await?;
configured.insert(cloud.to_string());
} else if flags.cloud_login {
if headless {
skipped.push(cloud.to_string());
} else {
match seed_cloud_interactive().await {
Ok(()) => {
configured.insert(cloud.to_string());
}
Err(e) => note(&format!("cloud sign-in did not complete: {e:#}")),
}
}
}
for (i, provider) in flags.providers.iter().enumerate() {
match flags.provider_api_keys.get(i) {
Some(key) => match seed_provider_api_key(provider, key).await {
Ok(()) => {
configured.insert(provider.clone());
}
Err(e) => {
note(&format!("provider '{provider}' not configured: {e:#}"));
skipped.push(provider.clone());
}
},
None if headless => skipped.push(provider.clone()),
None => match login_provider_with_options(
provider,
bitrouter_providers::oauth::credential_store::DEFAULT_LABEL,
ProviderLoginOptions::default(),
)
.await
{
Ok(_) => {
configured.insert(provider.clone());
}
Err(e) => {
note(&format!(
"provider '{provider}' login did not complete: {e:#}"
));
skipped.push(provider.clone());
}
},
}
}
Ok(())
}
async fn seed_cloud_api_key(key: &str) -> Result<()> {
cloud_login(LoginInputs {
authorization_server: None,
client_id: None,
scope: None,
api_key: Some(key.to_string()),
})
.await
.map(|_| ())
.context("seeding the cloud credential from --api-key")
}
async fn seed_provider_api_key(provider: &str, key: &str) -> Result<()> {
login_provider_with_options(
provider,
bitrouter_providers::oauth::credential_store::DEFAULT_LABEL,
ProviderLoginOptions {
import_existing: false,
no_browser: true,
api_key: Some(key.to_string()),
},
)
.await
.map(|_| ())
}
async fn run_interactive(
flags: OnboardingFlags,
signals: &ProbeSignals,
output: &Output,
) -> Result<()> {
eprintln!();
eprintln!("Welcome to BitRouter — let's get you to first value.");
eprintln!();
let mut configured = signals.already_configured();
let mut skipped: Vec<String> = Vec::new();
apply_flag_credentials(&flags, &mut configured, &mut skipped, false).await?;
let seeded_from_flags =
flags.api_key.is_some() || flags.cloud_login || !flags.providers.is_empty();
if flags.use_detected && signals.is_configured() {
eprintln!("Step 1/3 — Credentials");
note("using the detected credential(s)");
} else if !seeded_from_flags {
interactive_credentials(signals, &mut configured, &mut skipped).await?;
}
let installed = interactive_harness(&flags).await?;
let after = interactive_after(&flags, &installed)?;
let mut report = OnboardingReport {
action: "onboarding",
providers_configured: configured.into_iter().collect(),
providers_skipped_interactive: skipped,
harnesses_installed: installed.clone(),
after: after.as_str().to_string(),
snippet: None,
};
match after {
AfterAction::Launch => match pick_launch_harness(&flags.harnesses, &installed)
.or_else(|| installed.first().and_then(|id| agent_by_id(id)))
{
Some(agent) => {
finish_launch(
agent,
flags.model.as_deref(),
flags.no_install,
report,
output,
)
.await
}
None => {
note("no harness available to launch; exiting");
report.after = AfterAction::Exit.as_str().to_string();
emit(output, &report)
}
},
AfterAction::Serve => finish_serve(report, output).await,
AfterAction::Exit => {
if flags.write_config
|| prompt_yes_no("Write a starter bitrouter.yaml to edit later?", false)
{
match crate::commands::write_starter_config(&flags.config, flags.force).await? {
ScaffoldOutcome::Wrote => note(&format!(
"wrote starter config to {}",
flags.config.display()
)),
ScaffoldOutcome::Skipped => note(&format!(
"{} already exists — left untouched (pass --force to overwrite)",
flags.config.display()
)),
}
}
emit(output, &report)
}
}
}
async fn interactive_credentials(
signals: &ProbeSignals,
configured: &mut BTreeSet<String>,
skipped: &mut Vec<String>,
) -> Result<()> {
eprintln!("Step 1/3 — Credentials");
if signals.is_configured() {
eprintln!(
" Detected: {}",
OnboardingStatusReport::from_signals(signals)
.signals
.join("; ")
);
}
loop {
eprintln!();
eprintln!(" How would you like to authenticate?");
eprintln!(" 1) Sign in to BitRouter Cloud — one account, every model [default]");
eprintln!(" 2) Log in to a specific provider (claude-code / openai-codex / …)");
if signals.is_configured() {
eprintln!(" 3) Use the detected credential(s) and continue");
}
eprintln!(" 0) Skip for now");
match prompt_line(" Choose [1]: ")?.as_str() {
"" | "1" => match seed_cloud_interactive().await {
Ok(()) => {
configured.insert(bitrouter_cloud_sdk::provider::PROVIDER_ID.to_string());
}
Err(e) => note(&format!("cloud sign-in did not complete: {e:#}")),
},
"2" => {
let id = prompt_line(" Provider id: ")?;
if id.is_empty() {
note("no provider id entered — skipping");
} else {
match login_provider_with_options(
&id,
bitrouter_providers::oauth::credential_store::DEFAULT_LABEL,
ProviderLoginOptions::default(),
)
.await
{
Ok(_) => {
configured.insert(id);
}
Err(e) => {
note(&format!("provider '{id}' login did not complete: {e:#}"));
skipped.push(id);
}
}
}
}
"3" if signals.is_configured() => break,
"0" => break,
other => {
note(&format!("'{other}' is not a choice"));
continue;
}
}
if !prompt_yes_no(" Add another provider?", false) {
break;
}
}
Ok(())
}
async fn seed_cloud_interactive() -> Result<()> {
cloud_login(LoginInputs {
authorization_server: None,
client_id: None,
scope: None,
api_key: None,
})
.await
.map(|_| ())
.context("BitRouter Cloud sign-in")
}
async fn interactive_harness(flags: &OnboardingFlags) -> Result<Vec<String>> {
eprintln!();
eprintln!("Step 2/3 — Harness");
let chosen: Vec<SpawnAgent> = if !flags.harnesses.is_empty() {
flags.harnesses.clone()
} else {
let answer = prompt_line(" Which coding agent do you drive? [claude/codex/skip]: ")?;
match answer.to_ascii_lowercase().as_str() {
"" | "claude" => vec![SpawnAgent::Claude],
"codex" => vec![SpawnAgent::Codex],
"skip" | "none" => Vec::new(),
other => {
note(&format!("'{other}' is not a known harness — skipping"));
Vec::new()
}
}
};
let mut installed = Vec::new();
for agent in chosen {
match crate::spawn::ensure_agent_installed(agent, flags.no_install).await {
Ok(_) => installed.push(agent.spec().id.to_string()),
Err(e) => note(&format!("{}: {e:#}", agent.spec().id)),
}
}
Ok(installed)
}
fn interactive_after(flags: &OnboardingFlags, installed: &[String]) -> Result<AfterAction> {
if let Some(after) = flags.after {
return Ok(after);
}
eprintln!();
eprintln!("Step 3/3 — Finish");
let can_launch = !installed.is_empty();
if can_launch {
eprintln!(" 1) Launch now [default]");
}
eprintln!(" 2) Start the daemon and print a paste-in snippet");
eprintln!(" 3) Exit");
let default_choice = if can_launch { "1" } else { "2" };
let choice = prompt_line(&format!(" Choose [{default_choice}]: "))?;
let choice = if choice.is_empty() {
default_choice.to_string()
} else {
choice
};
Ok(match choice.as_str() {
"1" if can_launch => AfterAction::Launch,
"2" => AfterAction::Serve,
_ => AfterAction::Exit,
})
}
async fn finish_launch(
agent: SpawnAgent,
model: Option<&str>,
no_install: bool,
report: OnboardingReport,
output: &Output,
) -> Result<()> {
let source = crate::paths::resolve_config(None)?;
let cfg = crate::paths::load_config(&source).await?;
emit(output, &report)?;
let agent_args = match model {
Some(m) if !m.is_empty() => vec!["--model".to_string(), m.to_string()],
_ => Vec::new(),
};
let opts = crate::spawn::SpawnOptions {
agent,
agent_args,
base_url: None,
no_install,
no_start: false,
check: false,
};
crate::spawn::run(&source, &cfg, opts).await
}
async fn finish_serve(mut report: OnboardingReport, output: &Output) -> Result<()> {
let source = crate::paths::resolve_config(None)?;
let cfg = crate::paths::load_config(&source).await?;
crate::spawn::ensure_local_daemon(&source, &cfg, false).await;
report.snippet = Some(build_snippet(&cfg.server.listen));
emit(output, &report)
}
fn build_snippet(listen: &str) -> Snippet {
let base_url = crate::spawn::derive_base_url(listen);
let token = crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string());
let v1 = {
let trimmed = base_url.trim_end_matches('/');
if trimmed.ends_with("/v1") {
trimmed.to_string()
} else {
format!("{trimmed}/v1")
}
};
let anthropic =
format!("export ANTHROPIC_BASE_URL={base_url}\nexport ANTHROPIC_AUTH_TOKEN={token}");
let openai = format!("export OPENAI_BASE_URL={v1}\nexport OPENAI_API_KEY={token}");
let codex = match crate::harness::by_id("codex-acp") {
Some(h) => {
let overlay = h.routing_overlay(&base_url, &token, None);
let mut parts = vec!["codex".to_string()];
parts.extend(overlay.args);
parts.join(" ")
}
None => String::new(),
};
Snippet {
base_url,
anthropic,
openai,
codex,
}
}
async fn reset_credentials(assume_yes: bool, interactive: bool) -> Result<()> {
let cloud_path = default_credentials_path().ok();
let mut store = CredentialStore::default_path().ok();
let provider_ids: Vec<String> = store
.as_ref()
.map(|s| s.providers().into_iter().map(String::from).collect())
.unwrap_or_default();
let remove_providers = if provider_ids.is_empty() {
false
} else if assume_yes {
true
} else if interactive {
prompt_yes_no(
&format!(
" --reset: also remove {} stored provider credential(s) ({})?",
provider_ids.len(),
provider_ids.join(", ")
),
false,
)
} else {
false
};
let outcome = reset_with(cloud_path.as_deref(), store.as_mut(), remove_providers)?;
if outcome.cloud_cleared {
note("cleared the cloud session");
}
if outcome.providers_removed > 0 {
note(&format!(
"removed {} provider credential(s)",
outcome.providers_removed
));
} else if !provider_ids.is_empty() && !remove_providers {
note("kept provider credentials");
}
Ok(())
}
#[derive(Debug, Default, PartialEq, Eq)]
struct ResetOutcome {
cloud_cleared: bool,
providers_removed: usize,
}
fn reset_with(
cloud_path: Option<&std::path::Path>,
store: Option<&mut CredentialStore>,
remove_providers: bool,
) -> Result<ResetOutcome> {
let mut outcome = ResetOutcome::default();
if let Some(path) = cloud_path
&& path.exists()
{
std::fs::remove_file(path)
.with_context(|| format!("removing cloud credentials {}", path.display()))?;
outcome.cloud_cleared = true;
}
if remove_providers && let Some(store) = store {
let ids: Vec<String> = store.providers().into_iter().map(String::from).collect();
for id in ids {
outcome.providers_removed += store
.remove_all_for(&id)
.with_context(|| format!("removing credentials for {id}"))?;
}
}
Ok(outcome)
}
fn agent_by_id(id: &str) -> Option<SpawnAgent> {
SpawnAgent::value_variants()
.iter()
.copied()
.find(|a| a.spec().id == id)
}
fn pick_launch_harness(requested: &[SpawnAgent], installed: &[String]) -> Option<SpawnAgent> {
requested
.iter()
.copied()
.find(|a| installed.iter().any(|id| id == a.spec().id))
}
fn note(msg: &str) {
let p = crate::style::Palette::for_stderr();
eprintln!(" {dim}{msg}{reset}", dim = p.dim, reset = p.reset);
}
fn emit(output: &Output, report: &dyn CliReport) -> Result<()> {
Output::emit(output, report).map_err(anyhow::Error::from)
}
fn prompt_line(prompt: &str) -> Result<String> {
use std::io::{BufRead, Write};
eprint!("{prompt}");
std::io::stderr().flush().ok();
let mut line = String::new();
let n = std::io::stdin()
.lock()
.read_line(&mut line)
.context("reading input from stdin")?;
if n == 0 {
return Ok(String::new());
}
Ok(line.trim().to_string())
}
fn prompt_yes_no(prompt: &str, default_yes: bool) -> bool {
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
match prompt_line(&format!("{prompt} {suffix}: ")) {
Ok(answer) => match answer.to_ascii_lowercase().as_str() {
"" => default_yes,
"y" | "yes" => true,
_ => false,
},
Err(_) => default_yes,
}
}
fn print_hint() {
let p = crate::style::Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} no credentials detected yet. Get started with one of:",
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
eprintln!();
eprintln!(" bitrouter init # guided setup wizard (interactive)");
eprintln!(" bitrouter cloud login # one BitRouter Cloud account, every model");
eprintln!(" bitrouter providers login claude-code # a subscription you already pay for");
eprintln!(" export OPENAI_API_KEY=… # or any BYOK provider key, then re-run");
eprintln!();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detected_env_keys_reports_present_vars_only() {
let present = |name: &str| {
matches!(name, "OPENAI_API_KEY" | "GEMINI_API_KEY").then(|| "x".to_string())
};
let keys = detected_env_keys(present);
assert_eq!(keys, vec!["OPENAI_API_KEY", "GEMINI_API_KEY"]);
}
#[test]
fn detected_env_keys_ignores_empty_values() {
let keys = detected_env_keys(|_| None);
assert!(keys.is_empty());
}
#[test]
fn probe_uses_gemini_not_google_key() {
assert!(PROBE_ENV_VARS.iter().any(|(v, _)| *v == "GEMINI_API_KEY"));
assert!(!PROBE_ENV_VARS.iter().any(|(v, _)| *v == "GOOGLE_API_KEY"));
}
#[test]
fn is_configured_is_false_when_no_signal_present() {
assert!(!ProbeSignals::default().is_configured());
}
#[test]
fn is_configured_true_for_each_signal_independently() {
assert!(
ProbeSignals {
env_keys: vec!["OPENAI_API_KEY".to_string()],
..Default::default()
}
.is_configured()
);
assert!(
ProbeSignals {
cloud_session: true,
..Default::default()
}
.is_configured()
);
assert!(
ProbeSignals {
subscription_providers: vec!["claude-code".to_string()],
..Default::default()
}
.is_configured()
);
}
#[test]
fn already_configured_maps_env_cloud_and_store() {
let signals = ProbeSignals {
env_keys: vec!["OPENAI_API_KEY".to_string(), "GEMINI_API_KEY".to_string()],
cloud_session: true,
subscription_providers: vec!["claude-code".to_string()],
};
let set = signals.already_configured();
assert!(set.contains("openai"));
assert!(set.contains("google")); assert!(set.contains("bitrouter")); assert!(set.contains("claude-code"));
}
#[test]
fn envelope_shape_includes_skipped_interactive() {
let report = OnboardingReport {
action: "onboarding",
providers_configured: vec!["bitrouter".to_string(), "openai".to_string()],
providers_skipped_interactive: vec!["github-copilot".to_string()],
harnesses_installed: vec!["claude".to_string()],
after: "launch".to_string(),
snippet: None,
};
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["action"], "onboarding");
assert_eq!(
v["providers_configured"],
serde_json::json!(["bitrouter", "openai"])
);
assert_eq!(
v["providers_skipped_interactive"],
serde_json::json!(["github-copilot"])
);
assert_eq!(v["harnesses_installed"], serde_json::json!(["claude"]));
assert_eq!(v["after"], "launch");
assert!(v.get("snippet").is_some());
assert!(v["snippet"].is_null());
}
#[test]
fn snippet_uses_placeholder_when_no_bitrouter_key() {
if crate::spawn::nonempty_env(crate::harness::BITROUTER_API_KEY_ENV).is_some() {
return; }
let snippet = build_snippet("127.0.0.1:4356");
assert_eq!(snippet.base_url, "http://127.0.0.1:4356");
assert!(
snippet
.anthropic
.contains("ANTHROPIC_BASE_URL=http://127.0.0.1:4356")
);
assert!(snippet.anthropic.contains(&format!(
"ANTHROPIC_AUTH_TOKEN={}",
crate::harness::PLACEHOLDER_API_KEY
)));
assert!(
snippet
.openai
.contains("OPENAI_BASE_URL=http://127.0.0.1:4356/v1")
);
assert!(snippet.codex.starts_with("codex "));
assert!(snippet.codex.contains("model_provider=\"bitrouter\""));
assert!(snippet.codex.contains("http://127.0.0.1:4356/v1"));
}
#[test]
fn pick_launch_harness_prefers_first_installed() {
let requested = vec![SpawnAgent::Codex, SpawnAgent::Claude];
let picked = pick_launch_harness(&requested, &["claude".to_string()]);
assert_eq!(picked, Some(SpawnAgent::Claude));
assert_eq!(pick_launch_harness(&requested, &[]), None);
}
#[test]
fn agent_by_id_round_trips() {
assert_eq!(agent_by_id("claude"), Some(SpawnAgent::Claude));
assert_eq!(agent_by_id("codex"), Some(SpawnAgent::Codex));
assert_eq!(agent_by_id("nope"), None);
}
fn tmp_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"bitrouter-onboarding-{label}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn reset_clears_cloud_session_and_optionally_providers() {
let dir = tmp_dir("reset");
let cloud = dir.join("account-credentials.json");
std::fs::write(&cloud, "{}").unwrap();
let mut store = CredentialStore::load(dir.join("oauth-tokens.json")).unwrap();
store
.set(
"claude-code",
"default",
bitrouter_providers::oauth::credential_store::Credential::ClaudeCodeCli,
)
.unwrap();
store
.set(
"openai",
"default",
bitrouter_providers::oauth::credential_store::Credential::api_key("sk-x"),
)
.unwrap();
let outcome = reset_with(Some(&cloud), Some(&mut store), false).unwrap();
assert!(outcome.cloud_cleared);
assert_eq!(outcome.providers_removed, 0);
assert!(!cloud.exists());
assert_eq!(store.providers().len(), 2, "provider creds must survive");
let outcome = reset_with(None, Some(&mut store), true).unwrap();
assert!(!outcome.cloud_cleared); assert_eq!(outcome.providers_removed, 2);
assert!(store.providers().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn reset_is_a_noop_when_nothing_stored() {
let dir = tmp_dir("reset-empty");
let missing_cloud = dir.join("account-credentials.json");
let mut store = CredentialStore::load(dir.join("oauth-tokens.json")).unwrap();
let outcome = reset_with(Some(&missing_cloud), Some(&mut store), true).unwrap();
assert_eq!(outcome, ResetOutcome::default());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn status_report_lists_signals_and_hint() {
let signals = ProbeSignals {
env_keys: vec!["OPENAI_API_KEY".to_string()],
cloud_session: true,
subscription_providers: vec![],
};
let report = OnboardingStatusReport::from_signals(&signals);
assert!(report.configured);
assert!(report.hint.contains("bitrouter launch"));
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["action"], "status");
assert_eq!(v["configured"], true);
}
}