use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use bitrouter::commands;
use bitrouter::daemon::{self, DaemonCommand, DaemonResponse, RouteHop};
use bitrouter_sdk::config;
#[derive(Parser)]
#[command(name = "bitrouter", version, about)]
struct Cli {
#[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,
},
Key {
#[command(subcommand)]
action: KeyAction,
},
Models {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(short, long)]
provider: Option<String>,
},
Tools {
#[command(subcommand)]
action: ToolsAction,
},
Observe {
#[command(subcommand)]
action: ObserveAction,
},
Policy {
#[command(subcommand)]
action: PolicyAction,
},
Providers {
#[command(subcommand)]
action: ProviderAction,
},
Wallet,
Login {
provider: Option<String>,
#[arg(short, long, default_value = "default")]
label: String,
},
Logout {
provider: Option<String>,
},
Whoami,
Agents {
#[command(subcommand)]
action: AgentsAction,
},
#[command(name = "agent-proxy")]
AgentProxy {
agent: String,
#[arg(short, long)]
config: Option<PathBuf>,
},
Auth {
#[command(subcommand)]
action: AuthAction,
},
Cloud {
#[command(subcommand)]
action: bitrouter::cloud::cli::CloudAction,
},
}
#[derive(Subcommand)]
enum AuthAction {
Login {
#[arg(long = "oauth-as", value_name = "URL")]
authorization_server: Option<String>,
#[arg(long = "client-id", value_name = "ID")]
client_id: Option<String>,
#[arg(long, value_name = "SCOPE")]
scope: Option<String>,
},
Logout {
#[arg(long = "oauth-as", value_name = "URL")]
authorization_server: Option<String>,
#[arg(long = "client-id", value_name = "ID")]
client_id: Option<String>,
},
Whoami,
}
#[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>,
#[arg(long)]
json: bool,
},
}
#[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>,
},
Use {
id: String,
},
}
#[tokio::main]
async fn main() {
match run().await {
Ok(()) => {}
Err(e) => {
bitrouter::error_report::report(&e);
std::process::exit(1);
}
}
}
async fn run() -> Result<()> {
let cli = Cli::parse();
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());
start(&source, &log_path).await
}
Command::Stop { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
stop(&socket).await
}
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());
restart(&source, &socket, &log_path).await
}
Command::Reload { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
reload(&socket).await
}
Command::Status { config, socket } => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
status(&socket).await
}
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?;
route(&model, &source, &socket).await
}
Command::Init { config } => init(&config).await,
Command::Key { action } => key(action).await,
Command::Models { config, provider } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
models(&source, provider.as_deref()).await
}
Command::Tools { action } => tools(action).await,
Command::Observe { action } => observe(action).await,
Command::Policy { action } => policy(action).await,
Command::Providers { action } => providers(action).await,
Command::Wallet => {
print_unimplemented(
"wallet",
"OWS wallet integration is delivered by the `ows` workspace, not\n\
by bitrouter. v1.0 ships the routing / settlement layer only.",
);
Ok(())
}
Command::Login { provider, label } => match provider.as_deref() {
Some(name) => bitrouter::commands::login_provider(name, &label).await,
None => {
print_unimplemented(
"login",
"`bitrouter login` is the per-provider OAuth surface.\n\
For cloud sign-in, run `bitrouter auth login`.\n\
For a local virtual key, run `bitrouter key sign --user <id>`.",
);
Ok(())
}
},
Command::Logout { provider } => match provider.as_deref() {
Some(name) => bitrouter::commands::logout_provider(name).await,
None => {
print_unimplemented(
"logout",
"`bitrouter logout` is the per-provider OAuth surface.\n\
For cloud sign-out, run `bitrouter auth logout`.",
);
Ok(())
}
},
Command::Whoami => {
print_unimplemented(
"whoami",
"`bitrouter whoami` (top-level) is a legacy shim. Use:\n\
- `bitrouter auth whoami` — local cloud identity (offline read).\n\
- `bitrouter cloud whoami` — same identity plus the configured /v1/* base URL.",
);
Ok(())
}
Command::Agents { action } => agents_cmd(action).await,
Command::AgentProxy { agent, config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
agent_proxy_cmd(&agent, &source).await
}
Command::Auth { action } => auth_cmd(action).await,
Command::Cloud { action } => bitrouter::cloud::cli::run(action).await,
}
}
async fn auth_cmd(action: AuthAction) -> Result<()> {
use bitrouter_cloud_sdk::auth::commands::{LoginInputs, login, logout, whoami};
match action {
AuthAction::Login {
authorization_server,
client_id,
scope,
} => {
login(LoginInputs {
authorization_server,
client_id,
scope,
})
.await?;
Ok(())
}
AuthAction::Logout {
authorization_server,
client_id,
} => {
logout(LoginInputs {
authorization_server,
client_id,
scope: None,
})
.await
}
AuthAction::Whoami => whoami().await,
}
}
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_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 cfg = bitrouter::paths::load_config(source).await?;
announce_zero_config(source, &cfg);
let listen = cfg.server.listen.clone();
let socket_path = match source {
bitrouter::paths::ConfigSource::File(path) => {
daemon::resolve_socket_path(path, &cfg.server.control_socket)
}
bitrouter::paths::ConfigSource::Default { home } => home.join("bitrouter.sock"),
};
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) -> Result<()> {
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::resolve_socket_path(
path,
&cfg.server.control_socket,
)),
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 exe = std::env::current_exe().context("locating current bitrouter binary")?;
let log_size_before = std::fs::metadata(log_path).map(|m| m.len()).unwrap_or(0);
let log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_path)
.with_context(|| format!("opening daemon log {}", log_path.display()))?;
let log_err = log
.try_clone()
.context("duplicating log handle for stderr")?;
let mut cmd = std::process::Command::new(&exe);
cmd.arg("serve");
if let bitrouter::paths::ConfigSource::File(path) = source {
cmd.arg("--config").arg(path);
}
cmd.stdout(std::process::Stdio::from(log))
.stderr(std::process::Stdio::from(log_err))
.stdin(std::process::Stdio::null());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const DETACHED_PROCESS: u32 = 0x0000_0008;
const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
}
let mut child = cmd.spawn().context("spawning detached `bitrouter serve`")?;
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
if let Ok(Some(status)) = child.try_wait() {
let tail = read_log_since(log_path, log_size_before).await;
eprint_failure_log(log_path, &tail);
anyhow::bail!("daemon exited immediately ({status})");
}
println!(
"bitrouter daemon started (pid {}) — logs at {}",
child.id(),
log_path.display()
);
Ok(())
}
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 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 auth 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 login anthropic # Claude Pro/Max subscription");
eprintln!(" bitrouter login github-copilot # GitHub Copilot subscription");
eprintln!(" bitrouter 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.to_string())
.filter(|v| v != "BITROUTER_API_KEY")
.collect();
vars.sort();
vars.dedup();
vars
}
async fn read_log_since(path: &Path, offset: u64) -> String {
let bytes = match tokio::fs::read(path).await {
Ok(b) => b,
Err(_) => return String::new(),
};
let start = (offset as usize).min(bytes.len());
String::from_utf8_lossy(&bytes[start..]).into_owned()
}
fn eprint_failure_log(log_path: &Path, content: &str) {
let trimmed = content.trim_end();
if trimmed.is_empty() {
return;
}
let p = bitrouter::style::Palette::for_stderr();
eprintln!(
"{dim}daemon log ({path}):{reset}",
dim = p.dim,
path = log_path.display(),
reset = p.reset,
);
for line in trimmed.lines() {
eprintln!(" {line}");
}
eprintln!();
}
async fn stop(socket: &Path) -> Result<()> {
match daemon::send_command(socket, &DaemonCommand::Stop).await? {
DaemonResponse::Ok => {
println!("daemon stopped");
Ok(())
}
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<()> {
if daemon::endpoint_in_use(socket) {
match daemon::send_command(socket, &DaemonCommand::Stop).await {
Ok(DaemonResponse::Ok) => println!("daemon stopped"),
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).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<()> {
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.to_string(), v))
})
.collect();
match daemon::send_command(socket, &DaemonCommand::Reload { env }).await? {
DaemonResponse::Ok => {
println!("config reloaded");
Ok(())
}
DaemonResponse::Error { message } => Err(anyhow::anyhow!(message)),
other => Err(anyhow::anyhow!("unexpected response: {other:?}")),
}
}
async fn status(socket: &Path) -> Result<()> {
let p = bitrouter::style::Palette::for_stdout();
match daemon::send_command(socket, &DaemonCommand::Status).await {
Ok(DaemonResponse::Status {
pid,
listen,
models,
}) => {
print_status_running(&p, pid, &listen, models, socket);
Ok(())
}
Ok(DaemonResponse::Error { message }) => Err(anyhow::anyhow!(message)),
Ok(other) => Err(anyhow::anyhow!("unexpected response: {other:?}")),
Err(e) if daemon::is_not_reachable(&e) => {
print_status_stopped(&p, socket);
Ok(())
}
Err(e) => Err(e),
}
}
fn print_status_running(
p: &bitrouter::style::Palette,
pid: u32,
listen: &str,
models: usize,
socket: &Path,
) {
println!(
"{green}●{reset} bitrouter is {bold}running{reset}",
green = p.green,
bold = p.bold,
reset = p.reset,
);
println!();
print_status_row(p, "pid", &pid.to_string());
print_status_row(p, "listen", listen);
print_status_row(p, "models", &format!("{models} routable"));
print_status_row(p, "socket", &socket.display().to_string());
}
fn print_status_stopped(p: &bitrouter::style::Palette, socket: &Path) {
println!(
"{dim}○{reset} bitrouter is {bold}stopped{reset}",
dim = p.dim,
bold = p.bold,
reset = p.reset,
);
println!();
print_status_row(p, "socket", &socket.display().to_string());
println!();
println!(
" {dim}Run `bitrouter start` to launch the daemon.{reset}",
dim = p.dim,
reset = p.reset,
);
}
fn print_status_row(p: &bitrouter::style::Palette, label: &str, value: &str) {
println!(
" {dim}{label:<8}{reset} {value}",
dim = p.dim,
reset = p.reset,
);
}
async fn route(model: &str, source: &bitrouter::paths::ConfigSource, socket: &Path) -> Result<()> {
if daemon::endpoint_in_use(socket) {
match daemon::send_command(
socket,
&DaemonCommand::Route {
model: model.into(),
},
)
.await
{
Ok(DaemonResponse::Route { chain }) => {
print_route_chain(model, &chain, "live daemon");
return Ok(());
}
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"
};
print_route_chain(model, &chain, label);
Ok(())
}
fn print_route_chain(model: &str, chain: &[RouteHop], source: &str) {
println!("model: {model} (resolved via: {source})");
if chain.is_empty() {
println!(" (empty chain — no provider declares this model)");
return;
}
for (i, hop) in chain.iter().enumerate() {
println!(
" {}. {} → {} ({})",
i + 1,
hop.provider,
hop.service_id,
hop.api_protocol
);
}
}
async fn init(config_path: &Path) -> Result<()> {
commands::init(config_path).await?;
println!("wrote starter config to {}", config_path.display());
println!(" (skip_auth is on — credential-less local requests are admitted)");
Ok(())
}
async fn key(action: KeyAction) -> Result<()> {
match action {
KeyAction::Sign { user, db, policy } => {
let key = commands::key_sign(&db, &user, policy.as_deref()).await?;
println!("created virtual key {} for user '{user}'", key.id);
println!();
println!(" {}", key.secret);
println!();
println!("This secret is shown ONCE — only its SHA-256 hash is stored.");
Ok(())
}
}
}
async fn models(source: &bitrouter::paths::ConfigSource, provider: Option<&str>) -> Result<()> {
let cfg = bitrouter::paths::load_config(source).await?;
let models = commands::list_models(&cfg, provider).await?;
if models.is_empty() {
match (provider, source.is_default()) {
(Some(p), _) => println!("(no routable models for provider '{p}')"),
(None, true) => {
println!("(no routable models — zero-config mode and no provider env vars are set)")
}
(None, false) => println!("(no routable models — configure providers in your config)"),
}
}
for (id, providers) in models {
println!("{id}\t{}", providers.join(", "));
}
Ok(())
}
async fn policy(action: PolicyAction) -> Result<()> {
match action {
PolicyAction::Create { id, dir } => {
let path = commands::create_policy(&dir, &id).await?;
println!("wrote starter policy to {}", path.display());
println!(" edit, then bind to a key with:");
println!(" bitrouter key sign --user <id> --policy {id}");
Ok(())
}
}
}
async fn providers(action: ProviderAction) -> 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);
if providers.is_empty() {
if source.is_default() {
println!("(no providers — zero-config mode and no provider env vars set)");
} else {
println!("(no providers configured)");
}
return Ok(());
}
println!("{:<20} {:<8} {:<6} API_BASE", "ID", "MODELS", "ACTIVE");
for p in providers {
println!(
"{:<20} {:<8} {:<6} {}",
p.id,
p.model_count,
if p.active { "yes" } else { "no" },
p.api_base
);
}
Ok(())
}
ProviderAction::Use { id } => {
println!("v1 has no \"current provider\" — `providers use {id}` is a no-op.");
println!(
" request a specific provider per-call via the model name or routing\n prefs; bind a default by editing `bitrouter.yaml`."
);
Ok(())
}
}
}
async fn tools(action: ToolsAction) -> 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?;
if cfg.mcp_servers.is_empty() {
println!("(no MCP servers configured)");
println!(" add an `mcp_servers:` block to your bitrouter.yaml —");
println!(" see the commented stub in the starter config written by");
println!(" `bitrouter init`.");
return Ok(());
}
let rows = tools_cmd::list(&cfg).await;
for row in rows {
match row.outcome {
Ok(tools) if tools.is_empty() => {
println!("{} (no tools advertised)", row.server);
}
Ok(tools) => {
println!("{} ({})", row.server, tools.len());
for t in tools {
if t.description.is_empty() {
println!(" {}", t.name);
} else {
println!(" {} — {}", t.name, t.description);
}
}
}
Err(e) => {
eprintln!("{}: ERROR — {e}", row.server);
}
}
}
Ok(())
}
ToolsAction::Status { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
if cfg.mcp_servers.is_empty() {
println!("(no MCP servers configured)");
return Ok(());
}
let rows = tools_cmd::status(&cfg).await;
println!(
"{:<24} {:<8} {:<12} TRANSPORT",
"SERVER", "STATUS", "LATENCY"
);
for row in rows {
let (status, latency) = match row.outcome {
Ok(d) => ("ok".to_string(), format!("{}ms", d.as_millis())),
Err(_) => ("FAIL".to_string(), "-".to_string()),
};
println!(
"{:<24} {:<8} {:<12} {}",
row.server, status, latency, row.transport
);
if let Err(e) = row.outcome.as_ref() {
eprintln!(" ↳ {e}");
}
}
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) => {
print!("{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) -> Result<()> {
match action {
ObserveAction::Status {
config,
socket,
json,
} => {
let socket = resolve_client_socket(config.as_deref(), socket.as_deref()).await?;
observe_status(&socket, json).await
}
}
}
async fn observe_status(socket: &Path, as_json: bool) -> Result<()> {
use bitrouter_observe::OTEL_ENABLED;
let payload = match daemon::send_command(socket, &DaemonCommand::ObserveStatus).await {
Ok(DaemonResponse::ObserveStatus { payload }) => Some(payload),
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) => None,
Err(e) => return Err(e),
};
if as_json {
let snapshot =
payload.unwrap_or_else(|| daemon::ObserveStatusPayload::unwired(OTEL_ENABLED));
println!(
"{}",
serde_json::to_string_pretty(&snapshot).context("rendering observe status as JSON")?
);
return Ok(());
}
let p = bitrouter::style::Palette::for_stdout();
match payload {
Some(s) => print_observe_running(&p, &s, socket),
None => print_observe_stopped(&p, socket),
}
Ok(())
}
fn print_observe_running(
p: &bitrouter::style::Palette,
s: &daemon::ObserveStatusPayload,
socket: &Path,
) {
let (bullet, headline) = if s.exporter_wired {
(
format!("{green}●{reset}", green = p.green, reset = p.reset),
"OTel exporter is wired",
)
} else if s.compiled_in {
(
format!("{dim}○{reset}", dim = p.dim, reset = p.reset),
"OTel feature compiled in, exporter not configured",
)
} else {
(
format!("{dim}○{reset}", dim = p.dim, reset = p.reset),
"OTel feature not compiled in",
)
};
println!(
"{bullet} bitrouter observe — {bold}{headline}{reset}",
bold = p.bold,
reset = p.reset,
);
println!();
print_status_row(p, "compiled", if s.compiled_in { "yes" } else { "no" });
print_status_row(p, "wired", if s.exporter_wired { "yes" } else { "no" });
if let Some(endpoint) = &s.endpoint {
print_status_row(p, "endpoint", endpoint);
}
if let Some(service) = &s.service_name {
print_status_row(p, "service", service);
}
if let Some(sampler) = &s.sampler {
let val = match s.sampler_arg {
Some(arg) => format!("{sampler} (arg={arg})"),
None => sampler.clone(),
};
print_status_row(p, "sampler", &val);
}
print_status_row(p, "metrics", if s.metrics_enabled { "on" } else { "off" });
print_status_row(p, "headers", &s.header_count.to_string());
print_status_row(p, "res-attrs", &s.resource_attribute_count.to_string());
print_status_row(
p,
"api-keys",
&format!("{} / {}", s.api_key_count, s.api_key_cap),
);
print_status_row(
p,
"users",
&format!("{} / {}", s.user_id_count, s.user_id_cap),
);
print_status_row(p, "in-flight", &s.active_spans.to_string());
print_status_row(p, "socket", &socket.display().to_string());
}
fn print_observe_stopped(p: &bitrouter::style::Palette, socket: &Path) {
use bitrouter_observe::OTEL_ENABLED;
println!(
"{dim}○{reset} bitrouter observe — {bold}daemon stopped{reset}",
dim = p.dim,
bold = p.bold,
reset = p.reset,
);
println!();
print_status_row(p, "compiled", if OTEL_ENABLED { "yes" } else { "no" });
print_status_row(p, "socket", &socket.display().to_string());
println!();
println!(
" {dim}Run `bitrouter start` to launch the daemon, then re-run this command.{reset}",
dim = p.dim,
reset = p.reset,
);
}
async fn agents_cmd(action: AgentsAction) -> 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 rows = agents_cmd::list(&cfg);
println!(
"{:<16} {:<12} {:<10} DESCRIPTION",
"ID", "CONFIGURED", "CATALOG"
);
for row in rows {
println!(
"{:<16} {:<12} {:<10} {}",
row.id,
if row.configured { "yes" } else { "no" },
if row.in_catalog { "yes" } else { "no" },
row.description,
);
}
Ok(())
}
AgentsAction::Check { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
if cfg.agents.is_empty() {
println!("(no agents configured)");
println!(" install one with: bitrouter agents install <id>");
return Ok(());
}
let rows = agents_cmd::check(&cfg).await;
println!("{:<24} {:<8} LATENCY/ERROR", "AGENT", "STATUS");
for row in rows {
match row.outcome {
Ok(d) => println!("{:<24} {:<8} {}ms", row.id, "ok", d.as_millis()),
Err(e) => {
println!("{:<24} {:<8} -", row.id, "FAIL");
eprintln!(" ↳ {e}");
}
}
}
Ok(())
}
AgentsAction::Install { id } => match agents_cmd::install(&id) {
Ok(yaml) => {
print!("{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
}
fn print_unimplemented(name: &str, detail: &str) {
eprintln!("`bitrouter {name}` is not implemented in v1.0.");
eprintln!();
for line in detail.lines() {
eprintln!(" {line}");
}
}
#[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;
}