#![recursion_limit = "256"]
#[cfg(not(any(feature = "tempo", feature = "solana")))]
compile_error!(
"bitrouter requires at least one payment chain feature: enable `tempo` and/or `solana`"
);
mod auth;
#[cfg(feature = "cli")]
mod cli;
#[cfg(feature = "cli")]
mod init;
mod runtime;
use std::path::PathBuf;
use std::sync::Arc;
use crate::runtime::{AppRuntime, PathOverrides, RuntimePaths, resolve_home};
use clap::{Parser, Subcommand};
type DefaultRuntime = AppRuntime<
bitrouter_core::routers::dynamic::DynamicRoutingTable<bitrouter_config::ConfigRoutingTable>,
>;
#[derive(Debug, Parser)]
#[command(name = "bitrouter", version, about = "BitRouter CLI")]
struct Cli {
#[arg(long, global = true)]
home_dir: Option<PathBuf>,
#[arg(long, global = true)]
config_file: Option<PathBuf>,
#[arg(long, global = true)]
env_file: Option<PathBuf>,
#[arg(long, global = true)]
run_dir: Option<PathBuf>,
#[arg(long, global = true)]
logs_dir: Option<PathBuf>,
#[arg(long = "db", global = true)]
database_url: Option<String>,
#[arg(long, global = true)]
no_tui: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Debug, Subcommand)]
enum Command {
Serve,
#[cfg(feature = "cli")]
Start,
#[cfg(feature = "cli")]
Stop,
#[cfg(feature = "cli")]
Status,
#[cfg(feature = "cli")]
Restart,
#[cfg(feature = "cli")]
Reload,
#[cfg(feature = "cli")]
Route {
#[command(subcommand)]
action: RouteAction,
},
#[cfg(feature = "cli")]
Wallet {
#[command(subcommand)]
action: WalletAction,
},
#[cfg(feature = "cli")]
Key {
#[command(subcommand)]
action: KeyAction,
},
#[cfg(feature = "cli")]
Tools {
#[command(subcommand)]
action: ToolsAction,
},
#[cfg(feature = "cli")]
Models {
#[command(subcommand)]
action: ModelsAction,
},
#[cfg(feature = "cli")]
Agents {
#[command(subcommand)]
action: AgentsAction,
},
#[cfg(feature = "cli")]
Providers {
#[command(subcommand)]
action: ProvidersAction,
},
#[cfg(feature = "cli")]
#[command(name = "agent-proxy")]
AgentProxy {
agent_name: String,
#[arg(long)]
token: Option<String>,
},
#[cfg(feature = "cli")]
Policy {
#[command(subcommand)]
action: PolicyAction,
},
#[cfg(feature = "cli")]
Auth {
#[command(subcommand)]
action: AuthAction,
},
Login {
#[arg(long)]
url: Option<String>,
},
Logout,
Whoami,
#[cfg(feature = "cli")]
Reset,
}
#[cfg(feature = "cli")]
#[derive(Debug, Subcommand)]
enum RouteAction {
List,
Add {
model: String,
#[arg(required = true, num_args = 1..)]
endpoints: Vec<String>,
#[arg(long, default_value = "priority")]
strategy: String,
},
Rm {
model: String,
},
}
#[derive(Debug, Subcommand)]
enum ToolsAction {
List,
Status,
Discover {
provider: String,
},
}
#[derive(Debug, Subcommand)]
enum WalletAction {
Create {
#[arg(long)]
name: String,
#[arg(long, default_value = "12")]
words: u32,
#[arg(long)]
show_mnemonic: bool,
},
Import {
#[arg(long)]
name: String,
#[arg(long)]
mnemonic: bool,
#[arg(long)]
private_key: bool,
#[arg(long)]
chain: Option<String>,
#[arg(long)]
index: Option<u32>,
},
List,
Info {
#[arg(long)]
wallet: String,
},
Export {
#[arg(long)]
wallet: String,
},
Delete {
#[arg(long)]
wallet: String,
},
Rename {
#[arg(long)]
wallet: String,
#[arg(long)]
new_name: String,
},
}
#[derive(Debug, Subcommand)]
enum ModelsAction {
List,
}
#[derive(Debug, Subcommand)]
enum AgentsAction {
List {
#[arg(long)]
refresh: bool,
},
Check,
Install {
id: String,
},
Uninstall {
id: String,
},
Update {
id: Option<String>,
},
}
#[derive(Debug, Subcommand)]
enum ProvidersAction {
List,
Use {
mode: String,
},
}
#[derive(Debug, Subcommand)]
enum PolicyAction {
Eval,
Create {
#[arg(long)]
name: Option<String>,
#[arg(long)]
daily_limit: Option<u64>,
#[arg(long)]
monthly_limit: Option<u64>,
#[arg(long)]
per_tx_max: Option<u64>,
#[arg(long, value_delimiter = ',')]
chains: Option<Vec<String>>,
#[arg(long)]
expires: Option<String>,
#[arg(long)]
file: Option<std::path::PathBuf>,
#[arg(long = "tool-allow")]
tool_allow: Vec<String>,
},
List,
Show {
#[arg(long)]
id: String,
},
Delete {
#[arg(long)]
id: String,
},
}
#[derive(Debug, Subcommand)]
enum AuthAction {
Login {
provider: Option<String>,
},
Refresh {
provider: Option<String>,
},
Status,
}
#[derive(Debug, Subcommand)]
enum KeyAction {
Create {
#[arg(long)]
name: String,
#[arg(long, required = true, num_args = 1..)]
wallet: Vec<String>,
#[arg(long)]
policy: Vec<String>,
#[arg(long)]
expires_at: Option<String>,
},
List,
Revoke {
#[arg(long)]
id: String,
},
Sign {
#[arg(long)]
wallet: String,
#[arg(long, value_delimiter = ',')]
models: Option<Vec<String>>,
#[arg(long)]
budget: Option<u64>,
#[arg(long)]
budget_scope: Option<String>,
#[arg(long)]
exp: Option<String>,
#[arg(long)]
ows_key: Option<String>,
#[arg(long)]
policy: Option<String>,
#[arg(long)]
raw: bool,
},
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
if has_removed_headless_flag(std::env::args_os()) {
return Err(
"`bitrouter --headless` has been removed; use `bitrouter serve` to run the server in the foreground."
.into(),
);
}
let cli = Cli::parse();
#[cfg(feature = "cli")]
let update_check = tokio::spawn(cli::update_check::check_for_update());
let result = run_cli(cli).await;
#[cfg(feature = "cli")]
if let Ok(Ok(Some(msg))) =
tokio::time::timeout(std::time::Duration::from_secs(2), update_check).await
{
eprintln!("{msg}");
}
result
}
async fn run_cli(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
let paths = resolve_home(cli.home_dir.as_deref());
let overrides = PathOverrides {
config_file: cli.config_file.clone(),
env_file: cli.env_file.clone(),
runtime_dir: cli.run_dir.clone(),
log_dir: cli.logs_dir.clone(),
};
let paths = overrides.apply(paths);
#[cfg(feature = "cli")]
if matches!(cli.command, Some(Command::Reset)) {
return run_reset(&paths, cli.no_tui).await;
}
if cli.command.is_none() {
#[cfg(feature = "cli")]
{
let config_exists = paths.config_file.exists()
&& std::fs::read_to_string(&paths.config_file)
.map(|s| !s.trim_start().starts_with('#'))
.unwrap_or(false);
if !config_exists {
let outcome = init::run_init(&paths)?;
if outcome == init::InitOutcome::Configured {
return launch_after_init(&paths, cli.no_tui).await;
}
return Ok(());
}
return run_help_status(&paths);
}
#[cfg(not(feature = "cli"))]
{
return Err("no subcommand given; use `bitrouter serve` to start the server".into());
}
}
#[cfg(feature = "cli")]
match cli.command {
Some(Command::Wallet { action }) => {
match action {
WalletAction::Create {
name,
words,
show_mnemonic,
} => cli::wallet::create(&name, Some(words), show_mnemonic)?,
WalletAction::Import {
name,
mnemonic,
private_key,
chain,
index,
} => {
if mnemonic {
cli::wallet::import_mnemonic(&name, index)?;
} else if private_key {
cli::wallet::import_private_key(&name, chain.as_deref())?;
} else {
return Err("specify --mnemonic or --private-key for wallet import".into());
}
}
WalletAction::List => cli::wallet::list(None)?,
WalletAction::Info { wallet } => cli::wallet::info(&wallet, None)?,
WalletAction::Export { wallet } => cli::wallet::export(&wallet)?,
WalletAction::Delete { wallet } => cli::wallet::delete(&wallet)?,
WalletAction::Rename { wallet, new_name } => {
cli::wallet::rename(&wallet, &new_name)?
}
}
return Ok(());
}
Some(Command::Key { action }) => {
match action {
KeyAction::Create {
name,
wallet,
policy,
expires_at,
} => cli::key::create(&name, &wallet, &policy, expires_at.as_deref())?,
KeyAction::List => cli::key::list()?,
KeyAction::Revoke { id } => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
cli::key::revoke_on_server(&runtime.config, runtime.config.server.listen, &id)?;
}
KeyAction::Sign {
wallet,
models,
budget,
budget_scope,
exp,
ows_key,
policy,
raw,
} => {
let runtime = (!raw).then(|| load_or_warn_scaffold(&paths));
let virtual_key_target = runtime.as_ref().map(|runtime: &DefaultRuntime| {
(&runtime.config, runtime.config.server.listen)
});
cli::key::sign(cli::key::SignOptions {
wallet_name: &wallet,
models: models.as_deref(),
budget,
budget_scope: budget_scope.as_deref(),
exp: exp.as_deref(),
ows_key: ows_key.as_deref(),
policy: policy.as_deref(),
virtual_key_target,
})?;
}
}
return Ok(());
}
Some(Command::Policy { action }) => {
let pd = cli::policy::policy_dir(&paths.home_dir);
match action {
PolicyAction::Eval => cli::policy::eval(&pd)?,
PolicyAction::Create {
name,
daily_limit,
monthly_limit,
per_tx_max,
chains,
expires,
file,
tool_allow,
} => cli::policy::create(
&pd,
cli::policy::CreateOpts {
name: name.as_deref().unwrap_or("default"),
daily_limit,
monthly_limit,
per_tx_max,
chains: chains.as_deref().unwrap_or(&[]),
expires_at: expires.as_deref(),
file: file.as_deref(),
tool_allow: &tool_allow,
},
)?,
PolicyAction::List => cli::policy::list(&pd)?,
PolicyAction::Show { id } => cli::policy::show(&pd, &id)?,
PolicyAction::Delete { id } => cli::policy::delete(&pd, &id)?,
}
return Ok(());
}
Some(Command::Auth { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
tokio::task::block_in_place(|| match action {
AuthAction::Login { provider } => {
cli::auth::run_login(&runtime.config, &paths, provider.as_deref())
}
AuthAction::Refresh { provider } => {
cli::auth::run_refresh(&runtime.config, &paths, provider.as_deref())
}
AuthAction::Status => cli::auth::run_status(&runtime.config, &paths),
})?;
return Ok(());
}
Some(Command::Login { url }) => {
let base = cli::cloud_auth::resolve_cloud_url(url.as_deref());
tokio::task::block_in_place(|| cli::cloud_auth::run_login(&paths.home_dir, &base))?;
return Ok(());
}
Some(Command::Logout) => {
cli::cloud_auth::run_logout(&paths.home_dir)?;
return Ok(());
}
Some(Command::Whoami) => {
cli::cloud_auth::run_whoami(&paths.home_dir)?;
return Ok(());
}
Some(Command::Tools { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
let addr = runtime.config.server.listen;
match action {
ToolsAction::List => cli::tools::run_list(&runtime.config, addr)?,
ToolsAction::Status => cli::tools::run_status(&runtime.config, addr)?,
ToolsAction::Discover { provider } => {
cli::tools::run_discover(&runtime.config, &provider).await?
}
}
return Ok(());
}
Some(Command::Models { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
match action {
ModelsAction::List => cli::models::run_list(&runtime.config)?,
}
return Ok(());
}
#[cfg(feature = "tui")]
Some(Command::AgentProxy { agent_name, token }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
cli::agent_proxy::run(&runtime.config, &agent_name, token.as_deref())?;
return Ok(());
}
#[cfg(not(feature = "tui"))]
Some(Command::AgentProxy { .. }) => {
return Err("agent-proxy requires the `tui` feature".into());
}
#[cfg(feature = "tui")]
Some(Command::Agents { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
match action {
AgentsAction::List { refresh } => {
cli::agents::run_list(&runtime.config, &paths, refresh).await?
}
AgentsAction::Check => cli::agents::run_check(&runtime.config)?,
AgentsAction::Install { id } => {
cli::agents::run_install(&id, &runtime.config, &paths).await?
}
AgentsAction::Uninstall { id } => cli::agents::run_uninstall(&id, &paths).await?,
AgentsAction::Update { id } => {
cli::agents::run_update(id.as_deref(), &runtime.config, &paths).await?
}
}
return Ok(());
}
#[cfg(not(feature = "tui"))]
Some(Command::Agents { .. }) => {
return Err("agent management requires the `tui` feature".into());
}
#[cfg(feature = "cli")]
Some(Command::Providers { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
match action {
ProvidersAction::List => cli::providers::run_list(&runtime.config)?,
ProvidersAction::Use { mode } => cli::providers::run_use(&mode, &runtime.config)?,
}
return Ok(());
}
Some(Command::Route { action }) => {
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
let addr = runtime.config.server.listen;
match action {
RouteAction::List => cli::route::run_list(&runtime.config, addr)?,
RouteAction::Add {
model,
endpoints,
strategy,
} => cli::route::run_add(
&runtime.config,
addr,
cli::route::RouteAddOpts {
model,
endpoints,
strategy: Some(strategy),
},
)?,
RouteAction::Rm { model } => cli::route::run_remove(&runtime.config, addr, &model)?,
}
return Ok(());
}
_ => {}
}
init_tracing();
let runtime: DefaultRuntime = load_or_warn_scaffold(&paths);
if is_server_command(&cli.command)
&& let Err(e) = ensure_ows_passphrase(&runtime.config)
{
eprintln!("wallet passphrase error: {e}");
std::process::exit(1);
}
match cli.command {
Some(Command::Serve) => {
let env_file = paths.env_file.exists().then_some(paths.env_file.as_path());
let db_url = crate::runtime::resolve_database_url(
cli.database_url.as_deref(),
&runtime.config,
&paths.home_dir,
env_file,
);
let mut db_opts = sea_orm::ConnectOptions::new(&db_url);
db_opts.sqlx_logging_level(tracing::log::LevelFilter::Debug);
let db = sea_orm::Database::connect(db_opts).await.map_err(|e| {
crate::runtime::error::RuntimeError::Other(format!(
"database connection failed: {e}. Check `database.url` in {} or BITROUTER_DATABASE_URL.",
paths.config_file.display()
))
})?;
crate::runtime::migrate(&db).await.map_err(|e| {
crate::runtime::error::RuntimeError::Other(format!(
"database migration failed: {e}"
))
})?;
let db = Arc::new(db);
print_first_run_guidance(&runtime);
let base_client = crate::runtime::http_client::build_upstream_client();
let client_builder = reqwest_middleware::ClientBuilder::new(base_client);
let client_builder =
match crate::runtime::payment::build_payment_middleware(&runtime.config) {
Ok(Some(mw)) => client_builder.with(mw),
Ok(None) => client_builder,
Err(e) => {
tracing::warn!("payment middleware disabled: {e}");
client_builder
}
};
let model_router = crate::runtime::Router::new(
client_builder.build(),
runtime.config.providers.clone(),
)
.with_token_store(paths.token_store_file.clone());
runtime.serve_with_reload(db, model_router).await?
}
#[cfg(feature = "cli")]
Some(Command::Start) => runtime.start().await?,
#[cfg(feature = "cli")]
Some(Command::Stop) => runtime.stop().await?,
#[cfg(feature = "cli")]
Some(Command::Status) => {
let status = runtime.status();
match status.daemon_pid {
Some(pid) => println!("daemon: running (pid {pid})"),
None => println!("daemon: stopped"),
}
println!("home: {}", status.home_dir.display());
println!("config: {}", status.config_file.display());
println!("runtime: {}", status.runtime_dir.display());
println!("listen: {}", status.listen_addr);
println!("providers: {}", status.providers.join(", "));
if !status.models.is_empty() {
println!("models: {}", status.models.join(", "));
}
}
#[cfg(feature = "cli")]
Some(Command::Restart) => runtime.restart().await?,
#[cfg(feature = "cli")]
Some(Command::Reload) => runtime.reload()?,
_ => {
unreachable!()
}
}
Ok(())
}
fn is_server_command(cmd: &Option<Command>) -> bool {
#[cfg(feature = "cli")]
{
matches!(cmd, Some(Command::Serve | Command::Start))
}
#[cfg(not(feature = "cli"))]
{
matches!(cmd, Some(Command::Serve))
}
}
fn load_or_warn_scaffold(paths: &RuntimePaths) -> DefaultRuntime {
match DefaultRuntime::load(paths.clone()) {
Ok(rt) => rt,
Err(e) => {
if paths.config_file.exists() {
eprintln!(
"warning: failed to parse {}: {e}",
paths.config_file.display()
);
eprintln!(" falling back to default configuration");
eprintln!();
}
DefaultRuntime::scaffold(paths.clone())
}
}
}
fn print_first_run_guidance(runtime: &DefaultRuntime) {
if runtime.config.has_configured_providers() {
return;
}
let detected = bitrouter_config::detect_providers_from_env();
if detected.is_empty() {
eprintln!("No providers configured and no API keys found in environment.");
eprintln!("Run `bitrouter` to set up providers interactively.");
eprintln!();
} else {
let names: Vec<&str> = detected.iter().map(|d| d.name.as_str()).collect();
eprintln!(
"Auto-detected providers from environment: {}",
names.join(", ")
);
eprintln!("Direct routing is available (e.g., \"openai:gpt-4o\").");
eprintln!("Run `bitrouter` to save a permanent configuration.");
eprintln!();
}
}
#[cfg(feature = "cli")]
fn run_help_status(paths: &RuntimePaths) -> Result<(), Box<dyn std::error::Error>> {
let runtime: DefaultRuntime = load_or_warn_scaffold(paths);
let status = runtime.status();
let version = env!("CARGO_PKG_VERSION");
println!();
println!(" BitRouter v{version}");
println!(" ─────────────────");
println!();
match status.daemon_pid {
Some(pid) => println!(" daemon: running (pid {pid})"),
None => println!(" daemon: stopped"),
}
println!(" home: {}", status.home_dir.display());
println!(" config: {}", status.config_file.display());
println!(" listen: {}", status.listen_addr);
if !status.providers.is_empty() {
println!(" providers: {}", status.providers.join(", "));
}
if !status.models.is_empty() {
println!(" models: {}", status.models.join(", "));
}
println!();
println!(" Commands:");
println!(" bitrouter serve Start the API server (foreground)");
println!(" bitrouter start Start as background daemon");
println!(" bitrouter stop Stop the daemon");
println!(" bitrouter status Show runtime status");
println!(" bitrouter models List routable models");
println!(" bitrouter agents List available ACP agents");
println!(" bitrouter reset Wipe config and re-run setup");
println!();
Ok(())
}
#[cfg(feature = "cli")]
async fn run_reset(paths: &RuntimePaths, no_tui: bool) -> Result<(), Box<dyn std::error::Error>> {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err("`bitrouter reset` requires an interactive terminal.".into());
}
let theme = dialoguer::theme::ColorfulTheme::default();
let confirm = dialoguer::Confirm::with_theme(&theme)
.with_prompt("This will delete your configuration and re-run setup. Continue?")
.default(false)
.interact()?;
if !confirm {
println!("Reset cancelled.");
return Ok(());
}
if paths.config_file.exists() {
std::fs::remove_file(&paths.config_file)?;
}
if paths.env_file.exists() {
std::fs::remove_file(&paths.env_file)?;
}
println!(" Configuration removed.");
println!();
let outcome = init::run_init(paths)?;
if outcome == init::InitOutcome::Configured {
return launch_after_init(paths, no_tui).await;
}
Ok(())
}
#[cfg(feature = "cli")]
async fn launch_after_init(
paths: &RuntimePaths,
no_tui: bool,
) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(feature = "tui")]
{
if !no_tui {
println!(" Launching TUI...");
println!();
let runtime: DefaultRuntime = load_or_warn_scaffold(paths);
let status = runtime.status();
let tui_config = bitrouter_tui::TuiConfig {
listen_addr: status.listen_addr,
providers: vec![],
route_count: 0,
daemon_pid: status.daemon_pid,
agents_dir: paths.agents_dir.clone(),
agent_state_file: paths.agent_state_file.clone(),
cache_dir: paths.cache_dir.clone(),
};
let mut bitrouter_config = runtime.config.clone();
let cache_file = paths.cache_dir.join("acp-registry.json");
let registry_url = bitrouter_providers::acp::registry::resolve_registry_url(
bitrouter_config.acp_registry_url.as_deref(),
);
match bitrouter_providers::acp::registry::fetch_registry(
&cache_file,
bitrouter_providers::acp::registry::DEFAULT_TTL_SECS,
®istry_url,
)
.await
{
Ok(index) => {
bitrouter_providers::acp::registry::merge_registry_into_agents(
&index,
&mut bitrouter_config.agents,
);
}
Err(e) => {
eprintln!(" note: ACP registry unavailable ({e}); using built-ins only");
}
}
bitrouter_providers::acp::state::overlay_install_state_sync(
&mut bitrouter_config.agents,
&paths.agent_state_file,
);
let launch_cwd = std::env::current_dir()?;
bitrouter_tui::run(tui_config, &bitrouter_config, launch_cwd).await?;
return Ok(());
}
}
let _ = (paths, no_tui);
println!(" Start the server:");
println!(" bitrouter serve foreground");
println!(" bitrouter start background daemon");
println!();
Ok(())
}
fn ensure_ows_passphrase(
config: &bitrouter_config::BitrouterConfig,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
let wallet = match config.wallet.as_ref() {
Some(w) => w,
None => return Ok(()),
};
if std::env::var("OWS_PASSPHRASE").is_ok() {
return Ok(());
}
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return Err(format!(
"wallet '{}' configured but OWS_PASSPHRASE is not set and stdin is not a terminal",
wallet.name,
)
.into());
}
#[cfg(feature = "cli")]
{
let passphrase =
dialoguer::Password::with_theme(&dialoguer::theme::ColorfulTheme::default())
.with_prompt(format!("OWS passphrase for wallet '{}'", wallet.name))
.allow_empty_password(true)
.interact()?;
unsafe { std::env::set_var("OWS_PASSPHRASE", passphrase) };
Ok(())
}
#[cfg(not(feature = "cli"))]
{
Err(format!(
"wallet '{}' configured but OWS_PASSPHRASE is not set; this binary was built without `cli`, so interactive prompting is unavailable",
wallet.name,
)
.into())
}
}
fn has_removed_headless_flag<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<std::ffi::OsStr>,
{
args.into_iter().any(|arg| arg.as_ref() == "--headless")
}
fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.try_init();
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
use clap::error::ErrorKind;
#[test]
fn serve_subcommand_parses_correctly() {
let cli = Cli::try_parse_from(["bitrouter", "serve"]).ok();
assert!(cli.is_some());
assert!(matches!(
cli,
Some(Cli {
command: Some(Command::Serve),
..
})
));
}
#[test]
fn headless_flag_is_rejected() {
let err = Cli::try_parse_from(["bitrouter", "--headless"]).err();
assert!(matches!(
err.as_ref().map(clap::Error::kind),
Some(ErrorKind::UnknownArgument)
));
}
#[test]
fn help_mentions_serve_but_not_headless() {
let mut command = Cli::command();
let mut help = Vec::new();
assert!(command.write_long_help(&mut help).is_ok());
let help_text = String::from_utf8(help).ok();
assert!(help_text.is_some());
assert!(matches!(help_text.as_deref(), Some(text) if text.contains("serve")));
assert!(matches!(help_text.as_deref(), Some(text) if !text.contains("--headless")));
}
#[test]
fn removed_headless_flag_is_detected_before_parse() {
assert!(has_removed_headless_flag(["bitrouter", "--headless"]));
assert!(!has_removed_headless_flag(["bitrouter", "serve"]));
}
}