use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand, ValueEnum};
use bitrouter::commands;
use bitrouter::daemon::{self, DaemonCommand, DaemonResponse, RouteHop};
use bitrouter::output::reports::admin::{
KeySignReport, PolicyCreateReport, ProviderLoginReport, ProviderLogoutReport,
};
use bitrouter::output::reports::agents::{
AgentCheckRow, AgentInstallReport, AgentRow, AgentsCheckReport, AgentsListReport,
};
use bitrouter::output::reports::config::{InitReport, UnsetVar, ValidateReport};
use bitrouter::output::reports::daemon::{
DaemonActionReport, RouteHopView, RouteReport, StatusReport,
};
use bitrouter::output::reports::observe::ObserveStatusReport;
use bitrouter::output::reports::routing::{ModelRow, ModelsReport, ProviderRow, ProvidersReport};
use bitrouter::output::reports::tools::{
ServerStatusView, ServerToolsView, ToolInfo, ToolsDiscoverReport, ToolsListReport,
ToolsStatusReport,
};
use bitrouter::output::reports::verify::{Check, VerifyReport};
use bitrouter::output::{CliReport, Output};
use bitrouter_sdk::config;
#[derive(Parser)]
#[command(name = "bitrouter", version, about)]
struct Cli {
#[arg(short = 'j', long, global = true, conflicts_with = "human")]
json: bool,
#[arg(short = 'H', long, global = true)]
human: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Serve {
#[arg(short, long)]
config: Option<PathBuf>,
},
Start {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
log: Option<PathBuf>,
},
Stop {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
Restart {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
#[arg(long)]
log: Option<PathBuf>,
},
Reload {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
Status {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
Route {
model: String,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
Init {
#[arg(short, long, default_value = "bitrouter.yaml")]
config: PathBuf,
},
Config {
#[command(subcommand)]
action: ConfigAction,
},
Key {
#[command(subcommand)]
action: KeyAction,
},
Models {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(short, long)]
provider: Option<String>,
},
Verify {
model: String,
},
Tools {
#[command(subcommand)]
action: ToolsAction,
},
Observe {
#[command(subcommand)]
action: ObserveAction,
},
Policy {
#[command(subcommand)]
action: PolicyAction,
},
Providers {
#[command(subcommand)]
action: ProviderAction,
},
Agents {
#[command(subcommand)]
action: AgentsAction,
},
#[command(name = "agent-proxy")]
AgentProxy {
agent: String,
#[arg(short, long)]
config: Option<PathBuf>,
},
Spawn {
#[arg(short, long, value_enum)]
agent: bitrouter::spawn::SpawnAgent,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
no_install: bool,
#[arg(long)]
no_start: bool,
#[arg(long)]
check: bool,
#[arg(last = true, allow_hyphen_values = true)]
agent_args: Vec<String>,
},
Cloud {
#[command(subcommand)]
action: bitrouter::cloud::cli::CloudAction,
},
Skills {
#[command(subcommand)]
action: bitrouter::skills::cli::SkillsAction,
},
Mcp {
#[command(subcommand)]
action: McpAction,
},
Update {
#[arg(long)]
check: bool,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
stable: bool,
#[arg(long)]
restart: bool,
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Validate {
#[arg(short, long)]
config: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum McpAction {
Serve {
#[arg(long, value_enum, default_value_t = McpTransport::Stdio)]
transport: McpTransport,
#[arg(long, value_enum)]
backend: Option<McpBackend>,
#[arg(long, default_value = "http://127.0.0.1:4356")]
local_url: String,
#[arg(long, default_value = "https://api.bitrouter.ai")]
cloud_url: String,
#[arg(long)]
token: Option<String>,
#[arg(long, default_value = "127.0.0.1:4357")]
bind: String,
},
Install {
#[arg(long, value_enum, default_value_t = McpClient::Claude)]
client: McpClient,
#[arg(long)]
config: Option<PathBuf>,
},
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum McpTransport {
Stdio,
Http,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum McpBackend {
Local,
Cloud,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum McpClient {
Claude,
Cursor,
}
impl From<McpTransport> for bitrouter_mcp::Transport {
fn from(t: McpTransport) -> Self {
match t {
McpTransport::Stdio => bitrouter_mcp::Transport::Stdio,
McpTransport::Http => bitrouter_mcp::Transport::Http,
}
}
}
impl From<McpBackend> for bitrouter_mcp::BackendKind {
fn from(b: McpBackend) -> Self {
match b {
McpBackend::Local => bitrouter_mcp::BackendKind::Local,
McpBackend::Cloud => bitrouter_mcp::BackendKind::Cloud,
}
}
}
impl From<McpClient> for bitrouter_mcp::install::Client {
fn from(c: McpClient) -> Self {
match c {
McpClient::Claude => bitrouter_mcp::install::Client::Claude,
McpClient::Cursor => bitrouter_mcp::install::Client::Cursor,
}
}
}
#[derive(Subcommand)]
enum AgentsAction {
List {
#[arg(short, long)]
config: Option<PathBuf>,
},
Check {
#[arg(short, long)]
config: Option<PathBuf>,
},
Install {
id: String,
},
}
#[derive(Subcommand)]
enum ObserveAction {
Status {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum ToolsAction {
List {
#[arg(short, long)]
config: Option<PathBuf>,
},
Status {
#[arg(short, long)]
config: Option<PathBuf>,
},
Discover {
server: String,
#[arg(short, long)]
config: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum KeyAction {
Sign {
#[arg(short, long)]
user: String,
#[arg(short, long, default_value = "sqlite://./bitrouter.db")]
db: String,
#[arg(long)]
policy: Option<String>,
},
}
#[derive(Subcommand)]
enum PolicyAction {
Create {
id: String,
#[arg(long, default_value = "./policies")]
dir: PathBuf,
},
}
#[derive(Subcommand)]
enum ProviderAction {
List {
#[arg(short, long)]
config: Option<PathBuf>,
},
Login {
provider: String,
#[arg(short, long, default_value = "default")]
label: String,
#[arg(long)]
import_existing: bool,
#[arg(long)]
no_browser: bool,
},
Logout {
provider: String,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let output = bitrouter::output::Output::from_flags(cli.json, cli.human);
match run(cli, &output).await {
Ok(()) => {}
Err(e) => {
let _ = output.emit(&bitrouter::output::error::envelope_from_anyhow(&e));
std::process::exit(1);
}
}
}
async fn run(cli: Cli, output: &bitrouter::output::Output) -> Result<()> {
if !matches!(cli.command, Command::Serve { .. }) {
init_basic_tracing_subscriber();
}
match cli.command {
Command::Serve { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
serve(&source).await
}
Command::Start { config, log } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let log_path = resolve_log_path(source.home(), log.as_deref());
output.emit(&start(&source, &log_path, "start").await?)?;
Ok(())
}
Command::Stop { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
output.emit(&stop(&socket).await?)?;
Ok(())
}
Command::Restart {
config,
socket,
log,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let socket = resolve_client_socket_from(&source, socket.as_deref()).await?;
let log_path = resolve_log_path(source.home(), log.as_deref());
output.emit(&restart(&source, &socket, &log_path).await?)?;
Ok(())
}
Command::Reload { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
output.emit(&reload(&socket).await?)?;
Ok(())
}
Command::Status { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
output.emit(&status(&socket).await?)?;
Ok(())
}
Command::Route {
model,
config,
socket,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let socket = resolve_client_socket_from(&source, socket.as_deref()).await?;
output.emit(&route(&model, &source, &socket).await?)?;
Ok(())
}
Command::Init { config } => {
output.emit(&init(&config).await?)?;
Ok(())
}
Command::Config { action } => {
let report = config_cmd(action).await?;
output.emit(&report)?;
if report.valid {
Ok(())
} else {
std::process::exit(1)
}
}
Command::Key { action } => {
output.emit(&key(action).await?)?;
Ok(())
}
Command::Models { config, provider } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
output.emit(&models(&source, provider.as_deref()).await?)?;
Ok(())
}
Command::Verify { model } => {
output.emit(&verify_attestation(&model).await?)?;
Ok(())
}
Command::Tools { action } => tools(action, output).await,
Command::Observe { action } => observe(action, output).await,
Command::Policy { action } => {
output.emit(&policy(action).await?)?;
Ok(())
}
Command::Providers { action } => providers(action, output).await,
Command::Agents { action } => agents_cmd(action, output).await,
Command::AgentProxy { agent, config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
agent_proxy_cmd(&agent, &source).await
}
Command::Spawn {
agent,
config,
base_url,
no_install,
no_start,
check,
agent_args,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let opts = bitrouter::spawn::SpawnOptions {
agent,
agent_args,
base_url,
no_install,
no_start,
check,
};
if opts.check {
let report = bitrouter::spawn::check(&cfg, &opts).await?;
output.emit(&report)?;
if report.exit_code() == 0 {
Ok(())
} else {
std::process::exit(report.exit_code());
}
} else {
bitrouter::spawn::run(&source, &cfg, opts).await
}
}
Command::Cloud { action } => bitrouter::cloud::cli::run(action, output.format()).await,
Command::Skills { action } => bitrouter::skills::cli::run(action, output).await,
Command::Mcp { action } => mcp_cmd(action).await,
Command::Update {
check,
tag,
stable,
restart: restart_after,
yes,
} => {
let source = bitrouter::paths::resolve_config(None)?;
let socket = resolve_client_socket_from(&source, None).await?;
let opts = bitrouter::update::UpdateOptions {
check,
tag,
stable,
restart: restart_after,
yes,
};
let outcome = bitrouter::update::run(opts, &socket).await?;
if outcome.restart_needed {
let log_path = resolve_log_path(source.home(), None);
restart(&source, &socket, &log_path).await?;
}
output.emit(&outcome.report)?;
Ok(())
}
}
}
async fn config_cmd(action: ConfigAction) -> Result<ValidateReport> {
match action {
ConfigAction::Validate { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
validate_config(&source).await
}
}
}
async fn validate_config(source: &bitrouter::paths::ConfigSource) -> Result<ValidateReport> {
use bitrouter::paths::ConfigSource;
let path = match source {
ConfigSource::File(p) => p,
ConfigSource::Default { .. } => anyhow::bail!(
"no config file found to validate — looked in ./bitrouter.yaml, \
$BITROUTER_HOME, and ~/.bitrouter. Pass --config <path>."
),
};
let raw = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("reading {}", path.display()))?;
let missing: std::cell::RefCell<std::collections::BTreeSet<String>> =
std::cell::RefCell::new(std::collections::BTreeSet::new());
let parsed = config::parse_with(&raw, |name| {
Some(config::env_lookup(name).unwrap_or_else(|| {
missing.borrow_mut().insert(name.to_string());
"https://env-placeholder.invalid".to_string()
}))
});
let missing = missing.into_inner();
match parsed {
Ok(cfg) => Ok(ValidateReport::valid(
path.display().to_string(),
cfg.providers.len(),
cfg.models.len(),
cfg.presets.len(),
cfg.variants.len(),
missing
.into_iter()
.map(|name| UnsetVar { unset_env: name })
.collect(),
)),
Err(e) => Ok(ValidateReport::invalid(
path.display().to_string(),
e.to_string(),
)),
}
}
async fn mcp_cmd(action: McpAction) -> Result<()> {
match action {
McpAction::Serve {
transport,
backend,
local_url,
cloud_url,
token,
bind,
} => {
let transport = bitrouter_mcp::Transport::from(transport);
let backend = backend.map(Into::into).unwrap_or(match transport {
bitrouter_mcp::Transport::Stdio => bitrouter_mcp::BackendKind::Local,
bitrouter_mcp::Transport::Http => bitrouter_mcp::BackendKind::Cloud,
});
let cloud_token = token.or_else(|| std::env::var("BITROUTER_TOKEN").ok());
if matches!(transport, bitrouter_mcp::Transport::Http) && cloud_token.is_some() {
eprintln!(
"note: --token/BITROUTER_TOKEN is ignored for --transport http (multi-tenant; each client sends its own Authorization)"
);
}
bitrouter_mcp::serve(bitrouter_mcp::ServeOptions {
transport,
backend,
local_url,
cloud_url,
cloud_token,
bind,
})
.await
}
McpAction::Install { client, config } => {
bitrouter_mcp::install(bitrouter_mcp::InstallOptions {
client: client.into(),
config_path: config,
})
}
}
}
async fn resolve_client_socket(config: Option<&Path>, socket: Option<&Path>) -> Result<PathBuf> {
if let Some(s) = socket {
return Ok(s.to_path_buf());
}
let source = bitrouter::paths::resolve_config(config)?;
match &source {
bitrouter::paths::ConfigSource::File(path) => {
let socket_str = match config::load(path).await {
Ok(cfg) => cfg.server.control_socket,
Err(_) => daemon::DEFAULT_CONTROL_SOCKET.to_string(),
};
Ok(daemon::resolve_socket_path(path, &socket_str))
}
bitrouter::paths::ConfigSource::Default { home } => Ok(home.join("bitrouter.sock")),
}
}
fn init_basic_tracing_subscriber() {
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
}
fn init_serve_tracing_subscriber(exporter: Option<&bitrouter_observe::otel::OtelExporter>) {
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"));
let registry = tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer());
match exporter {
Some(exp) => registry
.with(bitrouter_observe::otel::http_layer::tracing_subscriber_layer(exp))
.init(),
None => registry.init(),
}
}
fn resolve_log_path(home: &Path, log: Option<&Path>) -> PathBuf {
if let Some(l) = log {
return l.to_path_buf();
}
home.join("bitrouter.log")
}
async fn resolve_client_socket_from(
source: &bitrouter::paths::ConfigSource,
socket: Option<&Path>,
) -> Result<PathBuf> {
if let Some(s) = socket {
return Ok(s.to_path_buf());
}
match source {
bitrouter::paths::ConfigSource::File(path) => {
let cfg = config::load(path)
.await
.with_context(|| format!("loading {}", path.display()))?;
Ok(daemon::resolve_socket_path(
path,
&cfg.server.control_socket,
))
}
bitrouter::paths::ConfigSource::Default { home } => Ok(home.join("bitrouter.sock")),
}
}
async fn serve(source: &bitrouter::paths::ConfigSource) -> Result<()> {
let home = source.home();
bitrouter::paths::ensure_home_directory(home)?;
std::env::set_current_dir(home)
.with_context(|| format!("chdir to bitrouter home {}", home.display()))?;
let mut cfg = bitrouter::paths::load_config(source).await?;
bitrouter::claude_code::enable_if_logged_in(&mut cfg);
bitrouter::merge_registry_into(&mut cfg).await;
announce_zero_config(source, &cfg);
maybe_announce_telemetry(home);
let listen = cfg.server.listen.clone();
let socket_path = daemon::socket_path_for(source, &cfg);
let pid_path = pid_path_for(&socket_path);
let config_path_for_reload = match source {
bitrouter::paths::ConfigSource::File(path) => Some(path.as_path()),
bitrouter::paths::ConfigSource::Default { .. } => None,
};
let assembled = bitrouter::build_app_with_path(&cfg, config_path_for_reload).await?;
init_serve_tracing_subscriber(assembled.otel_exporter.as_deref());
if let Some(msg) = &assembled.otel_init_error {
tracing::error!("{msg}");
}
let app = Arc::new(assembled.app);
let policy_store = assembled.policy_store;
let observe_provider = assembled.observe;
let observe_for_shutdown = observe_provider.clone();
let reload_source = match source {
bitrouter::paths::ConfigSource::File(path) => {
bitrouter::reload::ReloadSource::File(path.clone())
}
bitrouter::paths::ConfigSource::Default { .. } => bitrouter::reload::ReloadSource::Default,
};
let reloader: Arc<dyn daemon::DaemonReloader> = Arc::new(bitrouter::reload::AppReloader::new(
policy_store.clone(),
assembled.routing_table,
reload_source,
));
daemon::write_pid_file(&pid_path).await?;
println!(
"bitrouter {} — serving on {listen} (control: {})",
bitrouter::VERSION,
socket_path.display()
);
let http_app = app.clone();
let http_listen = listen.clone();
let http = async move {
http_app
.serve_with_router_wrapper(
&http_listen,
bitrouter_observe::otel::http_layer::router_wrapper(),
)
.await
.map_err(anyhow::Error::from)
};
let control = daemon::run_control_socket(
socket_path,
app.clone(),
listen,
reloader.clone(),
observe_provider,
);
let hup_reloader = reloader.clone();
let hup = async move {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut hup = match signal(SignalKind::hangup()) {
Ok(s) => s,
Err(e) => return Err::<(), anyhow::Error>(anyhow::Error::from(e)),
};
loop {
if hup.recv().await.is_none() {
return Ok(());
}
match hup_reloader.reload().await {
Ok(()) => tracing::info!("SIGHUP — reload succeeded"),
Err(e) => tracing::warn!(error = %e, "SIGHUP reload failed"),
}
}
}
#[cfg(not(unix))]
{
let _keep = &hup_reloader;
std::future::pending::<()>().await;
Ok::<(), anyhow::Error>(())
}
};
let term = async {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
let mut sigint = signal(SignalKind::interrupt()).map_err(anyhow::Error::from)?;
let mut sigterm = signal(SignalKind::terminate()).map_err(anyhow::Error::from)?;
tokio::select! {
_ = sigint.recv() => tracing::info!("SIGINT — shutting down"),
_ = sigterm.recv() => tracing::info!("SIGTERM — shutting down"),
}
Ok::<(), anyhow::Error>(())
}
#[cfg(windows)]
{
use tokio::signal::windows;
let mut ctrl_c = windows::ctrl_c().map_err(anyhow::Error::from)?;
let mut ctrl_break = windows::ctrl_break().map_err(anyhow::Error::from)?;
let mut ctrl_close = windows::ctrl_close().map_err(anyhow::Error::from)?;
let mut ctrl_shutdown = windows::ctrl_shutdown().map_err(anyhow::Error::from)?;
tokio::select! {
_ = ctrl_c.recv() => tracing::info!("Ctrl-C — shutting down"),
_ = ctrl_break.recv() => tracing::info!("Ctrl-Break — shutting down"),
_ = ctrl_close.recv() => tracing::info!("console close — shutting down"),
_ = ctrl_shutdown.recv() => tracing::info!("system shutdown — shutting down"),
}
Ok::<(), anyhow::Error>(())
}
};
let result = tokio::select! {
r = http => r,
r = control => r,
r = hup => match r {
Ok(()) => Ok(()),
Err(e) => { tracing::warn!(error = %e, "SIGHUP listener unavailable"); Ok(()) }
},
r = term => match r {
Ok(()) => Ok(()),
Err(e) => { tracing::warn!(error = %e, "termination-signal listener unavailable"); Ok(()) }
},
};
observe_for_shutdown.shutdown().await;
daemon::remove_pid_file(&pid_path).await;
result
}
async fn start(
source: &bitrouter::paths::ConfigSource,
log_path: &Path,
action: &'static str,
) -> Result<DaemonActionReport> {
bitrouter::paths::ensure_home_directory(source.home())?;
let cfg_socket_path: Option<PathBuf> = match source {
bitrouter::paths::ConfigSource::File(path) => match config::load(path).await {
Ok(cfg) => Some(daemon::socket_path_for(source, &cfg)),
Err(_) => None,
},
bitrouter::paths::ConfigSource::Default { home } => Some(home.join("bitrouter.sock")),
};
if let Some(socket) = &cfg_socket_path {
let pid_path = pid_path_for(socket);
if let Some(pid) = daemon::read_pid_file(&pid_path).await {
if process_is_alive(pid) {
anyhow::bail!(
"bitrouter is already running (pid {pid}); use `restart` or `stop` first"
);
}
daemon::remove_pid_file(&pid_path).await;
}
}
let outcome = daemon::start_and_wait(
source,
log_path,
cfg_socket_path.as_deref(),
daemon::DAEMON_READY_TIMEOUT,
)
.await?;
match outcome {
daemon::DaemonStartOutcome::Ready(info) => Ok(DaemonActionReport::started(
action,
"started",
info.pid,
info.listen,
info.models,
log_path.display().to_string(),
)),
daemon::DaemonStartOutcome::NotReadyInTime { pid } => {
let p = bitrouter::style::Palette::for_stderr();
eprintln!(
"{cyan}note:{reset} bitrouter daemon started (pid {pid}) but has not become \
ready after {secs}s — check logs at {log}",
cyan = p.cyan,
reset = p.reset,
secs = daemon::DAEMON_READY_TIMEOUT.as_secs(),
log = log_path.display(),
);
Ok(DaemonActionReport::not_ready(
action,
pid,
log_path.display().to_string(),
))
}
daemon::DaemonStartOutcome::Exited { status, log_tail } => {
daemon::eprint_failure_log(log_path, &log_tail);
anyhow::bail!("daemon exited during startup ({status})")
}
}
}
fn announce_zero_config(
source: &bitrouter::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
) {
if !source.is_default() {
return;
}
let enabled: Vec<&str> = cfg.providers.keys().map(String::as_str).collect();
if enabled.is_empty() {
print_onboarding_hint();
} else {
bitrouter::error_report::info(format_args!(
"zero-config mode — auto-enabled providers: {}",
enabled.join(", ")
));
}
}
fn maybe_announce_telemetry(home: &std::path::Path) {
match bitrouter::paths::mark_telemetry_notice_shown(home) {
Ok(true) => {}
Ok(false) => return,
Err(e) => {
tracing::debug!("telemetry notice sentinel: {e:#}");
return;
}
}
let p = bitrouter::style::Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} optional usage telemetry is available — and OFF by default.",
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
eprintln!();
eprintln!(" Nothing is sent unless you opt in. Two levels are offered:");
eprintln!(
" • metadata — model, tokens, latency, finish reason, routing (no message content)"
);
eprintln!(" • full — the above plus request + response message content");
eprintln!();
eprintln!(" Enable it under plugins.bitrouter-observe.telemetry in your config:");
eprintln!();
eprintln!(" plugins:");
eprintln!(" bitrouter-observe:");
eprintln!(" telemetry:");
eprintln!(" enabled: true");
eprintln!(" level: metadata # or: full");
eprintln!();
eprintln!(" Remove the block (or set enabled: false) to turn it off again.");
eprintln!();
}
fn print_onboarding_hint() {
let p = bitrouter::style::Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} no providers are configured yet. Choose one:",
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
eprintln!();
eprintln!(" 1. Sign in to BitRouter Cloud — one account covers every model:");
eprintln!();
eprintln!(" bitrouter cloud login");
eprintln!(" bitrouter cloud --help # manage keys, usage, policies, billing");
eprintln!();
eprintln!(" 2. Or paste a BitRouter API key:");
eprintln!();
eprintln!(" export BITROUTER_API_KEY=brk_…");
eprintln!();
eprintln!(" 3. Or use a provider you already pay for, locally:");
eprintln!();
eprintln!(" bitrouter providers login claude-code # Claude Pro/Max subscription");
eprintln!(" bitrouter providers login github-copilot # GitHub Copilot subscription");
eprintln!(" bitrouter providers login openai-codex # ChatGPT subscription");
eprintln!();
eprintln!(" …or set an API-key env var:");
eprintln!();
let env_vars = other_provider_env_var_hints();
for var in &env_vars {
eprintln!(" export {var}=…");
}
eprintln!();
}
fn other_provider_env_var_hints() -> Vec<String> {
let mut vars: Vec<String> = bitrouter_providers::zero_config_env_var_providers()
.into_iter()
.map(|(_, env)| env)
.filter(|v| v != "BITROUTER_API_KEY")
.collect();
vars.sort();
vars.dedup();
vars
}
async fn stop(socket: &Path) -> Result<DaemonActionReport> {
match daemon::send_command(socket, &DaemonCommand::Stop).await? {
DaemonResponse::Ok => Ok(DaemonActionReport::simple("stop", "stopped")),
DaemonResponse::Error { message } => Err(anyhow::anyhow!(message)),
other => Err(anyhow::anyhow!("unexpected response: {other:?}")),
}
}
async fn restart(
source: &bitrouter::paths::ConfigSource,
socket: &Path,
log_path: &Path,
) -> Result<DaemonActionReport> {
if daemon::endpoint_in_use(socket) {
match daemon::send_command(socket, &DaemonCommand::Stop).await {
Ok(DaemonResponse::Ok) => {}
Ok(DaemonResponse::Error { message }) => return Err(anyhow::anyhow!(message)),
Ok(other) => return Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) => tracing::warn!(error = %e, "stop failed — proceeding to start"),
}
let pid_path = pid_path_for(socket);
if !wait_for_socket_release(socket, std::time::Duration::from_secs(30)).await {
tracing::warn!("endpoint still held after 30s — escalating to force-kill on pid file");
if let Some(pid) = daemon::read_pid_file(&pid_path).await {
force_kill(pid).await;
}
wait_for_socket_release(socket, std::time::Duration::from_secs(2)).await;
daemon::remove_pid_file(&pid_path).await;
}
}
start(source, log_path, "restart").await
}
async fn wait_for_socket_release(socket: &Path, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
while std::time::Instant::now() < deadline {
if !daemon::endpoint_in_use(socket) {
return true;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
!daemon::endpoint_in_use(socket)
}
async fn reload(socket: &Path) -> Result<DaemonActionReport> {
let env: Vec<(String, String)> = bitrouter_providers::zero_config_env_var_providers()
.into_iter()
.filter_map(|(_, var)| {
std::env::var(&var)
.ok()
.filter(|v| !v.is_empty())
.map(|v| (var, v))
})
.collect();
match daemon::send_command(socket, &DaemonCommand::Reload { env }).await? {
DaemonResponse::Ok => Ok(DaemonActionReport::simple("reload", "reloaded")),
DaemonResponse::Error { message } => Err(anyhow::anyhow!(message)),
other => Err(anyhow::anyhow!("unexpected response: {other:?}")),
}
}
async fn status(socket: &Path) -> Result<StatusReport> {
let report = match daemon::send_command(socket, &DaemonCommand::Status).await {
Ok(DaemonResponse::Status {
pid,
listen,
models,
}) => StatusReport::running(pid, listen, models, socket.display().to_string()),
Ok(DaemonResponse::Error { message }) => return Err(anyhow::anyhow!(message)),
Ok(other) => return Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if daemon::is_not_reachable(&e) => {
StatusReport::stopped(socket.display().to_string())
}
Err(e) => return Err(e),
};
if let Ok(source) = bitrouter::paths::resolve_config(None) {
bitrouter::update::maybe_nudge(source.home(), &bitrouter::style::Palette::for_stderr())
.await;
}
Ok(report)
}
async fn route(
model: &str,
source: &bitrouter::paths::ConfigSource,
socket: &Path,
) -> Result<RouteReport> {
if daemon::endpoint_in_use(socket) {
match daemon::send_command(
socket,
&DaemonCommand::Route {
model: model.into(),
},
)
.await
{
Ok(DaemonResponse::Route { chain }) => {
return Ok(route_report(model, "live daemon", chain));
}
Ok(DaemonResponse::Error { message }) => return Err(anyhow::anyhow!(message)),
Ok(other) => return Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) => {
tracing::debug!(error = %e, "daemon route failed — resolving from config");
}
}
}
let cfg = bitrouter::paths::load_config(source).await?;
let chain = commands::resolve_route(&cfg, model).await?;
let label = if source.is_default() {
"zero-config"
} else {
"config"
};
Ok(route_report(model, label, chain))
}
fn route_report(model: &str, resolved_via: &str, chain: Vec<RouteHop>) -> RouteReport {
RouteReport {
model: model.to_string(),
resolved_via: resolved_via.to_string(),
chain: chain
.into_iter()
.map(|h| RouteHopView {
provider: h.provider,
service_id: h.service_id,
protocol: h.api_protocol,
})
.collect(),
}
}
async fn verify_attestation(model: &str) -> Result<VerifyReport> {
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use bitrouter_attestation::{
AciDcapVerifierPolicy, ConfidentialVerifier, DcapQuoteVerifier, NVIDIA_NRAS_JWKS_URL,
NearVerifier, NvidiaEatKey, ReportTransport, ReqwestTransport,
};
fn env_list(key: &str) -> Vec<String> {
std::env::var(key)
.unwrap_or_default()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
let base =
std::env::var("NEAR_BASE").unwrap_or_else(|_| "https://cloud-api.near.ai/v1".to_string());
let policy = AciDcapVerifierPolicy::new(
env_list("NEAR_WORKLOAD_IDS"),
env_list("NEAR_IMAGE_DIGESTS"),
env_list("NEAR_KMS_ROOTS"),
env_list("NEAR_BASE_MEASUREMENTS"),
)
.map_err(|e| {
anyhow::anyhow!(
"attestation policy not pinned ({e}); set NEAR_KMS_ROOTS, \
NEAR_IMAGE_DIGESTS/NEAR_WORKLOAD_IDS and NEAR_BASE_MEASUREMENTS"
)
})?
.with_allowed_tcb_advisory_ids(env_list("NEAR_TCB_ALLOWED_ADVISORIES"));
let nvidia_key = match std::env::var("NVIDIA_EAT_KEY_PEM") {
Ok(path) => NvidiaEatKey::from_ec_pem(&std::fs::read(&path)?)
.map_err(|e| anyhow::anyhow!("invalid NVIDIA_EAT_KEY_PEM ({path}): {e}"))?,
Err(_) => {
let url = std::env::var("NVIDIA_JWKS_URL")
.unwrap_or_else(|_| NVIDIA_NRAS_JWKS_URL.to_string());
match NvidiaEatKey::fetch_jwks(&url).await {
Ok(key) => key,
Err(e) => {
bitrouter::error_report::info(format!(
"could not fetch NVIDIA JWKS ({e}); the GPU check will fail closed"
));
NvidiaEatKey::unconfigured()
}
}
}
};
let verifier = NearVerifier::new(
Arc::new(ReqwestTransport::new(&base)) as Arc<dyn ReportTransport>,
Arc::new(DcapQuoteVerifier::default()),
Arc::new(policy),
Arc::new(nvidia_key),
);
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let verdict = verifier.attestation_cached(model, now).await?;
let c = &verdict.checks;
let st = |b: bool| if b { "pass" } else { "fail" };
let opt_st = |v: Option<bool>| match v {
Some(true) => "pass",
Some(false) => "fail",
None => "skip",
};
let checks = vec![
Check {
name: "GPU NRAS attestation".into(),
status: st(c.gpu_nras_pass),
},
Check {
name: "Intel TDX DCAP quote".into(),
status: st(c.dcap_quote_valid),
},
Check {
name: "report_data binds key + nonce".into(),
status: st(c.report_data_binds_key_and_nonce),
},
Check {
name: "compose matches measured config".into(),
status: st(c.compose_matches_mr_config),
},
Check {
name: "event-log RTMR3 anchors policy fields".into(),
status: opt_st(c.event_log_rtmr_ok),
},
Check {
name: "base measurements match pin (MRTD/RTMR0-2)".into(),
status: st(c.base_measurements_match),
},
Check {
name: "policy accepts (KMS root + image/workload pin)".into(),
status: st(c.policy_accepts),
},
Check {
name: "TD debug disabled".into(),
status: st(c.debug_disabled),
},
Check {
name: match &c.tcb_status {
Some(s) => format!("TCB level acceptable ({s})"),
None => "TCB level acceptable".into(),
},
status: st(c.tcb_level_acceptable),
},
];
Ok(VerifyReport {
model: model.to_string(),
trust_boundary: verdict.trust_boundary,
verified: verdict.verified,
checks,
signers: verdict.attested_addresses,
})
}
async fn init(config_path: &Path) -> Result<InitReport> {
commands::init(config_path).await?;
Ok(InitReport {
action: "init",
path: config_path.display().to_string(),
skip_auth: true,
})
}
async fn key(action: KeyAction) -> Result<KeySignReport> {
match action {
KeyAction::Sign { user, db, policy } => {
let key = commands::key_sign(&db, &user, policy.as_deref()).await?;
Ok(KeySignReport {
id: key.id,
user,
secret: key.secret,
policy,
hash_stored: true,
})
}
}
}
async fn models(
source: &bitrouter::paths::ConfigSource,
provider: Option<&str>,
) -> Result<ModelsReport> {
let cfg = bitrouter::paths::load_config(source).await?;
let models = commands::list_models(&cfg, provider).await?;
Ok(ModelsReport {
models: models
.into_iter()
.map(|(id, providers)| ModelRow { id, providers })
.collect(),
})
}
async fn policy(action: PolicyAction) -> Result<PolicyCreateReport> {
match action {
PolicyAction::Create { id, dir } => {
let path = commands::create_policy(&dir, &id).await?;
Ok(PolicyCreateReport {
id,
path: path.display().to_string(),
created: true,
})
}
}
}
async fn providers(action: ProviderAction, output: &Output) -> Result<()> {
match action {
ProviderAction::List { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let providers = commands::list_providers(&cfg)
.into_iter()
.map(|p| ProviderRow {
id: p.id,
models: p.model_count,
active: p.active,
api_base: p.api_base,
})
.collect();
output.emit(&ProvidersReport { providers })?;
Ok(())
}
ProviderAction::Login {
provider,
label,
import_existing,
no_browser,
} => {
if provider == "bitrouter" {
if import_existing || no_browser {
anyhow::bail!(
"`bitrouter providers login bitrouter` uses BitRouter Cloud OAuth; \
--import-existing/--no-browser apply to upstream provider logins"
);
}
bitrouter::cloud::cli::run(
bitrouter::cloud::cli::CloudAction::Login {
authorization_server: None,
client_id: None,
scope: None,
},
output.format(),
)
.await
} else {
let outcome = bitrouter::commands::login_provider_with_options(
&provider,
&label,
bitrouter::commands::ProviderLoginOptions {
import_existing,
no_browser,
},
)
.await?;
output.emit(&ProviderLoginReport {
provider: outcome.provider,
label: outcome.label,
method: outcome.method,
credential: "saved",
path: outcome.path,
})?;
Ok(())
}
}
ProviderAction::Logout { provider } => {
if provider == "bitrouter" {
bitrouter::cloud::cli::run(
bitrouter::cloud::cli::CloudAction::Logout {
authorization_server: None,
client_id: None,
},
output.format(),
)
.await
} else {
let removed = bitrouter::commands::logout_provider(&provider).await?;
output.emit(&ProviderLogoutReport { provider, removed })?;
Ok(())
}
}
}
}
async fn tools(action: ToolsAction, output: &Output) -> Result<()> {
use bitrouter::tools as tools_cmd;
match action {
ToolsAction::List { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let servers = tools_cmd::list(&cfg)
.await
.into_iter()
.map(|row| match row.outcome {
Ok(tools) => ServerToolsView {
server: row.server,
tools: Some(
tools
.into_iter()
.map(|t| ToolInfo {
name: t.name,
description: t.description,
})
.collect(),
),
error: None,
},
Err(e) => ServerToolsView {
server: row.server,
tools: None,
error: Some(e),
},
})
.collect();
output.emit(&ToolsListReport { servers })?;
Ok(())
}
ToolsAction::Status { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let servers = tools_cmd::status(&cfg)
.await
.into_iter()
.map(|row| {
let (ok, latency_ms, error) = match row.outcome {
Ok(d) => (true, Some(d.as_millis()), None),
Err(e) => (false, None, Some(e)),
};
ServerStatusView {
server: row.server,
ok,
latency_ms,
transport: row.transport,
error,
}
})
.collect();
output.emit(&ToolsStatusReport { servers })?;
Ok(())
}
ToolsAction::Discover { server, config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
match tools_cmd::discover(&cfg, &server).await {
Ok(yaml) => {
output.emit(&ToolsDiscoverReport { server, yaml })?;
Ok(())
}
Err(e) => anyhow::bail!("discover '{server}': {e}"),
}
}
}
}
async fn agent_proxy_cmd(agent: &str, source: &bitrouter::paths::ConfigSource) -> Result<()> {
let cfg = bitrouter::paths::load_config(source).await?;
bitrouter::agent_proxy::run(cfg, agent).await
}
async fn observe(action: ObserveAction, output: &Output) -> Result<()> {
match action {
ObserveAction::Status { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
output.emit(&observe_status(&socket).await?)?;
Ok(())
}
}
}
async fn observe_status(socket: &Path) -> Result<ObserveStatusReport> {
use bitrouter_observe::OTEL_ENABLED;
let (snapshot, daemon_reachable) =
match daemon::send_command(socket, &DaemonCommand::ObserveStatus).await {
Ok(DaemonResponse::ObserveStatus { payload }) => (payload, true),
Ok(DaemonResponse::Error { message }) => return Err(anyhow::anyhow!(message)),
Ok(other) => return Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if daemon::is_not_reachable(&e) => {
(daemon::ObserveStatusPayload::unwired(OTEL_ENABLED), false)
}
Err(e) => return Err(e),
};
Ok(ObserveStatusReport {
daemon_reachable,
snapshot,
socket: socket.display().to_string(),
})
}
async fn agents_cmd(action: AgentsAction, output: &Output) -> Result<()> {
use bitrouter::agents as agents_cmd;
match action {
AgentsAction::List { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let agents = agents_cmd::list(&cfg)
.into_iter()
.map(|row| AgentRow {
id: row.id,
configured: row.configured,
in_catalog: row.in_catalog,
description: row.description,
})
.collect();
output.emit(&AgentsListReport { agents })?;
Ok(())
}
AgentsAction::Check { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let agents = agents_cmd::check(&cfg)
.await
.into_iter()
.map(|row| {
let (ok, latency_ms, error) = match row.outcome {
Ok(d) => (true, Some(d.as_millis()), None),
Err(e) => (false, None, Some(e)),
};
AgentCheckRow {
id: row.id,
ok,
latency_ms,
error,
}
})
.collect();
output.emit(&AgentsCheckReport { agents })?;
Ok(())
}
AgentsAction::Install { id } => match agents_cmd::install(&id) {
Ok(yaml) => {
output.emit(&AgentInstallReport { id, yaml })?;
Ok(())
}
Err(e) => anyhow::bail!(e),
},
}
}
fn pid_path_for(socket: &Path) -> PathBuf {
let mut p = socket.to_path_buf();
p.set_extension("pid");
p
}
#[cfg(unix)]
fn process_is_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(windows)]
fn process_is_alive(pid: u32) -> bool {
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output();
match output {
Ok(out) => {
let text = String::from_utf8_lossy(&out.stdout);
text.contains(&format!("\"{pid}\""))
}
Err(_) => false,
}
}
#[cfg(unix)]
async fn force_kill(pid: u32) {
let _ = tokio::process::Command::new("kill")
.args(["-9", &pid.to_string()])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
}
#[cfg(windows)]
async fn force_kill(pid: u32) {
let _ = tokio::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cli_definition_is_valid() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn update_flags_parse() {
use clap::Parser;
let cli =
Cli::try_parse_from(["bitrouter", "update", "--check", "--tag", "1.0.0-alpha.18"])
.expect("parse");
match cli.command {
Command::Update {
check,
tag,
stable,
restart,
yes,
} => {
assert!(check);
assert_eq!(tag.as_deref(), Some("1.0.0-alpha.18"));
assert!(!stable && !restart && !yes);
}
_ => panic!("expected Update"),
}
}
#[test]
fn provider_login_import_existing_flags_parse() {
use clap::Parser;
let cli = Cli::try_parse_from([
"bitrouter",
"providers",
"login",
"openai-codex",
"--import-existing",
"--no-browser",
"--label",
"work",
])
.expect("parse");
match cli.command {
Command::Providers {
action:
ProviderAction::Login {
provider,
label,
import_existing,
no_browser,
},
} => {
assert_eq!(provider, "openai-codex");
assert_eq!(label, "work");
assert!(import_existing);
assert!(no_browser);
}
_ => panic!("expected provider login"),
}
}
}