use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use bitrouter_providers::hosted::account::credentials::default_credentials_path;
use bitrouter_providers::hosted::account::manager::CredentialManager;
use bitrouter_providers::hosted::applier::PROVIDER_ID;
use bitrouter_providers::oauth::credential_store::CredentialStore;
use clap::ValueEnum;
use serde::Serialize;
use crate::cloud::auth::{LoginInputs, login as cloud_login};
use crate::commands::{ProviderLoginOptions, 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(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, Default)]
pub struct OnboardingFlags {
pub config: Option<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 after: Option<AfterAction>,
pub model: Option<String>,
}
#[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` to start an ACP 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 path = config_path(None)?;
if path.is_file() {
let source = crate::paths::resolve_config(Some(&path))?;
let config = crate::paths::load_config(&source).await?;
if config
.chat
.agent
.as_deref()
.is_some_and(|id| !id.trim().is_empty())
{
return start_default_chat(&source, config).await;
}
}
if std::io::stdin().is_terminal() {
let manager = crate::cloud::default_manager()?;
run_interactive(
OnboardingFlags {
config: Some(path),
..Default::default()
},
&probe(),
output,
manager,
)
.await
} else {
print_hint();
emit(output, &empty_report())
}
}
fn config_path(explicit: Option<&std::path::Path>) -> Result<PathBuf> {
if let Some(path) = explicit {
return Ok(std::path::absolute(path)?);
}
let local = std::env::current_dir()?.join("bitrouter.yaml");
if local.is_file() {
return Ok(local);
}
Ok(std::path::absolute(
crate::paths::runtime_home()?.join("bitrouter.yaml"),
)?)
}
async fn start_default_chat(
source: &crate::paths::ConfigSource,
config: bitrouter_sdk::config::Config,
) -> Result<()> {
let agent = config
.chat
.agent
.clone()
.context("no default ACP harness; run `bitrouter init`")?;
let routing = crate::acp_cli::RoutingOptions {
model: config.chat.model.clone(),
..Default::default()
};
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return crate::acp_cli::chat(crate::acp_cli::SpawnContext {
source,
config,
agent_id: &agent,
options: crate::acp_cli::launch_options(None),
routing,
})
.await;
}
let config_path = match source {
crate::paths::ConfigSource::File(path) => Some(path.as_path()),
crate::paths::ConfigSource::Default { .. } => None,
};
crate::dashboard::run(
None,
config_path,
None,
Some(crate::dashboard::SessionRequest {
agent,
selection: crate::acp_cli::SessionSelection::New,
turn_timeout: None,
routing,
}),
)
.await
}
fn save_config(
flags: &OnboardingFlags,
agent: &str,
providers: &BTreeSet<String>,
) -> Result<PathBuf> {
use std::io::Write;
let path = config_path(flags.config.as_deref())?;
let existing = path.is_file();
let text = if existing && !flags.force {
std::fs::read_to_string(&path)?
} else {
crate::commands::STARTER_CONFIG.to_string()
};
let mut value: serde_json::Value = serde_saphyr::from_str(&text)?;
if value.is_null() {
value = serde_json::json!({});
}
let root = value
.as_object_mut()
.context("configuration must be a YAML mapping")?;
let chat = root.entry("chat").or_insert_with(|| serde_json::json!({}));
let chat = chat
.as_object_mut()
.context("chat must be a YAML mapping")?;
chat.insert("agent".into(), agent.into());
if let Some(model) = &flags.model {
chat.insert("model".into(), model.clone().into());
}
let entries = root
.entry("providers")
.or_insert_with(|| serde_json::json!({}));
let entries = entries
.as_object_mut()
.context("providers must be a YAML mapping")?;
for provider in providers {
entries.entry(provider.clone()).or_insert_with(|| {
if provider == PROVIDER_ID {
serde_json::json!({"auto_discover": true})
} else {
serde_json::json!({})
}
});
}
let serialized = serde_saphyr::to_string(&value)?;
let _: bitrouter_sdk::config::Config = serde_saphyr::from_str(&serialized)?;
let parent = path.parent().context("configuration path has no parent")?;
if !parent.exists() {
crate::paths::ensure_home_directory(parent)?;
}
let mut temp = tempfile::NamedTempFile::new_in(parent)?;
temp.write_all(serialized.as_bytes())?;
temp.as_file().sync_all()?;
temp.persist(&path)
.with_context(|| format!("saving {}", path.display()))?;
note(&format!(
"saved default ACP harness '{agent}' in {}",
path.display()
));
Ok(path)
}
fn acp_id(agent: SpawnAgent) -> &'static str {
match agent {
SpawnAgent::Claude => "claude-acp",
SpawnAgent::Codex => "codex-acp",
}
}
pub async fn run(flags: OnboardingFlags, output: &Output) -> Result<()> {
let manager = crate::cloud::default_manager()?;
if flags.reset {
reset_credentials(flags.yes, std::io::stdin().is_terminal()).await?;
}
if flags.yes {
return run_headless(flags, output, manager).await;
}
if std::io::stdin().is_terminal() {
let signals = probe();
run_interactive(flags, &signals, output, manager).await
} else {
run_headless(flags, output, manager).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,
manager: Arc<CredentialManager>,
) -> 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, manager).await?;
let path = config_path(flags.config.as_deref())?;
let existing_agent = if path.is_file() && !flags.force {
let config: bitrouter_sdk::config::Config =
serde_saphyr::from_str(&std::fs::read_to_string(&path)?)?;
config.chat.agent
} else {
None
};
let agent = flags
.harnesses
.first()
.copied()
.map(acp_id)
.map(String::from)
.or(existing_agent)
.unwrap_or_else(|| "codex-acp".into());
let path = save_config(&flags, &agent, &configured)?;
let after = flags.after.unwrap_or(AfterAction::Exit);
let report = OnboardingReport {
action: "onboarding",
providers_configured: configured.into_iter().collect(),
providers_skipped_interactive: skipped,
harnesses_installed: flags
.harnesses
.iter()
.copied()
.map(acp_id)
.map(String::from)
.chain(flags.harnesses.is_empty().then(|| agent.to_string()))
.collect(),
after: after.as_str().to_string(),
snippet: None,
};
match after {
AfterAction::Launch => finish_launch(&path, report, output).await,
AfterAction::Serve => finish_serve(&path, 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,
manager: Arc<CredentialManager>,
) -> Result<()> {
let cloud = PROVIDER_ID;
if let Some(key) = flags.api_key.as_deref() {
seed_cloud_api_key(key, Arc::clone(&manager)).await?;
configured.insert(cloud.to_string());
} else if flags.cloud_login {
if headless {
skipped.push(cloud.to_string());
} else {
match seed_cloud_interactive(Arc::clone(&manager)).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, manager: Arc<CredentialManager>) -> Result<()> {
cloud_login(
manager,
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,
manager: Arc<CredentialManager>,
) -> Result<()> {
eprintln!();
eprintln!("Welcome to BitRouter — let's get you to first value.");
eprintln!();
if signals.is_configured() {
note(&format!(
"Detected: {}",
OnboardingStatusReport::from_signals(signals)
.signals
.join("; ")
));
}
let mut configured = signals.already_configured();
let mut skipped: Vec<String> = Vec::new();
apply_flag_credentials(
&flags,
&mut configured,
&mut skipped,
false,
Arc::clone(&manager),
)
.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(&flags, &mut configured, &mut skipped, manager).await?;
}
let installed = interactive_harness(&flags).await?;
let after = interactive_after(&flags, &installed).await?;
let agent = installed
.first()
.context("select an ACP harness to complete onboarding")?;
let path = save_config(&flags, agent, &configured)?;
let report = OnboardingReport {
action: "onboarding",
providers_configured: configured.into_iter().collect(),
providers_skipped_interactive: skipped,
harnesses_installed: installed,
after: after.as_str().to_string(),
snippet: None,
};
match after {
AfterAction::Launch => finish_launch(&path, report, output).await,
AfterAction::Serve => finish_serve(&path, report, output).await,
AfterAction::Exit => emit(output, &report),
}
}
fn provider_choices(
data: &bitrouter_providers::registry::types::RegistryData,
) -> Vec<&bitrouter_providers::registry::types::RegistryProvider> {
let mut providers: Vec<_> = data
.providers
.iter()
.filter(|provider| provider.is_active() && provider.is_mergeable())
.collect();
providers.sort_by_key(|provider| {
(
provider
.display_name
.as_deref()
.unwrap_or(&provider.name)
.to_lowercase(),
provider.name.clone(),
)
});
providers
}
async fn interactive_credentials(
flags: &OnboardingFlags,
configured: &mut BTreeSet<String>,
skipped: &mut Vec<String>,
manager: Arc<CredentialManager>,
) -> Result<()> {
use bitrouter_tui::select::Item;
let path = config_path(flags.config.as_deref())?;
let registry = if path.is_file() && !flags.force {
serde_saphyr::from_str::<bitrouter_sdk::config::Config>(&std::fs::read_to_string(path)?)?
.registry
} else {
bitrouter_sdk::config::RegistryConfig::default()
};
note("loading providers from the registry…");
let data = crate::bundled_registry::load(®istry).await;
let providers = data.as_ref().map(provider_choices).unwrap_or_default();
for provider in &providers {
if provider
.env_credential_var()
.is_some_and(|var| crate::spawn::nonempty_env(&var).is_some())
{
configured.insert(provider.name.clone());
}
}
let mut help = if providers.is_empty() {
"Registry unavailable or disabled. Continue to set up an ACP harness.".to_string()
} else {
"Choose a provider to sign in. End jumps to Continue.".to_string()
};
let mut selected = if configured.is_empty() {
0
} else {
providers.len()
};
loop {
let mut items: Vec<_> = providers
.iter()
.map(|provider| {
let status = if configured.contains(&provider.name) {
" · configured"
} else {
""
};
Item::new(
provider.display_name.as_deref().unwrap_or(&provider.name),
format!("{}{status}", provider.name),
)
})
.collect();
items.push(Item::new(
"Continue to harness setup",
if configured.is_empty() {
"skip provider login for now".to_string()
} else {
format!("use {} configured provider(s)", configured.len())
},
));
selected = crate::prompt::select("Step 1/3 — Providers", &help, items, selected).await?;
let Some(provider) = providers.get(selected) else {
return Ok(());
};
let id = &provider.name;
let result = if id == PROVIDER_ID {
let method = crate::prompt::select(
&format!(
"Log in to {}",
provider.display_name.as_deref().unwrap_or(id)
),
"Choose an authentication method",
vec![
Item::new("Browser sign-in", "device-code OAuth"),
Item::new("API key", "paste a static credential"),
],
0,
)
.await?;
if method == 0 {
seed_cloud_interactive(Arc::clone(&manager)).await
} else {
let key = crate::commands::read_api_key(id)?;
seed_cloud_api_key(&key, Arc::clone(&manager)).await
}
} else {
crate::commands::login_registry_provider(provider)
.await
.map(|_| ())
};
match result {
Ok(()) => {
configured.insert(id.clone());
skipped.retain(|skipped_id| skipped_id != id);
help = format!("{id} configured. Add another provider, or Continue.");
selected = providers.len();
}
Err(error) => {
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|e| e.kind() == std::io::ErrorKind::Interrupted)
{
return Err(error);
}
note(&format!(
"provider '{id}' login did not complete: {error:#}"
));
help = format!("{id}: {error}. Select to retry, or choose another provider.");
if !skipped.contains(id) {
skipped.push(id.clone());
}
}
}
}
}
async fn seed_cloud_interactive(manager: Arc<CredentialManager>) -> Result<()> {
cloud_login(
manager,
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 — Default ACP harness");
note("BitRouter provides the TUI. ACP adapters use a compatible local CLI when available.");
if !flags.harnesses.is_empty() {
return Ok(flags
.harnesses
.iter()
.copied()
.map(acp_id)
.map(String::from)
.collect());
}
let harnesses = crate::harness::CATALOG;
let selected = crate::prompt::select(
"Step 2/3 — Default ACP harness",
"BitRouter provides the TUI. Choose the agent it runs.",
harnesses
.iter()
.map(|harness| bitrouter_tui::select::Item::new(harness.id, harness.description))
.collect(),
harnesses
.iter()
.position(|harness| harness.id == "codex-acp")
.unwrap_or(0),
)
.await?;
Ok(vec![
harnesses
.get(selected)
.context("no ACP harness selected")?
.id
.to_string(),
])
}
async fn interactive_after(flags: &OnboardingFlags, installed: &[String]) -> Result<AfterAction> {
use bitrouter_tui::select::Item;
if let Some(after) = flags.after {
return Ok(after);
}
let mut actions = Vec::new();
let mut items = Vec::new();
if !installed.is_empty() {
actions.push(AfterAction::Launch);
items.push(Item::new(
"Open BitRouter Code now",
"use the selected default ACP harness",
));
}
actions.extend([AfterAction::Serve, AfterAction::Exit]);
items.extend([
Item::new("Start the daemon", "print connection instructions"),
Item::new("Save and exit", "run bitrouter whenever you're ready"),
]);
let selected = crate::prompt::select(
"Step 3/3 — Finish",
"Save your setup and choose what happens next",
items,
0,
)
.await?;
actions
.get(selected)
.copied()
.context("no finish action selected")
}
async fn finish_launch(
path: &std::path::Path,
report: OnboardingReport,
output: &Output,
) -> Result<()> {
let source = crate::paths::resolve_config(Some(path))?;
let config = crate::paths::load_config(&source).await?;
emit(output, &report)?;
start_default_chat(&source, config).await
}
async fn finish_serve(
path: &std::path::Path,
mut report: OnboardingReport,
output: &Output,
) -> Result<()> {
let source = crate::paths::resolve_config(Some(path))?;
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 = format!("bitrouter code codex --base-url {base_url}");
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,
)
.await?
} 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 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)
}
async fn prompt_yes_no(prompt: &str, default_yes: bool) -> Result<bool> {
let index = crate::prompt::select(
prompt,
"Choose whether to continue",
vec![
bitrouter_tui::select::Item::new("Yes", ""),
bitrouter_tui::select::Item::new("No", ""),
],
usize::from(!default_yes),
)
.await?;
Ok(index == 0)
}
fn print_hint() {
let p = crate::style::Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} setup is not complete. Run `bitrouter` in a terminal or `bitrouter init --yes`:",
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 provider_menu_contains_every_public_active_entry_in_display_order() -> Result<()> {
let data: bitrouter_providers::registry::types::RegistryData =
serde_json::from_value(serde_json::json!({
"providers": [
{"name": "zulu", "display_name": "Zulu", "status": "active"},
{"name": "bitrouter", "display_name": "BitRouter Cloud", "status": "active"},
{"name": "alpha", "display_name": "Alpha", "status": "active"},
{"name": "private", "status": "active", "access": "private"},
{"name": "preview", "status": "staging"},
{"name": "retired", "status": "withdrawn"}
], "canonical": []
}))?;
let ids: Vec<_> = provider_choices(&data)
.iter()
.map(|provider| provider.name.as_str())
.collect();
assert_eq!(ids, ["alpha", "bitrouter", "zulu"]);
Ok(())
}
#[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());
assert!(
v.get("optimization").is_none(),
"onboarding must not expose the removed optimization workflow"
);
}
#[tokio::test]
async fn headless_init_writes_no_removed_optimization_artifacts() -> Result<()> {
let directory = tempfile::tempdir()?;
let config = directory.path().join("bitrouter.yaml");
run_headless(
OnboardingFlags {
config: Some(config.clone()),
yes: true,
after: Some(AfterAction::Exit),
..OnboardingFlags::default()
},
&Output::new(crate::output::Format::Json),
Arc::new(CredentialManager::with_client(
directory.path().join("account-credentials.json"),
reqwest::Client::new(),
)),
)
.await?;
assert!(config.is_file(), "normal starter config was not written");
for removed in [
"bitrouter.optimize.yaml",
"bitrouter.optimize.lock.yaml",
"bitrouter.eval.md",
] {
assert!(
!directory.path().join(removed).exists(),
"removed onboarding artifact {removed} was written"
);
}
Ok(())
}
#[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_eq!(
snippet.codex,
"bitrouter code codex --base-url http://127.0.0.1:4356"
);
}
#[test]
fn reset_clears_cloud_session_and_optionally_providers() -> Result<()> {
let temp = tempfile::tempdir()?;
let dir = temp.path();
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());
Ok(())
}
#[test]
fn reset_is_a_noop_when_nothing_stored() -> Result<()> {
let temp = tempfile::tempdir()?;
let dir = temp.path();
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());
Ok(())
}
#[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("ACP"));
let v = serde_json::to_value(&report).unwrap();
assert_eq!(v["action"], "status");
assert_eq!(v["configured"], true);
}
}