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, AgentRegistryRow, 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::policy::PolicyReport;
use bitrouter::output::reports::routing::{ModelRow, ModelsReport, ProviderRow, ProvidersReport};
use bitrouter::output::reports::tools::{
ServerStatusView, ServerToolsView, ToolInfo, ToolsDiscoverReport, ToolsListReport,
ToolsStatusReport,
};
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(long, global = true)]
human: bool,
#[arg(short = 'H', hide = true, conflicts_with = "json")]
human_short: 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>,
},
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,
},
Launch {
#[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>,
},
Spawn {
agent: Option<String>,
#[arg(short = 'p', long, value_name = "TEXT")]
prompt: Option<String>,
#[arg(long, conflicts_with = "prompt")]
serve: bool,
#[arg(long, conflicts_with_all = ["prompt", "serve"])]
check: bool,
#[arg(long)]
direct: bool,
#[arg(long)]
model: Option<String>,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
no_start: bool,
#[arg(long)]
worktree: Option<String>,
#[arg(long, requires = "worktree")]
rm_worktree: bool,
#[arg(long)]
no_transcript: bool,
#[arg(long, value_name = "SECS")]
turn_timeout: Option<u64>,
#[arg(long, requires = "prompt")]
no_wait: bool,
#[arg(long, requires = "serve")]
warm: bool,
#[arg(long, value_name = "SECS", default_value_t = 1800, requires = "warm")]
idle_timeout: u64,
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long = "agent", short = 'a', hide = true, value_enum)]
legacy_agent: Option<bitrouter::spawn::SpawnAgent>,
#[arg(long, hide = true)]
no_install: 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,
},
WorkflowState {
#[command(subcommand)]
action: WorkflowStateAction,
},
Update {
#[arg(long)]
check: bool,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
stable: bool,
#[arg(long)]
restart: bool,
#[arg(short = 'y', long)]
yes: bool,
},
Acp {
#[command(subcommand)]
cmd: AcpCmd,
},
}
#[derive(Subcommand)]
enum ConfigAction {
Validate {
#[arg(short, long)]
config: Option<PathBuf>,
},
}
#[derive(Subcommand)]
enum WorkflowStateAction {
HarborOutcomes {
#[arg(long)]
harbor_run_dir: PathBuf,
#[arg(long)]
output: PathBuf,
},
Bundle {
#[arg(long)]
run_label: String,
#[arg(long)]
traces: PathBuf,
#[arg(long)]
cloud_usage: PathBuf,
#[arg(long)]
outcomes: PathBuf,
#[arg(long)]
policy_decisions: Option<PathBuf>,
#[arg(long)]
output_dir: PathBuf,
},
MeteringUsage {
#[arg(long)]
database_url: String,
#[arg(long)]
output: PathBuf,
#[arg(long = "impute-price")]
impute_prices: Vec<String>,
#[arg(long)]
since: Option<String>,
#[arg(long)]
until: Option<String>,
},
ApplyRewardFeedback {
#[arg(long)]
database_url: String,
#[arg(long)]
traces: PathBuf,
#[arg(long)]
outcomes: PathBuf,
#[arg(long)]
policy_decisions: 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(long)]
remote: bool,
#[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,
},
Init {
name: String,
#[arg(long)]
preset: String,
#[arg(long)]
strong: Option<String>,
#[arg(long)]
economy: String,
#[arg(short, long)]
config: Option<PathBuf>,
},
Check {
#[arg(short, long)]
config: Option<PathBuf>,
},
Status {
#[arg(short, long)]
config: Option<PathBuf>,
},
Show {
name: String,
#[arg(short, long)]
config: Option<PathBuf>,
},
Reload {
#[arg(short, long)]
config: Option<PathBuf>,
#[arg(long)]
socket: Option<PathBuf>,
},
Evolve {
#[arg(long)]
apply: bool,
#[arg(short, long)]
config: Option<PathBuf>,
},
Lock {
#[arg(short, long)]
config: Option<PathBuf>,
},
Unlock {
#[arg(short, long)]
config: Option<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,
},
}
#[derive(Subcommand)]
enum AcpCmd {
Serve {
#[arg(long)]
agent: String,
#[arg(long)]
worktree: Option<String>,
#[arg(long, requires = "worktree")]
rm_worktree: bool,
#[arg(long)]
no_transcript: bool,
#[arg(long, value_name = "SECS")]
turn_timeout: Option<u64>,
#[arg(long)]
warm: bool,
#[arg(long, value_name = "SECS", default_value_t = 1800, requires = "warm")]
idle_timeout: u64,
#[arg(long)]
direct: bool,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
no_start: bool,
#[arg(short, long)]
config: Option<PathBuf>,
},
Prompt {
#[arg(long)]
agent: String,
#[arg(long)]
worktree: Option<String>,
#[arg(long, requires = "worktree")]
rm_worktree: bool,
#[arg(long)]
no_transcript: bool,
#[arg(long, value_name = "SECS")]
turn_timeout: Option<u64>,
#[arg(long)]
no_wait: bool,
#[arg(long)]
direct: bool,
#[arg(long)]
base_url: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
no_start: bool,
#[arg(short, long)]
config: Option<PathBuf>,
text: String,
},
Sessions,
Attach {
record: String,
},
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let raw_cloud_api = matches!(
&cli.command,
Command::Cloud {
action: bitrouter::cloud::cli::CloudAction::Api(_)
}
);
let output = bitrouter::output::Output::from_flags(cli.json, cli.human || cli.human_short);
match run(cli, &output).await {
Ok(()) => {}
Err(e) => {
if raw_cloud_api {
eprintln!("error: {e:#}");
} else {
let _ = output.emit(&bitrouter::output::error::envelope_from_anyhow(&e));
}
std::process::exit(1);
}
}
}
async fn run(cli: Cli, output: &bitrouter::output::Output) -> Result<()> {
let is_acp = matches!(&cli.command, Command::Acp { .. });
if matches!(cli.command, Command::Serve { .. }) {
} else if is_acp {
init_stderr_tracing_subscriber();
} else {
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::Tools { action } => tools(action, output).await,
Command::Observe { action } => observe(action, output).await,
Command::Policy { action } => policy(action, output).await,
Command::Providers { action } => providers(action, output).await,
Command::Agents { action } => agents_cmd(action, output).await,
Command::Launch {
agent,
config,
base_url,
no_install,
no_start,
check,
agent_args,
} => {
let opts = bitrouter::spawn::SpawnOptions {
agent,
agent_args,
base_url,
no_install,
no_start,
check,
};
run_launch(config.as_deref(), opts, output).await
}
Command::Spawn {
agent,
prompt,
serve,
check,
direct,
model,
base_url,
no_start,
worktree,
rm_worktree,
no_transcript,
turn_timeout,
no_wait,
warm,
idle_timeout,
config,
legacy_agent,
no_install,
agent_args,
} => {
if let Some(legacy) = legacy_agent {
if agent.is_some() || prompt.is_some() || serve {
anyhow::bail!(
"`--agent` selects the deprecated interactive launcher; it cannot be \
combined with a positional agent id, `-p`, or `--serve`. Use \
`bitrouter launch --agent {}` for the TUI, or drop `--agent` to spawn \
an ACP sub-agent.",
legacy.spec().id
);
}
eprintln!(
"note: `bitrouter spawn --agent` is deprecated — use \
`bitrouter launch --agent {}` (this alias will be removed).",
legacy.spec().id
);
let opts = bitrouter::spawn::SpawnOptions {
agent: legacy,
agent_args,
base_url,
no_install,
no_start,
check,
};
return run_launch(config.as_deref(), opts, output).await;
}
let Some(agent) = agent else {
anyhow::bail!(
"spawn: provide an agent id and a mode, e.g. \
`bitrouter spawn claude-acp -p \"summarize README\"`, \
`bitrouter spawn codex-acp --serve`, or `--check`."
);
};
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let routing = bitrouter::acp_cli::RoutingOptions {
direct,
base_url,
model,
no_start,
};
if check {
let report = bitrouter::acp_cli::spawn_check(cfg, &agent, &routing).await?;
output.emit(&report)?;
if report.exit_code() == 0 {
Ok(())
} else {
std::process::exit(report.exit_code());
}
} else if serve {
let options = bitrouter::acp_cli::launch_options(
worktree.as_deref(),
rm_worktree,
no_transcript,
turn_timeout,
);
let warm = warm.then(|| bitrouter::acp_cli::WarmOptions {
idle_timeout: std::time::Duration::from_secs(idle_timeout),
});
let ctx = bitrouter::acp_cli::SpawnContext {
source: &source,
config: cfg,
agent_id: &agent,
options,
routing,
};
bitrouter::acp_cli::serve(ctx, warm).await
} else if let Some(text) = prompt {
let options = bitrouter::acp_cli::launch_options(
worktree.as_deref(),
rm_worktree,
no_transcript,
turn_timeout,
);
let mut stdout = tokio::io::stdout();
let ctx = bitrouter::acp_cli::SpawnContext {
source: &source,
config: cfg,
agent_id: &agent,
options,
routing,
};
bitrouter::acp_cli::prompt(ctx, &text, no_wait, &mut stdout).await
} else {
anyhow::bail!(
"spawn: choose a mode — `-p \"<prompt>\"` (NDJSON), \
`--serve` (ACP over stdio), or `--check` (preflight)."
);
}
}
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::WorkflowState { action } => workflow_state_cmd(action).await,
Command::Acp { cmd } => acp_cmd(cmd).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 workflow_state_cmd(action: WorkflowStateAction) -> Result<()> {
match action {
WorkflowStateAction::HarborOutcomes {
harbor_run_dir,
output,
} => {
use bitrouter::workflow_state::reward::BenchmarkOutcomeRecord;
let outcomes = BenchmarkOutcomeRecord::load_harbor_run_dir(&harbor_run_dir)
.with_context(|| format!("read Harbor run {}", harbor_run_dir.display()))?;
BenchmarkOutcomeRecord::write_jsonl(&output, &outcomes)
.with_context(|| format!("write benchmark outcomes {}", output.display()))?;
println!(
"✓ wrote {} benchmark outcomes to {}",
outcomes.len(),
output.display()
);
Ok(())
}
WorkflowStateAction::Bundle {
run_label,
traces,
cloud_usage,
outcomes,
policy_decisions,
output_dir,
} => {
use bitrouter::workflow_state::archive::{
CloudUsageRecord, TraceArchive, WorkflowRunArtifact,
};
use bitrouter::workflow_state::decision::PolicyDecisionRecord;
use bitrouter::workflow_state::real_trace::TraceSanitizer;
use bitrouter::workflow_state::reward::BenchmarkOutcomeRecord;
let traces = TraceArchive::read_jsonl(&traces)
.with_context(|| format!("read workflow traces {}", traces.display()))?;
let usage = CloudUsageRecord::load_snapshot_jsonl(&cloud_usage)
.with_context(|| format!("read cloud usage {}", cloud_usage.display()))?;
let outcomes = BenchmarkOutcomeRecord::load_jsonl(&outcomes)
.with_context(|| format!("read benchmark outcomes {}", outcomes.display()))?;
let decisions = match policy_decisions {
Some(path) => PolicyDecisionRecord::load_jsonl(&path)
.with_context(|| format!("read policy decisions {}", path.display()))?,
None => Vec::new(),
};
let artifact = WorkflowRunArtifact::write_bundle_with_decisions(
run_label,
&output_dir,
&traces,
&usage,
&outcomes,
&decisions,
&TraceSanitizer::default(),
)
.with_context(|| format!("write workflow bundle {}", output_dir.display()))?;
println!(
"✓ wrote workflow bundle to {} (traces: {}, reward matches: {})",
output_dir.display(),
artifact.trace_count,
artifact.reward_join.matched_trace_count
);
Ok(())
}
WorkflowStateAction::MeteringUsage {
database_url,
output,
impute_prices,
since,
until,
} => {
use bitrouter::metering::{
MeteringStore, MeteringUsageRecord, TimeWindow, UsagePriceOverride,
};
let impute_prices = impute_prices
.iter()
.map(|value| UsagePriceOverride::parse(value))
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(anyhow::Error::from)?;
let window = match since {
Some(since) => {
let start = parse_rfc3339_utc(&since, "--since")?;
let end = match until {
Some(until) => parse_rfc3339_utc(&until, "--until")?,
None => chrono::Utc::now(),
};
TimeWindow::Custom { start, end }
}
None => TimeWindow::ThisMonth,
};
let db = bitrouter::db::connect(&database_url)
.await
.with_context(|| format!("connect metering database {database_url}"))?;
let mut records = MeteringStore::new(db)
.export_usage(window)
.await
.with_context(|| format!("export metering usage from {database_url}"))?;
MeteringUsageRecord::apply_price_overrides(&mut records, &impute_prices);
MeteringUsageRecord::write_jsonl(&output, &records)
.with_context(|| format!("write metering usage {}", output.display()))?;
println!(
"✓ wrote {} metering usage records to {}",
records.len(),
output.display()
);
Ok(())
}
WorkflowStateAction::ApplyRewardFeedback {
database_url,
traces,
outcomes,
policy_decisions,
} => {
use bitrouter::adequacy::store::AdequacyStore;
use bitrouter::workflow_state::archive::{TraceArchive, WorkflowRunArtifact};
use bitrouter::workflow_state::decision::PolicyDecisionRecord;
use bitrouter::workflow_state::reward::BenchmarkOutcomeRecord;
use bitrouter::workflow_state::reward_feedback::apply_semantic_reward_feedback;
let traces = TraceArchive::read_jsonl(&traces)
.with_context(|| format!("read workflow traces {}", traces.display()))?;
let outcomes = BenchmarkOutcomeRecord::load_jsonl(&outcomes)
.with_context(|| format!("read benchmark outcomes {}", outcomes.display()))?;
let decisions = PolicyDecisionRecord::load_jsonl(&policy_decisions)
.with_context(|| format!("read policy decisions {}", policy_decisions.display()))?;
let artifact = WorkflowRunArtifact::build_with_decisions(
"reward-feedback",
&traces,
&[],
&outcomes,
&decisions,
)
.context("build workflow artifact for reward feedback")?;
let db = bitrouter::db::connect(&database_url)
.await
.with_context(|| format!("connect adequacy database {database_url}"))?;
let summary = apply_semantic_reward_feedback(
&AdequacyStore::new(db),
&artifact.semantic_policy_transition_candidates,
)
.await
.context("apply reward feedback pins")?;
println!(
"✓ applied reward feedback: {} candidates, {} pinned keys, {} new task-success evidence rows",
summary.candidate_count,
summary.pinned_count,
summary.semantic_success_evidence_count
);
for key in summary.pinned_request_keys {
println!(" pinned {key}");
}
for key in summary.semantic_success_request_keys {
println!(" confirmed success {key}");
}
Ok(())
}
}
}
fn parse_rfc3339_utc(value: &str, flag: &str) -> Result<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::parse_from_rfc3339(value)
.map(|dt| dt.with_timezone(&chrono::Utc))
.with_context(|| format!("{flag} must be RFC3339, got {value:?}"))
}
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) => match bitrouter::policy_lock::load_for_config(&cfg, Some(path)).await {
Ok(_) => 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(error) => Ok(ValidateReport::invalid(
path.display().to_string(),
error.to_string(),
)),
},
Err(e) => Ok(ValidateReport::invalid(
path.display().to_string(),
e.to_string(),
)),
}
}
struct LocalCostFooter {
source: bitrouter::paths::ConfigSource,
}
#[async_trait::async_trait]
impl bitrouter_mcp::server::CostFooter for LocalCostFooter {
async fn line(&self) -> Option<String> {
use bitrouter::metering::store::TimeWindow;
let store = bitrouter::metering::reader::open_readonly(&self.source).await?;
let today = store.spend_summary(TimeWindow::Today).await.ok()?;
(today.requests > 0).then(|| {
format!(
"bitrouter: spend today {} ({} requests)",
bitrouter::metering::fmt_usd(today.spend_micro_usd),
today.requests
)
})
}
}
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)"
);
}
let cost_footer: Option<std::sync::Arc<dyn bitrouter_mcp::server::CostFooter>> =
match (transport, backend) {
(bitrouter_mcp::Transport::Stdio, bitrouter_mcp::BackendKind::Local) => {
bitrouter::paths::resolve_config(None).ok().map(|source| {
std::sync::Arc::new(LocalCostFooter { source })
as std::sync::Arc<dyn bitrouter_mcp::server::CostFooter>
})
}
_ => None,
};
bitrouter_mcp::serve(bitrouter_mcp::ServeOptions {
transport,
backend,
local_url,
cloud_url,
cloud_token,
bind,
cost_footer,
})
.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_stderr_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 workflow_trace_capture =
bitrouter::workflow_state::real_trace::capture_from_env().map_err(anyhow::Error::from)?;
if workflow_trace_capture.is_some() {
tracing::info!(
env = bitrouter::workflow_state::real_trace::WORKFLOW_TRACE_JSONL_ENV,
"workflow trace capture enabled"
);
}
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,
assembled.upstream_executor,
reload_source,
)
.with_policy_runtime(assembled.policy_runtime),
);
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 {
let otel_wrapper = bitrouter_observe::otel::http_layer::router_wrapper();
match workflow_trace_capture {
Some(capture) => {
let workflow_wrapper = capture.router_wrapper();
http_app
.serve_with_router_wrapper(&http_listen, move |router| {
workflow_wrapper(otel_wrapper(router))
})
.await
}
None => {
http_app
.serve_with_router_wrapper(&http_listen, otel_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 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, output: &Output) -> Result<()> {
match action {
PolicyAction::Create { id, dir } => {
let path = commands::create_policy(&dir, &id).await?;
output.emit(&PolicyCreateReport {
id,
path: path.display().to_string(),
created: true,
})?;
}
PolicyAction::Init {
name,
preset,
strong,
economy,
config,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
let update = bitrouter::policy_lock::initialize_files(
config_path,
&name,
&preset,
strong.as_deref(),
&economy,
)
.await?;
output.emit(
&routing_policy_report(config_path, "init", true, update.changes, None).await?,
)?;
}
PolicyAction::Check { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
output.emit(
&routing_policy_report(config_path, "check", false, Vec::new(), None).await?,
)?;
}
PolicyAction::Status { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
output.emit(
&routing_policy_report(config_path, "status", false, Vec::new(), None).await?,
)?;
}
PolicyAction::Show { name, config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
output.emit(
&routing_policy_report(config_path, "show", false, Vec::new(), Some(&name)).await?,
)?;
}
PolicyAction::Reload { config, socket } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let socket = resolve_client_socket_from(&source, socket.as_deref()).await?;
output.emit(&reload(&socket).await?)?;
}
PolicyAction::Evolve { apply, config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
let update = bitrouter::policy_lock::evolve_files(config_path, apply).await?;
let action = if apply { "evolve" } else { "evolve-dry-run" };
let published = apply && !update.changes.is_empty();
let mut report =
routing_policy_report(config_path, action, published, update.changes, None).await?;
report.digest = Some(update.digest);
output.emit(&report)?;
}
PolicyAction::Lock { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
bitrouter::policy_lock::set_writeback_file(
config_path,
config::PolicyWriteback::Locked,
)
.await?;
output
.emit(&routing_policy_report(config_path, "lock", true, Vec::new(), None).await?)?;
}
PolicyAction::Unlock { config } => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let config_path = require_policy_config_path(&source)?;
bitrouter::policy_lock::set_writeback_file(
config_path,
config::PolicyWriteback::Evolve,
)
.await?;
output.emit(
&routing_policy_report(config_path, "unlock", true, Vec::new(), None).await?,
)?;
}
}
Ok(())
}
fn require_policy_config_path(source: &bitrouter::paths::ConfigSource) -> Result<&Path> {
match source {
bitrouter::paths::ConfigSource::File(path) => Ok(path),
bitrouter::paths::ConfigSource::Default { .. } => anyhow::bail!(
"routing policies require a file-backed bitrouter.yaml; run `bitrouter init` first"
),
}
}
async fn routing_policy_report(
config_path: &Path,
action: &str,
applied: bool,
changes: Vec<String>,
show: Option<&str>,
) -> Result<PolicyReport> {
let raw = tokio::fs::read_to_string(config_path)
.await
.with_context(|| format!("reading {}", config_path.display()))?;
let cfg = config::parse(&raw).context("parsing bitrouter.yaml")?;
let loaded = bitrouter::policy_lock::load_for_config(&cfg, Some(config_path)).await?;
let policy = match show {
Some(name) => {
let lock = loaded
.as_ref()
.ok_or_else(|| anyhow::anyhow!("no policy lock is configured"))?;
let definition = lock
.document
.policies
.get(name)
.ok_or_else(|| anyhow::anyhow!("policy '{name}' does not exist"))?;
Some(serde_json::to_value(definition).context("serializing policy")?)
}
None => None,
};
let bindings = cfg
.presets
.iter()
.filter_map(|(name, preset)| {
preset
.policy
.as_ref()
.map(|policy| (name.clone(), policy.clone()))
})
.collect();
let path = loaded
.as_ref()
.map(|lock| lock.path.clone())
.or_else(|| bitrouter::policy_lock::resolve_path(&cfg, Some(config_path)));
let policies = loaded
.as_ref()
.map(|lock| lock.document.policies.keys().cloned().collect())
.unwrap_or_default();
Ok(PolicyReport {
action: action.to_string(),
path: path.map(|path| path.display().to_string()),
digest: loaded.as_ref().map(|lock| lock.digest.clone()),
writeback: match cfg.policy.writeback {
config::PolicyWriteback::Locked => "locked",
config::PolicyWriteback::Evolve => "evolve",
}
.to_string(),
policies,
bindings,
changes,
policy,
applied,
})
}
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,
api_key: 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 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 { remote, 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();
let registry = if remote {
let fetched =
bitrouter::agent_registry::fetch(bitrouter::agent_registry::REGISTRY_URL)
.await?;
Some(
agents_cmd::registry_rows(&fetched)
.into_iter()
.map(|row| AgentRegistryRow {
id: row.id,
version: row.version,
install: row.install.to_string(),
description: row.description,
})
.collect(),
)
} else {
None
};
output.emit(&AgentsListReport { agents, registry })?;
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(catalog_miss) => {
let registry = match bitrouter::agent_registry::fetch(
bitrouter::agent_registry::REGISTRY_URL,
)
.await
{
Ok(registry) => registry,
Err(fetch_err) => {
anyhow::bail!(
"{catalog_miss}\n(also failed to consult the ACP registry: {fetch_err})"
)
}
};
match agents_cmd::install_from_registry(®istry, &id) {
Ok(yaml) => {
output.emit(&AgentInstallReport { id, yaml })?;
Ok(())
}
Err(e) => anyhow::bail!(e),
}
}
},
}
}
async fn run_launch(
config: Option<&std::path::Path>,
opts: bitrouter::spawn::SpawnOptions,
output: &bitrouter::output::Output,
) -> Result<()> {
let source = bitrouter::paths::resolve_config(config)?;
let cfg = bitrouter::paths::load_config(&source).await?;
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
}
}
async fn acp_cmd(cmd: AcpCmd) -> Result<()> {
match cmd {
AcpCmd::Serve {
agent,
worktree,
rm_worktree,
no_transcript,
turn_timeout,
warm,
idle_timeout,
direct,
base_url,
model,
no_start,
config,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let options = bitrouter::acp_cli::launch_options(
worktree.as_deref(),
rm_worktree,
no_transcript,
turn_timeout,
);
let warm = warm.then(|| bitrouter::acp_cli::WarmOptions {
idle_timeout: std::time::Duration::from_secs(idle_timeout),
});
let routing = bitrouter::acp_cli::RoutingOptions {
direct,
base_url,
model,
no_start,
};
let ctx = bitrouter::acp_cli::SpawnContext {
source: &source,
config: cfg,
agent_id: &agent,
options,
routing,
};
bitrouter::acp_cli::serve(ctx, warm).await
}
AcpCmd::Prompt {
agent,
worktree,
rm_worktree,
no_transcript,
turn_timeout,
no_wait,
direct,
base_url,
model,
no_start,
config,
text,
} => {
let source = bitrouter::paths::resolve_config(config.as_deref())?;
let cfg = bitrouter::paths::load_config(&source).await?;
let options = bitrouter::acp_cli::launch_options(
worktree.as_deref(),
rm_worktree,
no_transcript,
turn_timeout,
);
let routing = bitrouter::acp_cli::RoutingOptions {
direct,
base_url,
model,
no_start,
};
let mut stdout = tokio::io::stdout();
let ctx = bitrouter::acp_cli::SpawnContext {
source: &source,
config: cfg,
agent_id: &agent,
options,
routing,
};
bitrouter::acp_cli::prompt(ctx, &text, no_wait, &mut stdout).await
}
AcpCmd::Sessions => {
let mut stdout = tokio::io::stdout();
bitrouter::acp_cli::sessions(&mut stdout).await
}
AcpCmd::Attach { record } => bitrouter::acp_cli::attach(&record).await,
}
}
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"),
}
}
#[test]
fn routing_policy_init_and_evolve_flags_parse() {
use clap::Parser;
let init = Cli::try_parse_from([
"bitrouter",
"policy",
"init",
"terminal-bench",
"--preset",
"coding",
"--economy",
"moonshotai/kimi-k2.7-code",
"--config",
"team/bitrouter.yaml",
])
.expect("parse init");
match init.command {
Command::Policy {
action:
PolicyAction::Init {
name,
preset,
strong,
economy,
config,
},
} => {
assert_eq!(name, "terminal-bench");
assert_eq!(preset, "coding");
assert_eq!(strong, None);
assert_eq!(economy, "moonshotai/kimi-k2.7-code");
assert_eq!(config, Some(PathBuf::from("team/bitrouter.yaml")));
}
_ => panic!("expected policy init"),
}
let evolve = Cli::try_parse_from(["bitrouter", "policy", "evolve", "--apply"])
.expect("parse evolve");
assert!(matches!(
evolve.command,
Command::Policy {
action: PolicyAction::Evolve { apply: true, .. }
}
));
}
#[test]
fn cloud_api_owns_header_short_flag_in_full_command_tree() {
let cli = Cli::try_parse_from([
"bitrouter",
"cloud",
"api",
"/v1/models",
"-H",
"X-Test: value",
])
.unwrap();
match cli.command {
Command::Cloud {
action: bitrouter::cloud::cli::CloudAction::Api(args),
} => assert_eq!(args.headers, ["X-Test: value"]),
_ => panic!("expected cloud API command"),
}
let human = Cli::try_parse_from(["bitrouter", "-H", "cloud", "whoami"]).unwrap();
assert!(human.human_short);
}
#[tokio::test]
async fn config_validate_rejects_a_missing_bound_policy() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bitrouter.yaml");
tokio::fs::write(
&path,
r#"presets:
coding:
model: anthropic/claude-opus-4.8
policy: missing
"#,
)
.await
.unwrap();
let source = bitrouter::paths::ConfigSource::File(path);
let report = validate_config(&source).await.unwrap();
assert!(!report.valid);
assert!(report.errors[0].contains("policy lock"));
}
}