use anyhow::{Context, Result};
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::config::{Config, ConfigRoutingTable};
use bitrouter_sdk::invocation;
use bitrouter_sdk::language_model::routing::ModelInfo;
use bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};
use crate::actions::skills::SkillsReport;
use crate::auth::{NewApiKey, db as auth_db, generate};
use crate::daemon::RouteHop;
use crate::output::reports::skills::SkillInitReport;
pub const STARTER_CONFIG: &str = r#"# bitrouter.yaml — generated by `bro init`.
#
# This file mirrors the zero-config in-memory default. Delete any
# section you don't want to override, or add custom providers /
# guardrails / policies / MCP servers as needed.
server:
# Bind localhost only by default — combined with `skip_auth: true`,
# exposing `0.0.0.0` would let any host on the network use bitrouter
# without a credential check. Flip to `0.0.0.0:4356` only when you've
# also set `skip_auth: false` and minted virtual keys with
# `bro key sign`.
listen: "127.0.0.1:4356"
# skip_auth=true admits credential-less local requests (cc-switch /
# litellm style). Set to false and create keys with `bro key
# sign` for a multi-tenant deployment.
skip_auth: true
# Retryable fallback is immediate by default. For providers that can return a
# short overload burst, opt into a bounded delay schedule. Values apply before
# the 2nd, 3rd, ... route candidate; the final value repeats for longer chains.
# upstream:
# fallback_backoff_ms: [1000, 2000, 4000, 8000, 16000, 30000]
database:
# Relative paths are interpreted against the bitrouter home (the
# directory containing this file) — the daemon chdirs there on
# startup, so `./bitrouter.db` always lands alongside the config.
url: "sqlite://./bitrouter.db"
# Routing policies are opt-in and live in a sibling policy-lock.yaml.
# `bro policy init <name> --preset <preset> --strong <model>
# --economy <model>` creates the lock and preset binding. With no binding,
# model routing behaves exactly as it does in zero-config mode.
# Built-in providers auto-enable when their env var is set. An entry
# with `inherit_defaults: true` (the workspace default) plus an empty
# body is enough — `api_base`, `api_protocol`, `api_key`, and the
# routable models all flow from the built-in catalog. Comment a line
# out (or set `active: false`) to disable a provider you don't want.
providers:
openai: {} # uses OPENAI_API_KEY
anthropic: {} # uses ANTHROPIC_API_KEY
google: {} # uses GEMINI_API_KEY
openrouter: {} # uses OPENROUTER_API_KEY
# github-copilot needs `bro providers login github-copilot` first:
# github-copilot: {}
# A provider can hold multiple accounts — e.g. two subscriptions to
# the same upstream. `account_strategy` is `failover` (try the first,
# drop to the next on a retryable / out-of-credits error — default)
# or `balance` (spread load evenly across accounts):
# opencode-go: { account_strategy: failover, accounts: [{ api_key: "${OPENCODE_GO_KEY_A}", label: primary }, { api_key: "${OPENCODE_GO_KEY_B}", label: backup }] }
# Upstream MCP (Model Context Protocol) servers — `bro tools
# list/status` reads this, and `POST /mcp/<name>` proxies JSON-RPC
# against the entry. Discover servers from the official MCP registry
# with `bro mcp search <query>` and print a paste-ready stub with
# `bro mcp add <name>`. Spec:
# https://modelcontextprotocol.io/specification/2025-06-18
# mcp_servers:
# ctx7:
# name: ctx7
# transport:
# type: http
# url: https://mcp.context7.com/mcp
# headers:
# Authorization: "Bearer ${CTX7_TOKEN}"
# git:
# name: git
# transport:
# type: stdio
# command: uvx
# args: ["mcp-server-git"]
# Upstream ACP (Agent Client Protocol) agents — manage with
# `bro agents list` / `check` / `install`. Spec:
# https://agentclientprotocol.com/protocol/schema
# agents:
# claude:
# name: claude
# transport:
# type: stdio
# command: npx
# args: ["-y", "@agentclientprotocol/claude-agent-acp@0.70.0"]
# codex:
# name: codex
# transport:
# type: stdio
# command: npx
# args: ["-y", "@agentclientprotocol/codex-acp@1.7.0"]
inherit_defaults: true
"#;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScaffoldOutcome {
Wrote,
Skipped,
}
pub async fn write_starter_config(path: &std::path::Path, force: bool) -> Result<ScaffoldOutcome> {
if !force && tokio::fs::try_exists(path).await.unwrap_or(false) {
return Ok(ScaffoldOutcome::Skipped);
}
tokio::fs::write(path, STARTER_CONFIG)
.await
.with_context(|| format!("writing {}", path.display()))?;
Ok(ScaffoldOutcome::Wrote)
}
pub struct GeneratedKey {
pub id: String,
pub secret: String,
}
pub async fn key_sign(
db_url: &str,
user_id: &str,
policy_id: Option<&str>,
) -> Result<GeneratedKey> {
let db = crate::db::connect(db_url)
.await
.with_context(|| format!("connecting to database {db_url}"))?;
crate::db::run_migrations(&db)
.await
.context("running database migrations")?;
auth_db::upsert_user(&db, user_id)
.await
.context("creating user row")?;
let key = generate();
let id = format!("brvk_id_{}", &key.hash[..16]);
auth_db::insert_api_key(
&db,
&NewApiKey {
id: id.clone(),
key_hash: key.hash.clone(),
user_id: user_id.to_string(),
spend_limit_micro_usd: None,
rpm_limit: None,
policy_id: policy_id.map(|s| s.to_string()),
},
)
.await
.context("inserting api key")?;
Ok(GeneratedKey {
id,
secret: key.secret,
})
}
pub async fn list_models(config: &Config) -> Result<Vec<ModelInfo>> {
let mut resolved = resolve_static(config.clone());
bitrouter_sdk::config::discover_models(&mut resolved).await;
Ok(ConfigRoutingTable::from_config(resolved).list_models())
}
pub fn resolve_static(mut config: Config) -> Config {
bitrouter_providers::apply_builtin_defaults(&mut config);
if let Ok(store) = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()
{
bitrouter_providers::activate_stored_credential_providers(&mut config, &store);
}
config
}
pub async fn resolve_route(config: &Config, model: &str) -> Result<Vec<RouteHop>> {
let table = ConfigRoutingTable::from_config(resolve_static(config.clone()));
let chain = table
.route_chain(model, &RoutingPrefs::default(), &CallerContext::local())
.await
.with_context(|| format!("resolving model '{model}'"))?;
Ok(chain
.into_iter()
.map(|t| RouteHop {
provider: t.provider_name,
service_id: t.service_id,
api_protocol: format!("{:?}", t.api_protocol).to_lowercase(),
})
.collect())
}
pub struct ProviderListing {
pub id: String,
pub api_base: String,
pub active: bool,
pub model_count: usize,
}
pub fn list_providers(config: &Config) -> Vec<ProviderListing> {
let mut resolved = config.clone();
bitrouter_providers::apply_builtin_defaults(&mut resolved);
let mut providers: Vec<ProviderListing> = resolved
.providers
.iter()
.map(|(id, p)| ProviderListing {
id: id.clone(),
api_base: p.api_base.clone(),
active: p.active,
model_count: p.models.len(),
})
.collect();
providers.sort_by(|a, b| a.id.cmp(&b.id));
providers
}
fn validate_policy_id(id: &str) -> Result<()> {
if id.is_empty() {
anyhow::bail!("policy id must not be empty");
}
if id.starts_with('.') {
anyhow::bail!("policy id must not start with '.' (no dotfiles / `..`)");
}
if id.chars().any(|c| {
c == '/'
|| c == '\\'
|| c == '\0'
|| c.is_whitespace()
|| !(c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
}) {
anyhow::bail!(
"policy id '{id}' contains invalid characters (allowed: letters, digits, `_`, `-`, `.`)"
);
}
Ok(())
}
pub async fn create_policy(policy_dir: &std::path::Path, id: &str) -> Result<std::path::PathBuf> {
validate_policy_id(id)?;
tokio::fs::create_dir_all(policy_dir)
.await
.with_context(|| format!("creating policy dir {}", policy_dir.display()))?;
let path = policy_dir.join(format!("{id}.yaml"));
if tokio::fs::try_exists(&path).await.unwrap_or(false) {
anyhow::bail!("policy '{id}' already exists at {}", path.display());
}
let body = format!(
"# policy '{id}' — edit to taste, then bind it to a key:\n\
# bro key sign --user <id> --policy {id}\n\
id: {id}\n\
# allowed_models: [gpt-5, claude-sonnet-4-6]\n\
# denied_models: []\n\
# max_spend_micro_usd: 1000000\n\
# max_requests_per_minute: 60\n\
# allowed_tools: [search]\n"
);
tokio::fs::write(&path, body)
.await
.with_context(|| format!("writing {}", path.display()))?;
Ok(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthMethod {
ClaudeCodeSession,
PkceSubscription,
ImportFromCli,
DeviceCode,
ApiKey,
}
impl AuthMethod {
fn label(self) -> &'static str {
match self {
AuthMethod::ClaudeCodeSession => {
"Use your Claude Code session (read ~/.claude, or sign in via the claude CLI)"
}
AuthMethod::PkceSubscription => "Subscription (browser sign-in)",
AuthMethod::ImportFromCli => "Import an existing session from the vendor CLI",
AuthMethod::DeviceCode => "Device-code OAuth (show a code in browser)",
AuthMethod::ApiKey => "API key (paste a static credential)",
}
}
}
fn import_cli_for(provider_id: &str) -> Option<&'static str> {
match provider_id {
bitrouter_providers::codex::PROVIDER_ID => Some("Codex"),
bitrouter_providers::supergrok::PROVIDER_ID => Some("Grok"),
bitrouter_providers::antigravity::PROVIDER_ID => Some("Antigravity (agy)"),
_ => None,
}
}
fn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec<AuthMethod> {
use bitrouter_providers::AuthScheme;
let mut methods = Vec::new();
let is_claude_code = entry.id == bitrouter_providers::claude_code::PROVIDER_ID;
if is_claude_code {
methods.push(AuthMethod::ClaudeCodeSession);
}
if import_cli_for(&entry.id).is_some() {
methods.push(AuthMethod::ImportFromCli);
}
let is_anthropic = entry.id == bitrouter_providers::anthropic::PROVIDER_ID;
let has_pkce = !is_anthropic && bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);
if has_pkce {
methods.push(AuthMethod::PkceSubscription);
}
match &entry.auth {
AuthScheme::Bearer { .. } | AuthScheme::Header { .. } => {
methods.push(AuthMethod::ApiKey);
}
AuthScheme::Oauth { params, .. } => {
let has_device = params
.get("device_authorization_endpoint")
.and_then(|v| v.as_str())
.is_some();
if has_device {
methods.push(AuthMethod::DeviceCode);
}
}
AuthScheme::Native { .. } => {}
}
methods
}
async fn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result<AuthMethod> {
let index = crate::prompt::select(
&format!("Log in to {provider}"),
"Choose an authentication method",
options
.iter()
.map(|method| bitrouter_tui::select::Item::new(method.label(), ""))
.collect(),
0,
)
.await?;
options
.get(index)
.copied()
.context("no authentication method selected")
}
pub struct LoginOutcome {
pub provider: String,
pub label: String,
pub method: String,
pub path: String,
}
#[derive(Debug, Clone, Default)]
pub struct ProviderLoginOptions {
pub import_existing: bool,
pub no_browser: bool,
pub api_key: Option<String>,
}
pub async fn login_provider(provider_id: &str, label: &str) -> Result<LoginOutcome> {
login_provider_with_options(provider_id, label, ProviderLoginOptions::default()).await
}
pub async fn login_provider_with_options(
provider_id: &str,
label: &str,
options: ProviderLoginOptions,
) -> Result<LoginOutcome> {
use bitrouter_providers::builtin;
let entry: bitrouter_providers::ProviderEntry = match builtin::find(provider_id)
.cloned()
.or(crate::bundled_registry::provider(provider_id)?)
{
Some(e) => e,
None => {
let data =
crate::bundled_registry::load(&bitrouter_sdk::config::RegistryConfig::default())
.await
.ok_or_else(|| {
anyhow::anyhow!(
"could not load the provider registry (offline, and nothing cached) \
to resolve '{provider_id}'"
)
})?;
let provider = data
.providers
.iter()
.find(|p| p.name == provider_id)
.ok_or_else(|| {
anyhow::anyhow!(
"unknown provider '{provider_id}' — try `{cli} providers list` \
for the catalog",
cli = invocation::name()
)
})?;
builtin::entry_from_registry(provider)
.with_context(|| format!("resolving the auth shape for '{provider_id}'"))?
}
};
login_entry(&entry, label, options).await
}
pub(crate) async fn login_registry_provider(
provider: &bitrouter_providers::registry::types::RegistryProvider,
) -> Result<LoginOutcome> {
let entry = bitrouter_providers::builtin::entry_from_registry(provider)?;
login_entry(
&entry,
bitrouter_providers::oauth::credential_store::DEFAULT_LABEL,
ProviderLoginOptions::default(),
)
.await
}
async fn login_entry(
entry: &bitrouter_providers::ProviderEntry,
label: &str,
options: ProviderLoginOptions,
) -> Result<LoginOutcome> {
let provider_id = entry.id.as_str();
let methods = available_methods(entry);
if methods.is_empty() {
anyhow::bail!(
"provider '{provider_id}' has no interactive login path \
(auth scheme: {:?})",
std::mem::discriminant(&entry.auth)
);
}
let chosen = choose_auth_method(entry, &methods, &options).await?;
eprintln!();
eprintln!(
" Logging in to {} via {}",
entry.display_name,
chosen.label()
);
let credential = match chosen {
AuthMethod::ClaudeCodeSession => run_claude_code_session().await?,
AuthMethod::PkceSubscription => run_pkce_subscription(provider_id).await?,
AuthMethod::ImportFromCli => run_cli_import(provider_id)?,
AuthMethod::DeviceCode => run_device_code(provider_id, entry).await?,
AuthMethod::ApiKey => match options.api_key.as_deref() {
Some(key) => {
let key = key.trim();
if key.is_empty() {
anyhow::bail!("the supplied API key is empty — aborting login");
}
bitrouter_providers::oauth::credential_store::Credential::api_key(key)
}
None => run_api_key_paste(&entry.display_name)?,
},
};
let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()
.context("opening credential store")?;
let kind = credential.kind_label();
store
.set(provider_id, label, credential)
.with_context(|| format!("persisting credential for {provider_id} ({label})"))?;
eprintln!(
" ✓ Saved {kind} credential for {provider_id} (label: {label}) at {}",
store.path().display()
);
eprintln!();
Ok(LoginOutcome {
provider: provider_id.to_string(),
label: label.to_string(),
method: kind.to_string(),
path: store.path().display().to_string(),
})
}
async fn choose_auth_method(
entry: &bitrouter_providers::ProviderEntry,
methods: &[AuthMethod],
options: &ProviderLoginOptions,
) -> Result<AuthMethod> {
if options.api_key.is_some() {
if methods.contains(&AuthMethod::ApiKey) {
return Ok(AuthMethod::ApiKey);
}
anyhow::bail!(
"provider '{}' does not accept a pasted API key — it uses an \
interactive OAuth flow; drop --api-key/--key-stdin",
entry.id
);
}
if options.import_existing {
if methods.contains(&AuthMethod::ImportFromCli) {
return Ok(AuthMethod::ImportFromCli);
}
anyhow::bail!(
"provider '{}' cannot import an existing vendor CLI session",
entry.id
);
}
let selectable: Vec<AuthMethod> = if options.no_browser {
methods
.iter()
.copied()
.filter(|m| *m != AuthMethod::PkceSubscription)
.collect()
} else {
methods.to_vec()
};
if selectable.is_empty() {
anyhow::bail!(
"provider '{}' has no non-browser login path; rerun without --no-browser",
entry.id
);
}
if selectable.len() == 1 || options.no_browser {
Ok(selectable[0])
} else {
prompt_method_choice(&entry.display_name, &selectable).await
}
}
async fn run_claude_code_session()
-> Result<bitrouter_providers::oauth::credential_store::Credential> {
use std::io::IsTerminal;
use bitrouter_providers::import::claude_code::ClaudeCodeStore;
use bitrouter_providers::oauth::credential_store::Credential;
crate::claude_code::migrate_legacy_anthropic_marker_default();
let store = ClaudeCodeStore::system().ok_or_else(|| {
anyhow::anyhow!(
"could not resolve your home directory (set HOME) to find the Claude Code session"
)
})?;
if let Some(live) = store
.read()
.context("reading your existing Claude Code session")?
{
eprintln!(
" Found your Claude Code session ({}) — bitrouter will read it live.",
live.source
);
return Ok(Credential::ClaudeCodeCli);
}
if !std::io::stdin().is_terminal() {
anyhow::bail!(
"not signed in to Claude Code. Run `claude auth login` (install the CLI first with \
`curl -fsSL https://claude.ai/install.sh | bash`), then re-run \
`bro providers login claude-code`."
);
}
let claude = crate::spawn::ensure_agent_installed(crate::spawn::SpawnAgent::Claude, false)
.await
.context("locating the claude CLI to sign you in")?;
eprintln!(" You're not signed in to Claude Code yet.");
eprintln!(" Handing over to `claude auth login` — it is a separate program.");
eprintln!(" It opens a browser, and may then ask you to paste a code back here.");
eprintln!(" Complete both steps; this command resumes when it exits.");
eprintln!(" ─────────────────────────────────────────────────────────────");
let status = tokio::process::Command::new(&claude)
.arg("auth")
.arg("login")
.arg("--claudeai")
.status()
.await
.context("running `claude auth login`")?;
eprintln!(" ─────────────────────────────────────────────────────────────");
if !status.success() {
anyhow::bail!(
"`claude auth login` exited without completing the sign-in ({status}). \
If a browser opened, the flow may still need the code from it pasted \
into that prompt. Check with `claude auth status`, then re-run \
`bro providers login claude-code`."
);
}
match store
.read()
.context("re-reading your Claude Code session after sign-in")?
{
Some(live) => {
eprintln!(
" Signed in — bitrouter will read your session live from {}.",
live.source
);
Ok(Credential::ClaudeCodeCli)
}
None => anyhow::bail!(
"still no Claude Code session after `claude auth login` — run `claude auth status` \
to check, then retry."
),
}
}
async fn run_pkce_subscription(
provider_id: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
let registry = bitrouter_providers::oauth::registry::find(provider_id).ok_or_else(|| {
anyhow::anyhow!(
"provider '{provider_id}' has no PKCE login registration — \
this should have been caught by available_methods()"
)
})?;
let client = reqwest::Client::builder()
.user_agent(concat!("bitrouter/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(30))
.build()
.context("building HTTP client for PKCE login")?;
let ux = StderrLoginUx;
let outcome = bitrouter_providers::oauth::login::run_login(
&client,
®istry,
&ux,
bitrouter_providers::oauth::auth_code::MANUAL_PASTE_TIMEOUT,
)
.await
.with_context(|| format!("PKCE login for {provider_id}"))?;
if outcome.manual_fallback_used {
eprintln!(" (used manual paste fallback)");
}
Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(outcome.token))
}
async fn run_device_code(
provider_id: &str,
entry: &bitrouter_providers::ProviderEntry,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
use bitrouter_providers::AuthScheme;
let params = match &entry.auth {
AuthScheme::Oauth { params, .. } => params,
_ => anyhow::bail!("provider '{provider_id}' is not OAuth — refusing device flow"),
};
let client_id = params
.get("client_id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("'{provider_id}': auth.params.client_id is missing"))?
.to_string();
let scope = params
.get("scope")
.and_then(|v| v.as_str())
.map(str::to_string);
let device_url = params
.get("device_authorization_endpoint")
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow::anyhow!("'{provider_id}': auth.params.device_authorization_endpoint is missing")
})?
.to_string();
let token_url = params
.get("token_endpoint")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("'{provider_id}': auth.params.token_endpoint is missing"))?
.to_string();
let flow = bitrouter_providers::oauth::DeviceCodeFlow::new(
bitrouter_providers::oauth::DeviceCodeParams {
client_id,
scope,
device_authorization_endpoint: device_url,
token_endpoint: token_url,
},
)
.with_context(|| format!("building OAuth flow for {provider_id}"))?;
let device_code = flow
.request_device_code()
.await
.with_context(|| format!("requesting OAuth device code for {provider_id}"))?;
eprintln!();
eprintln!(" OAuth device authorization for {}", entry.display_name);
eprintln!(" ──────────────────────────────────────────");
if let Some(complete) = &device_code.verification_uri_complete {
eprintln!(" Open: {complete}");
} else {
eprintln!(" Open: {}", device_code.verification_uri);
}
eprintln!(" Code: {}", device_code.user_code);
eprintln!();
eprintln!(" Waiting for authorization…");
let mut interval = std::time::Duration::from_secs(device_code.interval.max(1));
let token = loop {
tokio::time::sleep(interval).await;
match flow.poll_once(&device_code.device_code).await? {
bitrouter_providers::oauth::device_code::PollOutcome::Token(t) => break t,
bitrouter_providers::oauth::device_code::PollOutcome::Pending => continue,
bitrouter_providers::oauth::device_code::PollOutcome::SlowDown => {
interval += std::time::Duration::from_secs(5);
}
}
};
Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(token))
}
fn run_cli_import(
provider_id: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
let imported = match provider_id {
bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::import(),
bitrouter_providers::supergrok::PROVIDER_ID => bitrouter_providers::import::grok::import(),
bitrouter_providers::antigravity::PROVIDER_ID => {
bitrouter_providers::import::antigravity::import()
}
other => anyhow::bail!("no vendor-CLI import is available for provider '{other}'"),
}
.with_context(|| format!("importing a CLI credential for {provider_id}"))?;
let imported = imported.ok_or_else(|| {
anyhow::anyhow!(
"no existing CLI session found for {provider_id} — sign in to the \
vendor CLI first, or choose the browser sign-in instead"
)
})?;
eprintln!(" Imported existing session from {}", imported.source);
Ok(bitrouter_providers::oauth::credential_store::Credential::from_oauth_token(imported.token))
}
fn run_api_key_paste(
display_name: &str,
) -> Result<bitrouter_providers::oauth::credential_store::Credential> {
Ok(
bitrouter_providers::oauth::credential_store::Credential::api_key(read_api_key(
display_name,
)?),
)
}
pub(crate) fn read_api_key(display_name: &str) -> Result<String> {
use std::io::BufRead;
eprintln!();
eprintln!(" Paste your {display_name} API key, then press enter:");
eprint!(" > ");
let stdin = std::io::stdin();
let mut line = String::new();
stdin
.lock()
.read_line(&mut line)
.context("reading API key from stdin")?;
let key = line.trim();
if key.is_empty() {
anyhow::bail!("no API key entered — aborting login");
}
Ok(key.to_string())
}
struct StderrLoginUx;
#[async_trait::async_trait]
impl bitrouter_providers::oauth::login::LoginUx for StderrLoginUx {
async fn show_authorize_url(&self, url: &str, hint: &str) {
eprintln!();
eprintln!(" Open this URL in your browser to continue:");
eprintln!();
eprintln!(" {url}");
eprintln!();
eprintln!(" {hint}");
}
async fn prompt_pasted_redirect(
&self,
) -> std::result::Result<String, bitrouter_providers::oauth::login::LoginError> {
let pasted = tokio::task::spawn_blocking(|| {
use std::io::BufRead;
eprint!(" Paste redirect URL (or just the code): ");
let stdin = std::io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line).map_err(|e| {
bitrouter_providers::oauth::login::LoginError::UserIo(format!(
"reading pasted redirect: {e}"
))
})?;
Ok::<_, bitrouter_providers::oauth::login::LoginError>(line)
})
.await
.map_err(|e| {
bitrouter_providers::oauth::login::LoginError::UserIo(format!(
"join error reading pasted redirect: {e}"
))
})??;
Ok(pasted)
}
}
pub async fn logout_provider(provider_id: &str) -> Result<usize> {
let mut store = bitrouter_providers::oauth::credential_store::CredentialStore::default_path()
.context("opening credential store")?;
let removed = store
.remove_all_for(provider_id)
.with_context(|| format!("removing stored credentials for {provider_id}"))?;
match removed {
0 => eprintln!(" No stored credentials for {provider_id}; nothing to remove."),
n => eprintln!(" ✓ Removed {n} stored credential(s) for {provider_id}."),
}
Ok(removed)
}
pub fn skills_list(global: bool) -> Result<SkillsReport> {
let cwd = std::env::current_dir().context("resolving the current directory")?;
let roots = crate::skills::root::SkillsRoot::cli_scope(global, cwd)?;
Ok(crate::actions::skills::InstalledSkills::new(roots).report())
}
pub fn skills_init(name: &str, output: &std::path::Path) -> Result<SkillInitReport> {
crate::skills::validate_skill_name(name)?;
if output.file_name().and_then(|file| file.to_str()) != Some("SKILL.md") {
anyhow::bail!("skill output must be named SKILL.md");
}
let declared_parent = output
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| std::path::Path::new("."));
let resolved_parent = if declared_parent == std::path::Path::new(".") {
std::env::current_dir().context("resolving the current directory")?
} else {
declared_parent.to_path_buf()
};
if resolved_parent.file_name().and_then(|dir| dir.to_str()) != Some(name) {
anyhow::bail!("skill parent directory must be named '{name}' to match its frontmatter");
}
if output.exists() {
anyhow::bail!("{} already exists; refusing to overwrite", output.display());
}
std::fs::create_dir_all(declared_parent)
.with_context(|| format!("creating {}", declared_parent.display()))?;
let body = format!(
"---\nname: {name}\ndescription: TODO — one line on what this skill does and when to use it.\n---\n\n# {name}\n\nTODO: instructions for the agent.\n\n## When to use\n\n## Steps\n"
);
std::fs::write(output, body).with_context(|| format!("writing {}", output.display()))?;
Ok(SkillInitReport {
path: output.display().to_string(),
created: true,
})
}
#[cfg(test)]
mod skills_init_tests {
use super::*;
#[test]
fn scaffold_requires_skill_md_under_the_named_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let wrong = dir.path().join("other").join("SKILL.md");
std::fs::create_dir_all(wrong.parent().expect("parent")).expect("mkdir");
let Err(err) = skills_init("demo", &wrong) else {
panic!("directory/name mismatch must be invalid");
};
assert!(err.to_string().contains("parent directory"), "{err}");
}
#[test]
fn scaffold_creates_a_valid_named_skill_directory() {
let dir = tempfile::tempdir().expect("tempdir");
let output = dir.path().join("demo").join("SKILL.md");
let report = skills_init("demo", &output).expect("scaffold");
assert!(output.is_file());
assert_eq!(report.path, output.display().to_string());
let parsed = crate::skills::format::parse_frontmatter(
&std::fs::read_to_string(output).expect("read scaffold"),
)
.expect("valid frontmatter");
assert_eq!(parsed.name, "demo");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn init_writes_skip_auth_true_config() {
let dir = std::env::temp_dir().join(format!(
"brinit-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
tokio::fs::create_dir_all(&dir).await.unwrap();
let path = dir.join("bitrouter.yaml");
assert_eq!(
write_starter_config(&path, false).await.unwrap(),
ScaffoldOutcome::Wrote
);
let written = tokio::fs::read_to_string(&path).await.unwrap();
assert!(written.contains("skip_auth: true"));
let cfg = bitrouter_sdk::config::parse_with(&written, |_| Some("x".to_string())).unwrap();
assert!(cfg.server.skip_auth);
tokio::fs::write(&path, "# hand-edited\n").await.unwrap();
assert_eq!(
write_starter_config(&path, false).await.unwrap(),
ScaffoldOutcome::Skipped
);
assert_eq!(
tokio::fs::read_to_string(&path).await.unwrap(),
"# hand-edited\n"
);
assert_eq!(
write_starter_config(&path, true).await.unwrap(),
ScaffoldOutcome::Wrote
);
assert!(
tokio::fs::read_to_string(&path)
.await
.unwrap()
.contains("skip_auth: true")
);
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn key_sign_produces_brvk_secret_and_persists_only_hash() {
let key = key_sign("sqlite::memory:", "u1", Some("p1")).await.unwrap();
assert!(key.secret.starts_with("brvk_"));
assert!(key.id.starts_with("brvk_id_"));
}
#[test]
fn starter_config_loads_with_all_env_vars_unset() {
let cfg = bitrouter_sdk::config::parse_with(STARTER_CONFIG, |_| None)
.expect("starter config must load with no env vars set");
assert!(cfg.server.skip_auth);
}
#[test]
fn starter_config_parses_with_mcp_and_agents_examples_uncommented() {
let uncommented: String = STARTER_CONFIG
.lines()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("# mcp_servers:")
|| trimmed.starts_with("# agents:")
|| trimmed.starts_with("# ")
|| trimmed.starts_with("# ")
|| trimmed.starts_with("# ")
{
let leading_spaces = line.len() - trimmed.len();
let stripped = trimmed
.strip_prefix("# ")
.or_else(|| trimmed.strip_prefix("#"))
.unwrap_or(trimmed);
format!("{}{}", " ".repeat(leading_spaces), stripped)
} else {
line.to_string()
}
})
.collect::<Vec<_>>()
.join("\n");
let cfg = bitrouter_sdk::config::parse_with(&uncommented, |_| Some("x".to_string()))
.expect("uncommented STARTER_CONFIG must parse against current schema");
assert!(
!cfg.mcp_servers.is_empty(),
"mcp_servers block should land as parsed entries; got: {:?}",
cfg.mcp_servers.keys().collect::<Vec<_>>()
);
assert!(
!cfg.agents.is_empty(),
"agents block should land as parsed entries; got: {:?}",
cfg.agents.keys().collect::<Vec<_>>()
);
}
fn entry_for(json: serde_json::Value) -> bitrouter_providers::ProviderEntry {
let p: bitrouter_providers::registry::types::RegistryProvider =
serde_json::from_value(json).expect("valid registry-provider fixture");
bitrouter_providers::builtin::entry_from_registry(&p).expect("maps to an entry")
}
#[test]
fn claude_code_prefers_claude_code_session_first() {
let entry = entry_for(serde_json::json!({
"name": "claude-code",
"api_base": "https://api.anthropic.com/v1",
"status": "active",
"auth": { "kind": "oauth", "handler": "claude-code" },
"models": []
}));
let methods = available_methods(&entry);
assert_eq!(
methods.first(),
Some(&AuthMethod::ClaudeCodeSession),
"the live Claude Code session must be the default for claude-code"
);
assert!(
!methods.contains(&AuthMethod::ImportFromCli),
"claude-code uses the live session, not the one-shot copy that can family-revoke"
);
}
#[test]
fn anthropic_offers_api_key_only() {
let entry = entry_for(serde_json::json!({
"name": "anthropic",
"api_base": "https://api.anthropic.com/v1",
"status": "active",
"auth": { "kind": "header", "header": "x-api-key", "env": "ANTHROPIC_API_KEY" },
"models": []
}));
let methods = available_methods(&entry);
assert_eq!(
methods,
vec![AuthMethod::ApiKey],
"anthropic must offer the API-key path only"
);
assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));
assert!(!methods.contains(&AuthMethod::PkceSubscription));
assert!(!methods.contains(&AuthMethod::ImportFromCli));
}
#[test]
fn codex_keeps_oneshot_import_and_offers_no_claude_code_session() {
let entry = entry_for(serde_json::json!({
"name": "openai-codex",
"api_base": "https://chatgpt.com/backend-api/codex",
"status": "active",
"auth": { "kind": "oauth", "handler": "openai-codex" },
"models": []
}));
let methods = available_methods(&entry);
assert_eq!(
methods.first(),
Some(&AuthMethod::ImportFromCli),
"openai-codex onboarding should first reuse an existing Codex CLI session"
);
assert!(methods.contains(&AuthMethod::ImportFromCli));
assert!(methods.contains(&AuthMethod::PkceSubscription));
assert!(!methods.contains(&AuthMethod::ClaudeCodeSession));
}
#[tokio::test]
async fn import_existing_selects_vendor_cli_import_without_prompting() -> Result<()> {
let entry = entry_for(serde_json::json!({
"name": "openai-codex",
"api_base": "https://chatgpt.com/backend-api/codex",
"status": "active",
"auth": { "kind": "oauth", "handler": "openai-codex" },
"models": []
}));
let methods = available_methods(&entry);
let chosen = choose_auth_method(
&entry,
&methods,
&ProviderLoginOptions {
import_existing: true,
no_browser: true,
api_key: None,
},
)
.await?;
assert_eq!(chosen, AuthMethod::ImportFromCli);
Ok(())
}
#[tokio::test]
async fn api_key_option_selects_api_key_method_without_prompting() -> Result<()> {
let entry = entry_for(serde_json::json!({
"name": "anthropic",
"api_base": "https://api.anthropic.com/v1",
"status": "active",
"auth": { "kind": "header", "header": "x-api-key", "env": "ANTHROPIC_API_KEY" },
"models": []
}));
let methods = available_methods(&entry);
let chosen = choose_auth_method(
&entry,
&methods,
&ProviderLoginOptions {
import_existing: false,
no_browser: false,
api_key: Some("sk-ant-test".to_string()),
},
)
.await?;
assert_eq!(chosen, AuthMethod::ApiKey);
Ok(())
}
#[tokio::test]
async fn api_key_option_rejects_oauth_only_providers() -> Result<()> {
let entry = entry_for(serde_json::json!({
"name": "openai-codex",
"api_base": "https://chatgpt.com/backend-api/codex",
"status": "active",
"auth": { "kind": "oauth", "handler": "openai-codex" },
"models": []
}));
let methods = available_methods(&entry);
let err = choose_auth_method(
&entry,
&methods,
&ProviderLoginOptions {
import_existing: false,
no_browser: false,
api_key: Some("sk-whatever".to_string()),
},
)
.await
.err()
.context("login flags must be rejected")?;
assert!(format!("{err:#}").contains("does not accept a pasted API key"));
Ok(())
}
#[tokio::test]
async fn import_existing_rejects_providers_without_vendor_cli_import() -> Result<()> {
let entry = entry_for(serde_json::json!({
"name": "anthropic",
"api_base": "https://api.anthropic.com/v1",
"status": "active",
"auth": { "kind": "header", "header": "x-api-key", "env": "ANTHROPIC_API_KEY" },
"models": []
}));
let methods = available_methods(&entry);
let err = choose_auth_method(
&entry,
&methods,
&ProviderLoginOptions {
import_existing: true,
no_browser: true,
api_key: None,
},
)
.await
.err()
.context("login flags must be rejected")?;
assert!(format!("{err:#}").contains("cannot import"));
Ok(())
}
fn sample_config() -> Config {
let yaml = r#"
providers:
openai:
api_base: https://api.openai.com/v1
api_key: k1
models: [{ id: gpt-5 }, { id: shared }]
anthropic:
api_base: https://api.anthropic.com/v1
api_key: k2
models: [{ id: shared }]
"#;
bitrouter_sdk::config::parse_with(yaml, |_| None).unwrap()
}
#[tokio::test]
async fn list_models_keeps_every_provider_of_a_model() {
let cfg = sample_config();
let all = list_models(&cfg).await.unwrap();
assert_eq!(all.len(), 2); let shared = all.iter().find(|m| m.id == "shared").expect("shared");
assert_eq!(
shared.providers,
vec!["anthropic".to_string(), "openai".to_string()]
);
}
#[tokio::test]
async fn resolve_route_returns_the_cascade_chain() {
let cfg = sample_config();
let chain = resolve_route(&cfg, "shared").await.unwrap();
assert_eq!(chain.len(), 2);
assert_eq!(chain[0].provider, "anthropic");
assert_eq!(chain[1].provider, "openai");
assert!(resolve_route(&cfg, "no-such-model").await.is_err());
}
#[test]
fn list_providers_sorts_and_summarises() {
let providers = list_providers(&sample_config());
assert_eq!(providers.len(), 2);
assert_eq!(providers[0].id, "anthropic");
assert_eq!(providers[1].id, "openai");
assert_eq!(providers[1].model_count, 2);
assert!(providers[1].active);
}
#[tokio::test]
async fn create_policy_rejects_path_traversal_ids() {
let dir = std::env::temp_dir().join(format!(
"brpoltrav-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
for bad in [
"../etc/passwd",
"foo/bar",
"..",
".hidden",
"",
"a b",
"x\0y",
] {
let err = create_policy(&dir, bad).await.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("invalid characters")
|| msg.contains("must not")
|| msg.contains("empty"),
"bad id '{bad}' should have been rejected; got: {msg}"
);
}
let _ = tokio::fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn create_policy_writes_a_starter_file() {
let dir = std::env::temp_dir().join(format!(
"brpol-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = create_policy(&dir, "team-default").await.unwrap();
let body = tokio::fs::read_to_string(&path).await.unwrap();
assert!(body.contains("id: team-default"));
assert!(create_policy(&dir, "team-default").await.is_err());
let _ = tokio::fs::remove_dir_all(&dir).await;
}
}