use std::ffi::OsString;
use std::io::IsTerminal;
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::ValueEnum;
use crate::style::Palette;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum SpawnAgent {
Claude,
}
impl SpawnAgent {
pub fn spec(self) -> AgentSpec {
match self {
SpawnAgent::Claude => AgentSpec {
agent: self,
id: "claude",
binary: "claude",
base_url_env: "ANTHROPIC_BASE_URL",
auth_token_env: "ANTHROPIC_AUTH_TOKEN",
},
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AgentSpec {
pub agent: SpawnAgent,
pub id: &'static str,
pub binary: &'static str,
pub base_url_env: &'static str,
pub auth_token_env: &'static str,
}
const BITROUTER_API_KEY_ENV: &str = "BITROUTER_API_KEY";
const PLACEHOLDER_API_KEY: &str = "bitrouter-local";
pub struct SpawnOptions {
pub agent: SpawnAgent,
pub agent_args: Vec<String>,
pub base_url: Option<String>,
pub no_install: bool,
}
pub async fn run(cfg: &bitrouter_sdk::config::Config, opts: SpawnOptions) -> Result<()> {
let spec = opts.agent.spec();
let base_url = match &opts.base_url {
Some(explicit) => explicit.clone(),
None => derive_base_url(&cfg.server.listen),
};
let binary = ensure_agent_installed(opts.agent, opts.no_install).await?;
warn_if_daemon_unreachable(&cfg.server.listen);
let parent_auth = nonempty_env(spec.auth_token_env);
let bitrouter_key = nonempty_env(BITROUTER_API_KEY_ENV);
let env = build_child_env(&spec, &base_url, parent_auth, bitrouter_key);
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}spawn:{reset} launching {bold}{}{reset} via BitRouter ({})",
spec.id,
base_url,
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
let mut cmd = tokio::process::Command::new(&binary);
cmd.args(&opts.agent_args);
for (k, v) in &env {
cmd.env(k, v);
}
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd
.status()
.await
.with_context(|| format!("spawning agent '{}' ({})", spec.id, binary.display()))?;
std::process::exit(status.code().unwrap_or(1));
}
fn build_child_env(
spec: &AgentSpec,
base_url: &str,
parent_auth: Option<String>,
bitrouter_key: Option<String>,
) -> Vec<(&'static str, String)> {
let token = parent_auth
.or(bitrouter_key)
.unwrap_or_else(|| PLACEHOLDER_API_KEY.to_string());
vec![
(spec.base_url_env, base_url.to_string()),
(spec.auth_token_env, token),
]
}
fn nonempty_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
fn derive_base_url(listen: &str) -> String {
let (host, port) = split_listen(listen);
format!("http://{}:{}", rewrite_host(host), port)
}
const DEFAULT_PORT: &str = "4356";
fn split_listen(listen: &str) -> (&str, &str) {
if listen.starts_with('[') {
return match listen.rsplit_once("]:") {
Some((host, port)) => (&listen[..host.len() + 1], port),
None => (listen, DEFAULT_PORT),
};
}
match listen.rsplit_once(':') {
Some((host, port)) => (host, port),
None => (listen, DEFAULT_PORT),
}
}
fn rewrite_host(host: &str) -> &str {
match host {
"0.0.0.0" | "" => "127.0.0.1",
"::" | "[::]" => "[::1]",
other => other,
}
}
fn resolve_binary(name: &str) -> Option<PathBuf> {
find_on_path(name, std::env::var_os("PATH"), &extra_search_dirs())
}
fn find_on_path(name: &str, path: Option<OsString>, extra: &[PathBuf]) -> Option<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(path) = path {
dirs.extend(std::env::split_paths(&path));
}
dirs.extend(extra.iter().cloned());
for dir in dirs {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
#[cfg(windows)]
{
for ext in ["exe", "cmd", "bat"] {
let with_ext = dir.join(format!("{name}.{ext}"));
if is_executable_file(&with_ext) {
return Some(with_ext);
}
}
}
}
None
}
fn extra_search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = home_dir() {
dirs.push(home.join(".local").join("bin"));
}
dirs
}
fn is_executable_file(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn home_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(PathBuf::from)
}
}
pub(crate) async fn ensure_agent_installed(agent: SpawnAgent, no_install: bool) -> Result<PathBuf> {
let spec = agent.spec();
match resolve_binary(spec.binary) {
Some(path) => Ok(path),
None => ensure_installed(&spec, no_install).await,
}
}
async fn ensure_installed(spec: &AgentSpec, no_install: bool) -> Result<PathBuf> {
let install = InstallCommand::for_agent(spec.agent);
let may_prompt = !no_install && std::io::stdin().is_terminal();
if !may_prompt {
anyhow::bail!(
"agent '{}' is not installed (no `{}` on PATH).\n Install it with:\n {}",
spec.id,
spec.binary,
install.display(),
);
}
if !confirm_install(spec, &install)? {
anyhow::bail!("aborted — '{}' was not installed", spec.id);
}
install.run().await?;
resolve_binary(spec.binary).ok_or_else(|| {
anyhow::anyhow!(
"installed '{}' but still cannot find `{}` on PATH or in ~/.local/bin — \
open a new shell (or add the install dir to PATH) and re-run",
spec.id,
spec.binary,
)
})
}
fn confirm_install(spec: &AgentSpec, install: &InstallCommand) -> Result<bool> {
use std::io::{BufRead, Write};
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} agent `{}` is not installed on this machine.",
spec.id,
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
eprintln!(" Installer: {}", install.display());
eprint!("Proceed to install? [Y/n]: ");
std::io::stderr().flush().ok();
let stdin = std::io::stdin();
let mut line = String::new();
let n = stdin
.lock()
.read_line(&mut line)
.context("reading install confirmation from stdin")?;
if n == 0 {
eprintln!();
return Ok(false);
}
let answer = line.trim().to_ascii_lowercase();
Ok(answer.is_empty() || answer == "y" || answer == "yes")
}
fn warn_if_daemon_unreachable(listen: &str) {
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let (host, port) = split_listen(listen);
let probe = format!("{}:{}", rewrite_host(host), port);
let reachable = probe
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.map(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok())
.unwrap_or(false);
if !reachable {
let p = Palette::for_stderr();
eprintln!(
"{cyan}note:{reset} no BitRouter daemon appears to be listening on {probe} — \
start one with `bitrouter start` (the agent will fail to reach it otherwise).",
cyan = p.cyan,
reset = p.reset,
);
}
}
#[derive(Debug, Clone)]
pub struct InstallCommand {
program: &'static str,
args: Vec<String>,
human: String,
}
impl InstallCommand {
pub fn for_agent(agent: SpawnAgent) -> Self {
match agent {
SpawnAgent::Claude => Self::claude(),
}
}
#[cfg(not(windows))]
fn claude() -> Self {
let human = "curl -fsSL https://claude.ai/install.sh | bash".to_string();
Self {
program: "bash",
args: vec![
"-c".to_string(),
"curl -fsSL https://claude.ai/install.sh | bash".to_string(),
],
human,
}
}
#[cfg(windows)]
fn claude() -> Self {
let human = "irm https://claude.ai/install.ps1 | iex".to_string();
Self {
program: "powershell",
args: vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"irm https://claude.ai/install.ps1 | iex".to_string(),
],
human,
}
}
pub fn display(&self) -> &str {
&self.human
}
async fn run(&self) -> Result<()> {
let p = Palette::for_stderr();
eprintln!(
"{cyan}spawn:{reset} installing — {}",
self.human,
cyan = p.cyan,
reset = p.reset,
);
let status = tokio::process::Command::new(self.program)
.args(&self.args)
.status()
.await
.with_context(|| format!("running installer: {}", self.human))?;
if !status.success() {
anyhow::bail!("installer exited with {status}: {}", self.human);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base_url_rewrites_wildcard_bind_to_loopback() {
assert_eq!(derive_base_url("0.0.0.0:4356"), "http://127.0.0.1:4356");
assert_eq!(derive_base_url("[::]:4356"), "http://[::1]:4356");
}
#[test]
fn base_url_preserves_explicit_host() {
assert_eq!(derive_base_url("127.0.0.1:4356"), "http://127.0.0.1:4356");
assert_eq!(
derive_base_url("router.internal:8080"),
"http://router.internal:8080"
);
assert_eq!(derive_base_url("[::1]:9000"), "http://[::1]:9000");
}
#[test]
fn base_url_defaults_port_when_missing() {
assert_eq!(derive_base_url("127.0.0.1"), "http://127.0.0.1:4356");
assert_eq!(derive_base_url("[::1]"), "http://[::1]:4356");
}
#[test]
fn child_env_always_sets_base_url() {
let spec = SpawnAgent::Claude.spec();
let env = build_child_env(&spec, "http://127.0.0.1:4356", None, None);
assert!(
env.iter()
.any(|(k, v)| *k == "ANTHROPIC_BASE_URL" && v == "http://127.0.0.1:4356")
);
}
fn auth_token(env: &[(&'static str, String)]) -> Option<String> {
env.iter()
.find(|(k, _)| *k == "ANTHROPIC_AUTH_TOKEN")
.map(|(_, v)| v.clone())
}
#[test]
fn child_env_auth_token_precedence() {
let spec = SpawnAgent::Claude.spec();
let explicit = build_child_env(
&spec,
"http://x:1",
Some("user-token".into()),
Some("brk_key".into()),
);
assert_eq!(auth_token(&explicit).as_deref(), Some("user-token"));
let from_key = build_child_env(&spec, "http://x:1", None, Some("brk_key".into()));
assert_eq!(auth_token(&from_key).as_deref(), Some("brk_key"));
let placeholder = build_child_env(&spec, "http://x:1", None, None);
assert_eq!(
auth_token(&placeholder).as_deref(),
Some(PLACEHOLDER_API_KEY)
);
}
#[test]
fn child_env_never_inherits_api_key_as_auth() {
let spec = SpawnAgent::Claude.spec();
let env = build_child_env(&spec, "http://x:1", None, None);
assert!(env.iter().any(|(k, _)| *k == "ANTHROPIC_AUTH_TOKEN"));
assert!(env.iter().all(|(k, _)| *k != "ANTHROPIC_API_KEY"));
}
#[test]
fn claude_spec_uses_anthropic_env_vars() {
let spec = SpawnAgent::Claude.spec();
assert_eq!(spec.binary, "claude");
assert_eq!(spec.base_url_env, "ANTHROPIC_BASE_URL");
assert_eq!(spec.auth_token_env, "ANTHROPIC_AUTH_TOKEN");
}
#[test]
fn install_command_is_the_official_native_installer() {
let cmd = InstallCommand::for_agent(SpawnAgent::Claude);
assert!(cmd.display().contains("claude.ai/install"));
#[cfg(not(windows))]
{
assert!(cmd.display().contains("install.sh"));
assert!(cmd.display().contains("| bash"));
}
#[cfg(windows)]
{
assert!(cmd.display().contains("install.ps1"));
}
}
#[test]
fn find_on_path_locates_executable_in_listed_dir() {
let dir = std::env::temp_dir().join(format!("bitrouter-spawn-path-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("fake-agent");
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = std::env::join_paths([dir.as_os_str()]).unwrap();
let found = find_on_path("fake-agent", Some(path), &[]);
assert_eq!(found.as_deref(), Some(bin.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_on_path_returns_none_when_absent() {
let path = std::env::join_paths([std::env::temp_dir().as_os_str()]).unwrap();
assert!(find_on_path("definitely-not-a-real-binary-xyz", Some(path), &[]).is_none());
}
#[test]
fn find_on_path_falls_back_to_extra_dirs() {
let dir =
std::env::temp_dir().join(format!("bitrouter-spawn-extra-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("fake-agent");
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let found = find_on_path("fake-agent", None, std::slice::from_ref(&dir));
assert_eq!(found.as_deref(), Some(bin.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
}