use anyhow::{Context, Result};
use bitrouter_sdk::caller::CallerContext;
use bitrouter_sdk::config::{Config, ConfigRoutingTable};
use bitrouter_sdk::language_model::{RoutingPrefs, RoutingTable};
use crate::auth::{NewApiKey, db as auth_db, generate};
use crate::daemon::RouteHop;
pub const STARTER_CONFIG: &str = r#"# bitrouter.yaml — generated by `bitrouter 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
# `bitrouter 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 `bitrouter key
# sign` for a multi-tenant deployment.
skip_auth: true
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"
# 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 `bitrouter 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 — `bitrouter tools
# list/status` reads this, and `POST /mcp/<name>` proxies JSON-RPC
# against the entry. 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 — invoked from an editor
# via `bitrouter agent-proxy <id>`. Spec:
# https://agentclientprotocol.com/protocol/schema
# agents:
# claude:
# name: claude
# transport:
# type: stdio
# command: npx
# args: ["-y", "@zed-industries/claude-code-acp@latest"]
# codex:
# name: codex
# transport:
# type: stdio
# command: npx
# args: ["-y", "@zed-industries/codex-acp@latest"]
inherit_defaults: true
"#;
pub async fn init(path: &std::path::Path) -> Result<()> {
if tokio::fs::try_exists(path).await.unwrap_or(false) {
anyhow::bail!("{} already exists — refusing to overwrite", path.display());
}
tokio::fs::write(path, STARTER_CONFIG)
.await
.with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
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,
provider_filter: Option<&str>,
) -> Result<Vec<(String, Vec<String>)>> {
let mut resolved = config.clone();
bitrouter_providers::apply_builtin_defaults(&mut resolved);
bitrouter_sdk::config::discover_models(&mut resolved).await;
let table = ConfigRoutingTable::from_config(resolved);
Ok(table
.list_models()
.into_iter()
.filter(|m| match provider_filter {
Some(p) => m.providers.iter().any(|x| x == p),
None => true,
})
.map(|m| (m.id, m.providers))
.collect())
}
pub async fn resolve_route(config: &Config, model: &str) -> Result<Vec<RouteHop>> {
let mut resolved = config.clone();
bitrouter_providers::apply_builtin_defaults(&mut resolved);
let table = ConfigRoutingTable::from_config(resolved);
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\
# bitrouter 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 {
PkceSubscription,
ImportFromCli,
DeviceCode,
ApiKey,
}
impl AuthMethod {
fn label(self) -> &'static str {
match self {
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::anthropic::PROVIDER_ID => Some("Claude Code"),
bitrouter_providers::codex::PROVIDER_ID => Some("Codex"),
_ => None,
}
}
fn available_methods(entry: &bitrouter_providers::ProviderEntry) -> Vec<AuthMethod> {
use bitrouter_providers::AuthScheme;
let mut methods = Vec::new();
let has_pkce = bitrouter_providers::oauth::registry::has_pkce_flow(&entry.id);
if has_pkce {
methods.push(AuthMethod::PkceSubscription);
}
if import_cli_for(&entry.id).is_some() {
methods.push(AuthMethod::ImportFromCli);
}
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
}
fn prompt_method_choice(provider: &str, options: &[AuthMethod]) -> Result<AuthMethod> {
use std::io::BufRead;
eprintln!();
eprintln!("How would you like to authenticate to {provider}?");
for (i, m) in options.iter().enumerate() {
eprintln!(" {}) {}", i + 1, m.label());
}
let stdin = std::io::stdin();
let mut handle = stdin.lock();
loop {
eprint!("Choose [1]: ");
let mut line = String::new();
let n_bytes = handle
.read_line(&mut line)
.context("reading method choice from stdin")?;
if n_bytes == 0 {
anyhow::bail!("stdin closed before a choice was made");
}
let trimmed = line.trim();
if trimmed.is_empty() {
return Ok(options[0]);
}
match trimmed.parse::<usize>() {
Ok(n) if (1..=options.len()).contains(&n) => return Ok(options[n - 1]),
Ok(n) => eprintln!(" choice must be between 1 and {}, got {n}", options.len()),
Err(_) => eprintln!(" '{trimmed}' is not a number"),
}
}
}
pub async fn login_provider(provider_id: &str, label: &str) -> Result<()> {
use bitrouter_providers::builtin;
let entry = builtin::find(provider_id).ok_or_else(|| {
anyhow::anyhow!(
"unknown provider '{provider_id}' — try `bitrouter providers list` for the catalog"
)
})?;
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 = if methods.len() == 1 {
methods[0]
} else {
prompt_method_choice(&entry.display_name, &methods)?
};
eprintln!();
eprintln!(
" Logging in to {} via {}",
entry.display_name,
chosen.label()
);
let credential = match chosen {
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 => 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(())
}
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::anthropic::PROVIDER_ID => {
bitrouter_providers::import::claude_code::import()
}
bitrouter_providers::codex::PROVIDER_ID => bitrouter_providers::import::codex::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> {
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(bitrouter_providers::oauth::credential_store::Credential::api_key(key))
}
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<()> {
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(())
}
const DEFAULT_SKILLS_REGISTRY: &str = "https://api.bitrouter.ai";
fn skills_http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(concat!("bitrouter-skills/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(30))
.build()
.context("building skills HTTP client")
}
fn skills_target(global: bool) -> Result<bitrouter_skills::install::InstallTarget> {
use bitrouter_skills::install::InstallTarget;
if global {
Ok(InstallTarget::Global)
} else {
let cwd = std::env::current_dir().context("resolving the current directory")?;
Ok(InstallTarget::Project { project_root: cwd })
}
}
fn is_bare_skill_name(source: &str) -> bool {
!source.contains('/') && !source.contains("://") && !source.starts_with("git@")
}
async fn resolve_registry_source(
name: &str,
registry: Option<&str>,
namespace: &str,
) -> Result<bitrouter_skills::source::SkillSource> {
use bitrouter_skills::marketplace;
use bitrouter_skills::source::SkillSource;
let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);
let client = skills_http_client()?;
let market = marketplace::fetch_marketplace(&client, base, namespace).await?;
let entry = market
.find(name)
.ok_or_else(|| anyhow::anyhow!("skill {name:?} not found in registry {base}"))?;
SkillSource::try_from(&entry.source)
.with_context(|| format!("resolving registry source for {name:?}"))
}
pub async fn skills_add(
source: &str,
select: Option<&str>,
global: bool,
overwrite: bool,
registry: Option<&str>,
namespace: Option<&str>,
) -> Result<()> {
use bitrouter_skills::{install, source as skill_source};
if source.trim().is_empty() {
anyhow::bail!(
"a skill source is required (owner/repo, a git URL, a local path, or a registry name)"
);
}
let target = skills_target(global)?;
let parsed = if is_bare_skill_name(source) {
let nsid = namespace
.ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
resolve_registry_source(source, registry, nsid).await?
} else {
skill_source::parse_source(source)?
};
let outcome = install::install(&parsed, &target, overwrite, select, None).await?;
let verb = if outcome.was_updated {
"updated"
} else {
"installed"
};
println!("{verb} {} → {}", outcome.name, outcome.dest.display());
Ok(())
}
pub fn skills_list(global: bool) -> Result<()> {
use bitrouter_skills::install;
let target = skills_target(global)?;
let installed = install::list_installed(&target)?;
if installed.is_empty() {
println!("no skills installed");
return Ok(());
}
for (name, path) in installed {
println!("{name}\t{}", path.display());
}
Ok(())
}
pub fn skills_remove(name: &str, global: bool) -> Result<()> {
use bitrouter_skills::install;
let target = skills_target(global)?;
let dir = install::remove(name, &target)?;
println!("removed {name} ({})", dir.display());
Ok(())
}
pub async fn skills_find(
query: &str,
registry: Option<&str>,
namespace: Option<&str>,
) -> Result<()> {
use bitrouter_skills::marketplace;
let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);
let nsid = namespace
.ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
let client = skills_http_client()?;
let market = marketplace::fetch_marketplace(&client, base, nsid).await?;
let hits = market.search(query);
if hits.is_empty() {
println!("no skills matching {query:?} in {base}");
return Ok(());
}
for entry in hits {
let version = entry.version.as_deref().unwrap_or("-");
let description = entry.description.as_deref().unwrap_or("");
println!("{}\t{version}\t{description}", entry.name);
}
Ok(())
}
pub fn skills_init(name: &str, output: &std::path::Path) -> Result<()> {
bitrouter_skills::install::validate_skill_name(name)?;
if output.exists() {
anyhow::bail!("{} already exists; refusing to overwrite", output.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()))?;
println!("wrote {}", output.display());
Ok(())
}
pub async fn skills_update(
name: Option<&str>,
global: bool,
registry: Option<&str>,
namespace: Option<&str>,
) -> Result<()> {
use bitrouter_skills::install;
use bitrouter_skills::marketplace;
use bitrouter_skills::source::SkillSource;
let target = skills_target(global)?;
let base = registry.unwrap_or(DEFAULT_SKILLS_REGISTRY);
let installed: Vec<String> = install::list_installed(&target)?
.into_iter()
.map(|(n, _)| n)
.collect();
let names: Vec<String> = match name {
Some(n) => {
if !installed.iter().any(|i| i == n) {
anyhow::bail!("skill {n:?} is not installed; use `bitrouter skills add` first");
}
vec![n.to_string()]
}
None => installed,
};
if names.is_empty() {
println!("no skills installed to update");
return Ok(());
}
let nsid = namespace
.ok_or_else(|| anyhow::anyhow!("--namespace <nsid> is required to query a registry"))?;
let client = skills_http_client()?;
let market = marketplace::fetch_marketplace(&client, base, nsid).await?;
let mut failures = 0u32;
for skill_name in names {
match market.find(&skill_name) {
Some(entry) => {
let result = match SkillSource::try_from(&entry.source) {
Ok(parsed) => {
install::install(&parsed, &target, true, None, Some(&skill_name)).await
}
Err(e) => Err(e),
};
match result {
Ok(outcome) => {
println!("updated {} → {}", outcome.name, outcome.dest.display())
}
Err(e) => {
failures += 1;
eprintln!("failed to update {skill_name}: {e}");
}
}
}
None => println!("skipped {skill_name} (not in registry {base})"),
}
}
if failures > 0 {
anyhow::bail!("{failures} skill(s) failed to update");
}
Ok(())
}
#[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");
init(&path).await.unwrap();
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);
assert!(init(&path).await.is_err());
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_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 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_filters_by_provider() {
let cfg = sample_config();
let all = list_models(&cfg, None).await.unwrap();
assert_eq!(all.len(), 2); let openai_only = list_models(&cfg, Some("openai")).await.unwrap();
assert_eq!(openai_only.len(), 2); let anthropic_only = list_models(&cfg, Some("anthropic")).await.unwrap();
assert_eq!(anthropic_only.len(), 1); assert_eq!(anthropic_only[0].0, "shared");
}
#[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;
}
}