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;
use crate::output::reports::skills::{
FailedSkill, SkillAddReport, SkillEntry, SkillHit, SkillInitReport, SkillRemoveReport,
SkillsFindReport, SkillsListReport, SkillsUpdateReport, SkippedSkill, UpdatedSkill,
};
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"
# Routing policies are opt-in and live in a sibling policy-lock.yaml.
# `bitrouter 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 `bitrouter 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 — `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 — manage with
# `bitrouter agents list` / `check` / `install`. 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 {
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"),
_ => 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
}
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 struct LoginOutcome {
pub provider: String,
pub label: String,
pub method: String,
pub path: String,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ProviderLoginOptions {
pub import_existing: bool,
pub no_browser: bool,
}
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) {
Some(e) => e.clone(),
None => {
let data = bitrouter_providers::registry::apply::load_or_cached(
&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 `bitrouter providers list` \
for the catalog"
)
})?;
builtin::entry_from_registry(provider)
.with_context(|| format!("resolving the auth shape for '{provider_id}'"))?
}
};
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)?;
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 => 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(),
})
}
fn choose_auth_method(
entry: &bitrouter_providers::ProviderEntry,
methods: &[AuthMethod],
options: ProviderLoginOptions,
) -> Result<AuthMethod> {
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)
}
}
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 \
`bitrouter 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 — launching `claude auth login`.");
let status = tokio::process::Command::new(&claude)
.arg("auth")
.arg("login")
.arg("--claudeai")
.status()
.await
.context("running `claude auth login`")?;
if !status.success() {
anyhow::bail!(
"`claude auth login` didn't complete — sign in, then re-run \
`bitrouter 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(),
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<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)
}
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<SkillAddReport> {
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?;
Ok(SkillAddReport {
action: if outcome.was_updated {
"updated"
} else {
"installed"
},
name: outcome.name,
dest: outcome.dest.display().to_string(),
})
}
pub fn skills_list(global: bool) -> Result<SkillsListReport> {
use bitrouter_skills::install;
let target = skills_target(global)?;
let skills = install::list_installed(&target)?
.into_iter()
.map(|(name, path)| SkillEntry {
name,
path: path.display().to_string(),
})
.collect();
Ok(SkillsListReport { skills })
}
pub fn skills_remove(name: &str, global: bool) -> Result<SkillRemoveReport> {
use bitrouter_skills::install;
let target = skills_target(global)?;
let dir = install::remove(name, &target)?;
Ok(SkillRemoveReport {
name: name.to_string(),
path: dir.display().to_string(),
removed: true,
})
}
pub async fn skills_find(
query: &str,
registry: Option<&str>,
namespace: Option<&str>,
) -> Result<SkillsFindReport> {
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 results = market
.search(query)
.into_iter()
.map(|entry| SkillHit {
name: entry.name.clone(),
version: entry.version.clone().unwrap_or_else(|| "-".to_string()),
description: entry.description.clone().unwrap_or_default(),
})
.collect();
Ok(SkillsFindReport {
query: query.to_string(),
registry: base.to_string(),
results,
})
}
pub fn skills_init(name: &str, output: &std::path::Path) -> Result<SkillInitReport> {
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()))?;
Ok(SkillInitReport {
path: output.display().to_string(),
created: true,
})
}
pub async fn skills_update(
name: Option<&str>,
global: bool,
registry: Option<&str>,
namespace: Option<&str>,
) -> Result<SkillsUpdateReport> {
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() {
return Ok(SkillsUpdateReport {
updated: Vec::new(),
skipped: Vec::new(),
failed: Vec::new(),
});
}
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 report = SkillsUpdateReport {
updated: Vec::new(),
skipped: Vec::new(),
failed: Vec::new(),
};
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) => report.updated.push(UpdatedSkill {
name: outcome.name,
dest: outcome.dest.display().to_string(),
}),
Err(e) => report.failed.push(FailedSkill {
name: skill_name,
error: e.to_string(),
}),
}
}
None => report.skipped.push(SkippedSkill {
name: skill_name,
reason: format!("not in registry {base}"),
}),
}
}
Ok(report)
}
#[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_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));
}
#[test]
fn import_existing_selects_vendor_cli_import_without_prompting() {
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,
},
)
.unwrap();
assert_eq!(chosen, AuthMethod::ImportFromCli);
}
#[test]
fn import_existing_rejects_providers_without_vendor_cli_import() {
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,
},
)
.unwrap_err();
assert!(format!("{err:#}").contains("cannot import"));
}
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;
}
}