use std::path::Path;
use clap::{CommandFactory, Parser};
use roboticus_core::config::{RoboticusConfig, resolve_config_path};
use roboticus_server::cli;
mod banner;
mod cli_args;
mod legacy_proxy;
mod serve;
use banner::print_banner;
use cli_args::*;
use serve::FALLBACK_CONFIG;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(unix)]
{
use std::sync::atomic::{AtomicBool, Ordering};
static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false);
unsafe {
signal_hook::low_level::register(signal_hook::consts::SIGINT, || {
if SIGINT_RECEIVED.swap(true, Ordering::SeqCst) {
std::process::exit(1);
}
eprintln!("\n Shutting down... (press Ctrl+C again to force)");
})
.ok();
}
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if SIGINT_RECEIVED.load(Ordering::SeqCst) {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
std::process::exit(0);
}
}
});
}
let parsed = Cli::parse();
cli::init_theme(
&parsed.color,
&parsed.theme,
parsed.no_draw,
parsed.nerdmode,
);
cli::init_api_key(parsed.api_key.clone());
let t = cli::theme();
eprint!("{}", t.reset());
cli::cleanup_old_binary();
let url = if parsed.url == "http://localhost:18789" || parsed.url == "http://127.0.0.1:18789" {
resolve_config_path(parsed.config.as_deref())
.and_then(|p| {
std::fs::read_to_string(p)
.inspect_err(|e| {
tracing::warn!("failed to read config for URL resolution: {e}")
})
.ok()
})
.and_then(|contents| {
RoboticusConfig::from_str(&contents)
.inspect_err(|e| {
tracing::warn!("failed to parse config for URL resolution: {e}")
})
.ok()
})
.map(|cfg| format!("http://{}:{}", cfg.server.bind, cfg.server.port))
.unwrap_or_else(|| parsed.url.clone())
} else {
parsed.url.clone()
};
let url = &url;
let config_flag = parsed.config.clone();
let result = match parsed.command {
Some(Commands::Serve { port, bind }) => {
serve::cmd_serve(config_flag.clone(), parsed.profile.clone(), port, bind).await
}
Some(Commands::Init { path }) => cmd_init(&path),
Some(Commands::Setup) => cli::cmd_setup(),
Some(Commands::Check { json }) => match resolve_config_path(config_flag.as_deref()) {
Some(p) => cmd_check(&p.to_string_lossy(), json || parsed.json),
None => {
let t = cli::theme();
print_banner(t);
eprintln!(" {} No configuration file found.", t.icon_warn());
eprintln!(" Searched: ~/.roboticus/roboticus.toml, ./roboticus.toml");
eprintln!(
" Specify a path with {}--config <path>{} or create one with {}roboticus init{}",
t.bold(),
t.reset(),
t.bold(),
t.reset()
);
eprintln!();
Err("no configuration file found".into())
}
},
Some(Commands::Version) => {
cmd_version(parsed.json);
Ok(())
}
Some(Commands::Update(subcmd)) => {
let resolved = resolve_config_path(parsed.config.as_deref());
let config_path = resolved
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| "roboticus.toml".into());
let config_path = config_path.as_str();
match subcmd {
UpdateCmd::Check {
channel,
registry_url,
} => cli::cmd_update_check(&channel, registry_url.as_deref(), config_path).await,
UpdateCmd::All {
channel,
yes,
no_restart,
registry_url,
force,
} => {
let hygiene = make_hygiene_fn();
let daemon = make_daemon_callbacks();
cli::cmd_update_all(
&channel,
yes,
no_restart,
force,
registry_url.as_deref(),
config_path,
Some(&hygiene),
Some(&daemon),
)
.await
}
UpdateCmd::Binary {
channel,
yes,
method,
} => {
let hygiene = make_hygiene_fn();
cli::cmd_update_binary(&channel, yes, &method, Some(&hygiene)).await
}
UpdateCmd::Providers { yes, registry_url } => {
let hygiene = make_hygiene_fn();
cli::cmd_update_providers(
yes,
registry_url.as_deref(),
config_path,
Some(&hygiene),
)
.await
}
UpdateCmd::Skills { yes, registry_url } => {
let hygiene = make_hygiene_fn();
cli::cmd_update_skills(
yes,
registry_url.as_deref(),
config_path,
Some(&hygiene),
)
.await
}
}
}
Some(Commands::Status) => cli::cmd_status(url, parsed.json).await,
Some(Commands::Mechanic {
repair,
json,
allow_job,
}) => cli::cmd_mechanic(url, repair, json, &allow_job).await,
Some(Commands::Defrag { fix, yes }) => {
let workspace = roboticus_core::default_workspace_path();
cli::cmd_defrag(&workspace, fix, yes, parsed.json).map_err(|e| e.into())
}
Some(Commands::Logs {
lines,
follow,
level,
}) => cli::cmd_logs(url, lines, follow, &level, parsed.json).await,
Some(Commands::Circuit(sub)) => match sub {
CircuitCmd::Status => cli::cmd_circuit_status(url, parsed.json).await,
CircuitCmd::Reset { provider } => {
cli::cmd_circuit_reset(url, provider.as_deref()).await
}
},
Some(Commands::Sessions(sub)) => match sub {
SessionsCmd::List => cli::cmd_sessions_list(url, parsed.json).await,
SessionsCmd::Show { id } => cli::cmd_session_detail(url, &id, parsed.json).await,
SessionsCmd::Create { agent_id } => cli::cmd_session_create(url, &agent_id).await,
SessionsCmd::Export { id, format, output } => {
cli::cmd_session_export(url, &id, &format, output.as_deref()).await
}
SessionsCmd::BackfillNicknames => cli::cmd_sessions_backfill_nicknames(url).await,
},
Some(Commands::Memory(sub)) => match sub {
MemoryCmd::List {
tier,
session,
limit,
} => cli::cmd_memory(url, &tier, session.as_deref(), None, limit, parsed.json).await,
MemoryCmd::Search { query, limit } => {
cli::cmd_memory(
url,
"search",
None,
Some(query.as_str()),
limit,
parsed.json,
)
.await
}
MemoryCmd::Consolidate => cli::cmd_memory_consolidate(url).await,
MemoryCmd::Reindex => cli::cmd_memory_reindex(url).await,
},
Some(Commands::Mcp(sub)) => match sub {
McpCmd::List => cli::cmd_mcp_list(url, parsed.json).await,
McpCmd::Add {
name,
stdio,
sse,
args,
} => cli::cmd_mcp_add(&name, stdio.as_deref(), sse.as_deref(), &args),
McpCmd::Remove { name } => cli::cmd_mcp_remove(&name),
McpCmd::Test { name } => cli::cmd_mcp_test(url, &name).await,
},
Some(Commands::Ingest { path, json }) => cmd_ingest(&path, json, config_flag.as_deref()),
Some(Commands::Skills(sub)) => match sub {
SkillsCmd::List => cli::cmd_skills_list(url, parsed.json).await,
SkillsCmd::Show { id } => cli::cmd_skill_detail(url, &id, parsed.json).await,
SkillsCmd::Reload => cli::cmd_skills_reload(url).await,
SkillsCmd::CatalogList { query } => {
cli::cmd_skills_catalog_list(url, query.as_deref(), parsed.json).await
}
SkillsCmd::CatalogInstall { skills, activate } => {
cli::cmd_skills_catalog_install(url, &skills, activate).await
}
SkillsCmd::CatalogActivate { skills } => {
cli::cmd_skills_catalog_activate(url, &skills).await
}
SkillsCmd::Import {
source,
no_safety_check,
accept_warnings,
} => roboticus_server::migrate::cmd_skill_import(
&source,
no_safety_check,
accept_warnings,
),
SkillsCmd::Export { output, ids } => {
roboticus_server::migrate::cmd_skill_export(&output, &ids)
}
},
Some(Commands::Schedule(sub)) => match sub {
ScheduleCmd::List => cli::cmd_schedule_list(url, parsed.json).await,
ScheduleCmd::Run { job } => cli::cmd_schedule_run(url, &job, parsed.json).await,
ScheduleCmd::Recover {
all,
names,
dry_run,
} => cli::cmd_schedule_recover(url, &names, all, dry_run, parsed.json).await,
},
Some(Commands::Metrics(sub)) => match sub {
MetricsCmd::Costs => cli::cmd_metrics(url, "costs", None, parsed.json).await,
MetricsCmd::Transactions { hours } => {
cli::cmd_metrics(url, "transactions", hours, parsed.json).await
}
MetricsCmd::Cache => cli::cmd_metrics(url, "cache", None, parsed.json).await,
},
Some(Commands::Wallet(sub)) => match sub {
WalletCmd::Show => cli::cmd_wallet(url, parsed.json).await,
WalletCmd::Address => cli::cmd_wallet_address(url, parsed.json).await,
WalletCmd::Balance => cli::cmd_wallet_balance(url, parsed.json).await,
},
Some(Commands::Auth(sub)) => match sub {
AuthCmd::Login {
provider,
client_id,
} => cmd_auth_login(&provider, client_id.as_deref()).await,
AuthCmd::Status => cmd_auth_status().await,
AuthCmd::Logout { provider } => cmd_auth_logout(&provider).await,
},
Some(Commands::Config(sub)) => match sub {
ConfigCmd::Show => cli::cmd_config(url, parsed.json).await,
ConfigCmd::Get { path } => cli::cmd_config_get(url, &path).await,
ConfigCmd::Set {
path,
value,
file,
no_apply,
} => {
cli::cmd_config_set(&path, &value, &file)?;
if !no_apply {
cli::cmd_config_apply(url, &file).await?;
}
Ok(())
}
ConfigCmd::Unset {
path,
file,
no_apply,
} => {
cli::cmd_config_unset(&path, &file)?;
if !no_apply {
cli::cmd_config_apply(url, &file).await?;
}
Ok(())
}
ConfigCmd::Lint { file } => cli::cmd_config_lint(&file),
ConfigCmd::Backup { file } => cli::cmd_config_backup(&file),
},
Some(Commands::Models(sub)) => match sub {
ModelsCmd::List => cli::cmd_models_list(url, parsed.json).await,
ModelsCmd::Scan { provider } => cli::cmd_models_scan(url, provider.as_deref()).await,
ModelsCmd::Exercise { model, iterations } => {
cli::cmd_models_exercise(url, &model, iterations).await
}
ModelsCmd::Suggest => cli::cmd_models_suggest(url).await,
ModelsCmd::Reset { model } => cli::cmd_models_reset(url, model.as_deref()).await,
ModelsCmd::Baseline => cli::cmd_models_baseline(url).await,
},
Some(Commands::Plugins(sub)) => match sub {
PluginsCmd::List => cli::cmd_plugins_list(url, parsed.json).await,
PluginsCmd::Info { name } => cli::cmd_plugin_info(url, &name, parsed.json).await,
PluginsCmd::Install { source } => cli::cmd_plugin_install(&source).await,
PluginsCmd::Uninstall { name } => cli::cmd_plugin_uninstall(&name),
PluginsCmd::Enable { name } => cli::cmd_plugin_toggle(url, &name, true).await,
PluginsCmd::Disable { name } => cli::cmd_plugin_toggle(url, &name, false).await,
PluginsCmd::Search { query } => cli::cmd_plugin_search(&query).await,
PluginsCmd::Pack { dir, output } => cli::cmd_plugin_pack(&dir, output.as_deref()),
},
Some(Commands::Apps(sub)) => match sub {
AppsCmd::Install { source } => cli::cmd_apps_install(&source),
AppsCmd::List => cli::cmd_apps_list(),
AppsCmd::Uninstall { name, delete_data } => cli::cmd_apps_uninstall(&name, delete_data),
},
Some(Commands::Profile(sub)) => match sub {
ProfileCmd::List => cli::cmd_profile_list(),
ProfileCmd::Create { name, display_name } => {
cli::cmd_profile_create(&name, display_name.as_deref())
}
ProfileCmd::Switch { name } => cli::cmd_profile_switch(&name),
ProfileCmd::Delete { name, keep_data } => cli::cmd_profile_delete(&name, keep_data),
},
Some(Commands::Agents(sub)) => match sub {
AgentsCmd::List => cli::cmd_agents_list(url, parsed.json).await,
AgentsCmd::Start { id } => cli::cmd_agent_start(url, &id).await,
AgentsCmd::Stop { id } => cli::cmd_agent_stop(url, &id).await,
},
Some(Commands::Channels(sub)) => match sub {
ChannelsCmd::List => cli::cmd_channels_status(url, parsed.json).await,
ChannelsCmd::DeadLetter { limit } => {
cli::cmd_channels_dead_letter(url, limit, parsed.json).await
}
ChannelsCmd::Replay { id } => cli::cmd_channels_replay(url, &id).await,
},
Some(Commands::Integrations(sub)) => match sub {
IntegrationsCmd::List => cli::cmd_channels_status(url, parsed.json).await,
IntegrationsCmd::Test { platform } => cli::cmd_integrations_test(url, &platform).await,
IntegrationsCmd::Health => cli::cmd_channels_status(url, parsed.json).await,
IntegrationsCmd::Connect { platform } => {
cli::cmd_integrations_connect(url, &platform).await
}
IntegrationsCmd::Disconnect { platform } => {
cli::cmd_integrations_disconnect(url, &platform).await
}
},
Some(Commands::Security(sub)) => match sub {
SecurityCmd::Audit { config } => cli::cmd_security_audit(&config, parsed.json),
},
Some(Commands::Keystore(sub)) => cmd_keystore(sub).await,
Some(Commands::Migrate(sub)) => match sub {
MigrateCmd::Import {
source,
areas,
yes,
no_safety_check,
} => {
roboticus_server::migrate::cmd_migrate_import(&source, &areas, yes, no_safety_check)
}
MigrateCmd::Export { target, areas } => {
roboticus_server::migrate::cmd_migrate_export(&target, &areas)
}
MigrateCmd::Ironclad { source, yes } => {
roboticus_server::migrate::cmd_migrate_ironclad(source.as_deref(), yes)
}
},
Some(Commands::Daemon(sub)) => match sub {
DaemonCmd::Install { config, start } => {
let binary = std::env::current_exe()?.to_string_lossy().to_string();
let abs_config = std::path::Path::new(&config)
.canonicalize()
.or_else(|_| {
let home_cfg = roboticus_core::home_dir().join(".roboticus").join(&config);
home_cfg.canonicalize()
})
.map_err(|_| {
roboticus_core::RoboticusError::Config(format!(
"config file not found: {config}"
))
})?;
let path = roboticus_server::daemon::install_daemon(
&binary,
&abs_config.to_string_lossy(),
18789,
)?;
eprintln!(" Daemon installed: {}", path.display());
let should_start =
start || prompt_yes_no("Would you like to start the daemon now?");
if should_start {
roboticus_server::daemon::start_daemon()?;
eprintln!(" Daemon started");
} else {
eprintln!(" Run `roboticus daemon start` when you're ready");
}
Ok(())
}
DaemonCmd::Start => {
if !roboticus_server::daemon::is_installed() {
eprintln!(" Daemon is not installed. Run `roboticus daemon install` first.");
std::process::exit(1);
}
roboticus_server::daemon::start_daemon()?;
eprintln!(" Daemon started");
Ok(())
}
DaemonCmd::Stop => {
roboticus_server::daemon::stop_daemon()?;
eprintln!(" Daemon stopped");
Ok(())
}
DaemonCmd::Restart => {
if !roboticus_server::daemon::is_installed() {
eprintln!(" Daemon is not installed. Run `roboticus daemon install` first.");
std::process::exit(1);
}
roboticus_server::daemon::restart_daemon()?;
eprintln!(" Daemon restarted");
Ok(())
}
DaemonCmd::Status => {
let status = roboticus_server::daemon::daemon_status()?;
eprintln!(" {status}");
Ok(())
}
DaemonCmd::Uninstall => {
roboticus_server::daemon::uninstall_daemon()?;
eprintln!(" Daemon uninstalled");
Ok(())
}
},
Some(Commands::Web) => cmd_web(config_flag.as_deref(), url),
Some(Commands::Tui {
url: tui_url,
session,
}) => roboticus_tui::run_tui(&tui_url, session.as_deref(), parsed.api_key.clone())
.await
.map_err(|e| -> Box<dyn std::error::Error> { e }),
Some(Commands::Uninstall { purge }) => cli::cmd_uninstall(
purge,
Some(&|| {
roboticus_server::daemon::uninstall_daemon()
.map_err(|e| Box::new(e) as Box<dyn std::error::Error>)
}),
),
Some(Commands::Reset { yes }) => cli::cmd_reset(yes),
Some(Commands::Completion { shell }) => cli::cmd_completion(&shell),
None => {
let mut cmd = Cli::command();
cmd.print_help()?;
eprintln!();
Ok(())
}
};
eprint!("{}", t.hard_reset());
result
}
async fn cmd_auth_login(
provider: &str,
client_id_override: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let t = cli::theme();
let (a, d, r) = (t.accent(), t.dim(), t.reset());
let client_id = client_id_override
.map(String::from)
.or_else(|| {
let path = roboticus_core::home_dir()
.join(".roboticus")
.join("roboticus.toml");
let cfg = RoboticusConfig::from_file(&path).ok()?;
cfg.providers
.get(provider)
.and_then(|p| p.oauth_client_id.clone())
})
.unwrap_or_else(|| {
std::env::var("ROBOTICUS_OAUTH_CLIENT_ID").unwrap_or_else(|_| "roboticus-cli".into())
});
let verifier = roboticus_llm::oauth::generate_code_verifier();
let challenge = roboticus_llm::oauth::compute_code_challenge(&verifier);
let state_param = roboticus_llm::oauth::generate_code_verifier();
let redirect_uri = roboticus_llm::oauth::default_redirect_uri();
let auth_url = roboticus_llm::oauth::build_authorization_url(
&client_id,
&redirect_uri,
&challenge,
&state_param,
);
eprintln!("\n {a}OAuth Login — {provider}{r}\n");
eprintln!(" {d}Opening browser for authorization...{r}");
eprintln!(" {d}If the browser doesn't open, visit:{r}");
eprintln!(" {a}{auth_url}{r}\n");
let _ = open::that(&auth_url);
eprintln!(
" {d}Waiting for callback on port {}...{r}",
roboticus_llm::oauth::callback_port()
);
let (code, returned_state) = listen_for_callback().await?;
if returned_state != state_param {
return Err("OAuth state mismatch — possible CSRF attack".into());
}
eprintln!(" {a}Authorization code received, exchanging for tokens...{r}");
let http = reqwest::Client::new();
let mut params = std::collections::HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("code", &code);
params.insert("redirect_uri", &redirect_uri);
params.insert("client_id", &client_id);
params.insert("code_verifier", &verifier);
let resp = http
.post(roboticus_llm::oauth::token_url())
.form(¶ms)
.send()
.await?;
if !resp.status().is_success() {
let body = resp
.text()
.await
.inspect_err(|e| tracing::warn!(error = %e, "CLI response parse failed"))
.unwrap_or_default();
return Err(format!("Token exchange failed: {body}").into());
}
#[derive(serde::Deserialize)]
struct TokenResp {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<i64>,
}
let token_resp: TokenResp = resp.json().await?;
let expires_at = token_resp
.expires_in
.map(|secs| chrono::Utc::now().timestamp() + secs);
let manager = roboticus_llm::OAuthManager::new()?;
manager
.store_tokens(roboticus_llm::oauth::StoredTokens {
provider: provider.to_string(),
access_token: token_resp.access_token,
refresh_token: token_resp.refresh_token,
expires_at,
client_id: None,
})
.await;
let ok = t.icon_ok();
eprintln!("\n {ok} {a}Successfully authenticated with {provider}{r}");
eprintln!(" {d}Tokens stored in the encrypted keystore{r}\n");
eprintln!(" {d}To use OAuth auth, set auth_mode = \"oauth\" in your provider config:{r}");
eprintln!(" {d} [providers.{provider}]{r}");
eprintln!(" {d} auth_mode = \"oauth\"{r}\n");
Ok(())
}
async fn listen_for_callback() -> Result<(String, String), Box<dyn std::error::Error>> {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
let addr = format!("127.0.0.1:{}", roboticus_llm::oauth::callback_port());
let listener = TcpListener::bind(&addr).await?;
let (mut stream, _) = listener.accept().await?;
let mut buf = vec![0u8; 4096];
let n = stream.read(&mut buf).await?;
let request = String::from_utf8_lossy(&buf[..n]);
let (code, state) = parse_oauth_callback_request(&request);
let html = "<html><body><h2>Authentication successful!</h2><p>You can close this window and return to the terminal.</p></body></html>";
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
html.len(),
html
);
stream.write_all(response.as_bytes()).await?;
if code.is_empty() {
return Err("No authorization code received in callback".into());
}
Ok((code, state))
}
fn parse_oauth_callback_request(request: &str) -> (String, String) {
let first_line = request.lines().next().unwrap_or("");
let path = first_line.split_whitespace().nth(1).unwrap_or("/");
let mut code = String::new();
let mut state = String::new();
if let Some(query_start) = path.find('?') {
let query = &path[query_start + 1..];
for pair in query.split('&') {
if let Some((k, v)) = pair.split_once('=') {
match k {
"code" => code = v.to_string(),
"state" => state = v.to_string(),
_ => {}
}
}
}
}
(code, state)
}
async fn cmd_auth_status() -> Result<(), Box<dyn std::error::Error>> {
let t = cli::theme();
let (a, d, r) = (t.accent(), t.dim(), t.reset());
let manager = roboticus_llm::OAuthManager::new()?;
let statuses = manager.status().await;
eprintln!("\n {a}OAuth Token Status{r}\n");
if statuses.is_empty() {
eprintln!(" {d}No OAuth tokens stored.{r}");
eprintln!(" {d}Run `roboticus auth login --provider <name>` to authenticate.{r}\n");
return Ok(());
}
for s in &statuses {
let status_icon = if s.expired {
t.icon_warn()
} else {
t.icon_ok()
};
let status_text = if s.expired { "EXPIRED" } else { "active" };
eprintln!(" {status_icon} {a}{}{r} {d}{status_text}{r}", s.provider);
if let Some(exp) = s.expires_at {
let dt = chrono::DateTime::from_timestamp(exp, 0)
.map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| "unknown".into());
eprintln!(" {d}expires: {dt}{r}");
}
let refresh = if s.has_refresh_token { "yes" } else { "no" };
eprintln!(" {d}refresh token: {refresh}{r}");
}
eprintln!();
Ok(())
}
async fn cmd_auth_logout(provider: &str) -> Result<(), Box<dyn std::error::Error>> {
let t = cli::theme();
let (a, d, r) = (t.accent(), t.dim(), t.reset());
let manager = roboticus_llm::OAuthManager::new()?;
let removed = manager.remove_tokens(provider).await;
if removed {
let ok = t.icon_ok();
eprintln!("\n {ok} {a}Removed OAuth tokens for {provider}{r}\n");
} else {
eprintln!("\n {d}No tokens found for provider '{provider}'{r}\n");
}
Ok(())
}
fn open_keystore(
password: &Option<String>,
) -> Result<roboticus_core::keystore::Keystore, Box<dyn std::error::Error>> {
let ks =
roboticus_core::keystore::Keystore::new(roboticus_core::keystore::Keystore::default_path());
match password {
Some(p) => ks.unlock(p)?,
None => ks.unlock_machine()?,
}
Ok(ks)
}
async fn cmd_keystore(sub: KeystoreCmd) -> Result<(), Box<dyn std::error::Error>> {
let t = cli::theme();
let (a, d, r) = (t.accent(), t.dim(), t.reset());
let ok = t.icon_ok();
match sub {
KeystoreCmd::Set {
key,
value,
password,
} => {
let ks = open_keystore(&password)?;
let secret = match value {
Some(v) => v,
None => dialoguer::Password::new()
.with_prompt("Secret value")
.interact()?,
};
ks.set(&key, &secret)?;
eprintln!(" {ok} {a}Stored secret '{key}'{r}");
}
KeystoreCmd::Get { key, password } => {
let ks = open_keystore(&password)?;
match ks.get(&key) {
Some(val) => println!("{val}"),
None => {
eprintln!(" {d}Key '{key}' not found{r}");
std::process::exit(1);
}
}
}
KeystoreCmd::List { password } => {
let ks = open_keystore(&password)?;
let mut keys = ks.list_keys();
keys.sort();
if keys.is_empty() {
eprintln!(" {d}Keystore is empty{r}");
} else {
for k in &keys {
eprintln!(" {a}{k}{r}");
}
eprintln!("\n {d}{} secret(s){r}", keys.len());
}
}
KeystoreCmd::Remove { key, password } => {
let ks = open_keystore(&password)?;
if ks.remove(&key)? {
eprintln!(" {ok} {a}Removed '{key}'{r}");
} else {
eprintln!(" {d}Key '{key}' not found{r}");
}
}
KeystoreCmd::Import { path, password } => {
let ks = open_keystore(&password)?;
let contents = std::fs::read_to_string(&path)?;
let entries: std::collections::HashMap<String, String> =
serde_json::from_str(&contents)?;
let count = ks.import(entries)?;
eprintln!(" {ok} {a}Imported {count} secret(s){r}");
}
KeystoreCmd::Rekey { password } => {
let ks = open_keystore(&password)?;
let new_pass = dialoguer::Password::new()
.with_prompt("New passphrase")
.with_confirmation("Confirm new passphrase", "Passphrases do not match")
.interact()?;
ks.rekey(&new_pass)?;
eprintln!(" {ok} {a}Keystore re-encrypted with new passphrase{r}");
}
}
Ok(())
}
fn cmd_init(path: &str) -> Result<(), Box<dyn std::error::Error>> {
let t = cli::theme();
print_banner(t);
let dir = std::path::Path::new(path);
let (b, r) = (t.bold(), t.reset());
let (ok, action, warn) = (t.icon_ok(), t.icon_action(), t.icon_warn());
if let Ok(current_exe) = std::env::current_exe()
&& let Some(bin_dir) = current_exe.parent()
{
let legacy = bin_dir.join(if cfg!(windows) {
"ironclad.exe"
} else {
"ironclad"
});
if legacy.exists() && std::fs::remove_file(&legacy).is_ok() {
eprintln!(" {action} Removed legacy binary: {}", legacy.display());
}
}
t.typewrite_line(
&format!(
" {b}Initializing Roboticus workspace{r} at {}\n",
dir.display()
),
4,
);
let legacy_config = dir.join("ironclad.toml");
let config_path = dir.join("roboticus.toml");
if legacy_config.exists() && !config_path.exists() {
std::fs::rename(&legacy_config, &config_path)?;
if let Ok(content) = std::fs::read_to_string(&config_path) {
let rewritten = content.replace("/.ironclad/", "/.roboticus/");
if rewritten != content {
std::fs::write(&config_path, &rewritten)?;
}
}
t.typewrite_line(
&format!(" {action} Migrated ironclad.toml → roboticus.toml (paths updated)"),
4,
);
} else if config_path.exists() {
t.typewrite_line(
&format!(" {warn} roboticus.toml already exists, skipping"),
4,
);
} else {
std::fs::write(&config_path, workspace_init_toml())?;
t.typewrite_line(&format!(" {action} Created roboticus.toml"), 4);
t.typewrite_line(
" (API key generated — see [server] api_key in roboticus.toml for REST / MCP clients)",
4,
);
}
let skills_dir = dir.join("skills");
if skills_dir.exists() {
t.typewrite_line(
&format!(" {warn} skills/ directory already exists, skipping"),
4,
);
} else {
std::fs::create_dir_all(&skills_dir)?;
let count = cli::write_starter_skills(&skills_dir)?;
t.typewrite_line(
&format!(" {action} Created skills/ with {count} starter skills"),
4,
);
}
eprintln!();
t.typewrite_line(
&format!(" {ok} Done. Run {b}roboticus serve -c roboticus.toml{r} to start."),
4,
);
eprintln!();
Ok(())
}
fn cmd_check(config_path: &str, json: bool) -> Result<(), Box<dyn std::error::Error>> {
let config = match RoboticusConfig::from_file(Path::new(config_path)) {
Ok(c) => c,
Err(e) => {
if json {
let msg = format!("{e}");
println!(
"{}",
serde_json::json!({
"valid": false,
"config_path": config_path,
"error": msg,
})
);
return Err(Box::new(e));
}
let t = cli::theme();
print_banner(t);
let (b, r) = (t.bold(), t.reset());
let warn = t.icon_warn();
let msg = format!("{e}");
if msg.contains("No such file") || msg.contains("not found") || msg.contains("NotFound")
{
eprintln!(" {warn} Config file not found: {config_path}");
eprintln!(
" Specify a path with {b}--config <path>{r} or create one with {b}roboticus init{r}"
);
eprintln!();
}
return Err(Box::new(e));
}
};
if let Err(e) = config.validate() {
if json {
println!(
"{}",
serde_json::json!({
"valid": false,
"config_path": config_path,
"error": format!("{e}"),
})
);
}
return Err(Box::new(e));
}
let mem_sum = config.memory.working_budget_pct
+ config.memory.episodic_budget_pct
+ config.memory.semantic_budget_pct
+ config.memory.procedural_budget_pct
+ config.memory.relationship_budget_pct;
let skills_dir_exists = config.skills.skills_dir.exists();
let mut warnings: Vec<String> = Vec::new();
if !skills_dir_exists {
warnings.push(format!(
"Skills dir missing: {}",
config.skills.skills_dir.display()
));
}
{
if let Some(ref tg) = config.channels.telegram
&& tg.allowed_chat_ids.is_empty()
&& config.security.deny_on_empty_allowlist
{
warnings.push(
"Telegram: allowed_chat_ids is empty (all messages will be rejected)".to_string(),
);
}
if let Some(ref dc) = config.channels.discord
&& dc.allowed_guild_ids.is_empty()
&& config.security.deny_on_empty_allowlist
{
warnings.push(
"Discord: allowed_guild_ids is empty (all messages will be rejected)".to_string(),
);
}
if let Some(ref wa) = config.channels.whatsapp
&& wa.allowed_numbers.is_empty()
&& config.security.deny_on_empty_allowlist
{
warnings.push(
"WhatsApp: allowed_numbers is empty (all messages will be rejected)".to_string(),
);
}
if let Some(ref sig) = config.channels.signal
&& sig.allowed_numbers.is_empty()
&& config.security.deny_on_empty_allowlist
{
warnings.push(
"Signal: allowed_numbers is empty (all messages will be rejected)".to_string(),
);
}
if config.channels.email.enabled
&& config.channels.email.allowed_senders.is_empty()
&& config.security.deny_on_empty_allowlist
{
warnings.push(
"Email: allowed_senders is empty (all messages will be rejected)".to_string(),
);
}
if config.channels.trusted_sender_ids.is_empty() {
warnings.push("No trusted senders — no user can reach Creator authority".to_string());
}
}
if json {
println!(
"{}",
serde_json::to_string_pretty(&serde_json::json!({
"valid": true,
"config_path": config_path,
"agent_name": config.agent.name,
"agent_id": config.agent.id,
"server": format!("{}:{}", config.server.bind, config.server.port),
"primary_model": config.models.primary,
"database": config.database.path.display().to_string(),
"memory_budget_sum_pct": mem_sum,
"treasury_per_payment_cap": config.treasury.per_payment_cap,
"treasury_minimum_reserve": config.treasury.minimum_reserve,
"skills_dir": config.skills.skills_dir.display().to_string(),
"skills_dir_exists": skills_dir_exists,
"a2a_enabled": config.a2a.enabled,
"trusted_senders": config.channels.trusted_sender_ids.len(),
"warnings": warnings,
}))?
);
return Ok(());
}
let t = cli::theme();
print_banner(t);
let (s, b, r) = (t.success(), t.bold(), t.reset());
let (ok, warn) = (t.icon_ok(), t.icon_warn());
let tw = |text: &str| t.typewrite_line(text, 4);
tw(&format!(" {b}Validating{r} {config_path}\n"));
tw(&format!(" {ok} TOML syntax valid"));
tw(&format!(" {ok} Configuration semantics valid"));
tw(&format!(
" {ok} Agent: {} ({})",
config.agent.name, config.agent.id
));
tw(&format!(
" {ok} Server: {}:{}",
config.server.bind, config.server.port
));
tw(&format!(" {ok} Primary model: {}", config.models.primary));
tw(&format!(
" {ok} Database: {}",
config.database.path.display()
));
tw(&format!(" {ok} Memory budgets sum to {mem_sum}%"));
tw(&format!(
" {ok} Treasury: cap=${:.2}/payment, reserve=${:.2}",
config.treasury.per_payment_cap, config.treasury.minimum_reserve
));
if skills_dir_exists {
tw(&format!(
" {ok} Skills dir exists: {}",
config.skills.skills_dir.display()
));
} else {
tw(&format!(
" {warn} Skills dir missing: {}",
config.skills.skills_dir.display()
));
}
if config.a2a.enabled {
tw(&format!(
" {ok} A2A enabled (rate limit: {}/peer)",
config.a2a.rate_limit_per_peer
));
}
tw(&format!(
" {ok} Security: deny_on_empty_allowlist={}, allowlist={:?}, trusted={:?}, api={:?}, threat_ceiling={:?}",
config.security.deny_on_empty_allowlist,
config.security.allowlist_authority,
config.security.trusted_authority,
config.security.api_authority,
config.security.threat_caution_ceiling,
));
tw(&format!(
" {ok} Trusted senders: {} configured",
config.channels.trusted_sender_ids.len()
));
{
let mut any_channel_warn = false;
if let Some(ref tg) = config.channels.telegram {
if tg.allowed_chat_ids.is_empty() && config.security.deny_on_empty_allowlist {
tw(&format!(
" {warn} Telegram: allowed_chat_ids is empty (all messages will be rejected)"
));
tw(" Hint: find your chat ID by messaging @userinfobot on Telegram");
any_channel_warn = true;
} else if !tg.allowed_chat_ids.is_empty() {
tw(&format!(
" {ok} Telegram: {} chat ID(s) configured",
tg.allowed_chat_ids.len()
));
}
}
if let Some(ref dc) = config.channels.discord {
if dc.allowed_guild_ids.is_empty() && config.security.deny_on_empty_allowlist {
tw(&format!(
" {warn} Discord: allowed_guild_ids is empty (all messages will be rejected)"
));
any_channel_warn = true;
} else if !dc.allowed_guild_ids.is_empty() {
tw(&format!(
" {ok} Discord: {} guild ID(s) configured",
dc.allowed_guild_ids.len()
));
}
}
if let Some(ref wa) = config.channels.whatsapp {
if wa.allowed_numbers.is_empty() && config.security.deny_on_empty_allowlist {
tw(&format!(
" {warn} WhatsApp: allowed_numbers is empty (all messages will be rejected)"
));
any_channel_warn = true;
} else if !wa.allowed_numbers.is_empty() {
tw(&format!(
" {ok} WhatsApp: {} number(s) configured",
wa.allowed_numbers.len()
));
}
}
if let Some(ref sig) = config.channels.signal {
if sig.allowed_numbers.is_empty() && config.security.deny_on_empty_allowlist {
tw(&format!(
" {warn} Signal: allowed_numbers is empty (all messages will be rejected)"
));
any_channel_warn = true;
} else if !sig.allowed_numbers.is_empty() {
tw(&format!(
" {ok} Signal: {} number(s) configured",
sig.allowed_numbers.len()
));
}
}
if config.channels.email.enabled {
if config.channels.email.allowed_senders.is_empty()
&& config.security.deny_on_empty_allowlist
{
tw(&format!(
" {warn} Email: allowed_senders is empty (all messages will be rejected)"
));
any_channel_warn = true;
} else if !config.channels.email.allowed_senders.is_empty() {
tw(&format!(
" {ok} Email: {} sender(s) configured",
config.channels.email.allowed_senders.len()
));
}
}
if config.channels.trusted_sender_ids.is_empty() && !any_channel_warn {
tw(&format!(
" {warn} No trusted senders — no user can reach Creator authority"
));
}
}
eprintln!();
tw(&format!(" {ok} {s}All checks passed.{r}"));
eprintln!();
Ok(())
}
fn cmd_version(json: bool) {
if json {
let out = serde_json::json!({
"version": env!("CARGO_PKG_VERSION"),
"edition": "Rust 2024",
"target": std::env::consts::ARCH,
"os": std::env::consts::OS,
});
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
return;
}
let t = cli::theme();
print_banner(t);
let tw = |text: &str| t.typewrite_line(text, 4);
tw(&format!(" version: {}", env!("CARGO_PKG_VERSION")));
tw(" edition: Rust 2024");
tw(&format!(" target: {}", std::env::consts::ARCH));
tw(&format!(" os: {}", std::env::consts::OS));
eprintln!();
}
fn cmd_ingest(
path: &str,
json: bool,
config_path: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
use roboticus_agent::ingest::{ingest_directory, ingest_file};
let cfg = match resolve_config_path(config_path) {
Some(p) => RoboticusConfig::from_file(&p)?,
None => RoboticusConfig::from_str(FALLBACK_CONFIG)?,
};
let db_path = cfg.database.path.to_string_lossy();
let db = roboticus_db::Database::new(&db_path)?;
let target = std::path::Path::new(path);
let results = if target.is_dir() {
ingest_directory(&db, target)?
} else if target.is_file() {
vec![ingest_file(&db, target)?]
} else {
return Err(format!("{path} does not exist or is not accessible").into());
};
if json {
let out = serde_json::to_string_pretty(&results)?;
std::io::Write::write_all(&mut std::io::stdout(), out.as_bytes())?;
std::io::Write::write_all(&mut std::io::stdout(), b"\n")?;
} else {
if results.is_empty() {
eprintln!("No supported files found.");
return Ok(());
}
for r in &results {
eprintln!(
" ✓ {} — {} ({} chunks, {} chars)",
r.file_path,
r.file_type.label(),
r.chunks_stored,
r.total_chars
);
}
let total_chunks: usize = results.iter().map(|r| r.chunks_stored).sum();
eprintln!(
"\nIngested {} file(s), {} total chunks.",
results.len(),
total_chunks
);
}
Ok(())
}
fn resolve_web_url(
config_path: Option<&str>,
cli_url: &str,
) -> Result<String, Box<dyn std::error::Error>> {
let url = if let Some(path) = config_path {
let raw = std::fs::read_to_string(path)?;
let cfg: roboticus_core::config::RoboticusConfig = toml::from_str(&raw)?;
let host = if cfg.server.bind == "0.0.0.0" {
"127.0.0.1"
} else {
&cfg.server.bind
};
format!("http://{}:{}", host, cfg.server.port)
} else {
cli_url.to_string()
};
Ok(url)
}
fn cmd_web(config_path: Option<&str>, cli_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let url = resolve_web_url(config_path, cli_url)?;
eprintln!(" Opening {url}");
open::that(&url)?;
Ok(())
}
fn workspace_init_toml() -> String {
let api_key = roboticus_core::config_utils::generate_server_api_key();
format!(
r#"# Roboticus Configuration
# See: https://roboticus.ai/docs/configuration
#
# [server] api_key — required by clients for authenticated REST/MCP routes (x-api-key or Bearer).
[agent]
name = "Roboticus"
id = "roboticus"
workspace = "~/.roboticus/workspace"
log_level = "info"
[server]
port = 18789
bind = "127.0.0.1"
api_key = "{api_key}"
[database]
path = "~/.roboticus/state.db"
[models]
primary = "ollama/qwen3:8b"
fallbacks = []
[models.routing]
mode = "metascore"
confidence_threshold = 0.9
local_first = true
[memory]
working_budget_pct = 30.0
episodic_budget_pct = 25.0
semantic_budget_pct = 20.0
procedural_budget_pct = 15.0
relationship_budget_pct = 10.0
[cache]
enabled = true
exact_match_ttl_seconds = 3600
semantic_threshold = 0.95
max_entries = 10000
[treasury]
per_payment_cap = 100.0
hourly_transfer_limit = 500.0
daily_transfer_limit = 2000.0
minimum_reserve = 5.0
daily_inference_budget = 50.0
[skills]
skills_dir = "~/.roboticus/skills"
script_timeout_seconds = 30
script_max_output_bytes = 1048576
allowed_interpreters = ["bash", "python3", "node"]
sandbox_env = true
hot_reload = true
[a2a]
enabled = true
max_message_size = 65536
rate_limit_per_peer = 10
session_timeout_seconds = 3600
require_on_chain_identity = true
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cmd_init_then_cmd_check_succeeds_for_generated_workspace() {
let dir = tempfile::tempdir().unwrap();
cmd_init(dir.path().to_str().unwrap()).expect("init should succeed");
let cfg_path = dir.path().join("roboticus.toml");
assert!(cfg_path.exists());
cmd_check(cfg_path.to_str().unwrap(), false).expect("check should succeed");
}
#[test]
fn cmd_init_is_idempotent_when_config_exists() {
let dir = tempfile::tempdir().unwrap();
let cfg_path = dir.path().join("roboticus.toml");
std::fs::write(&cfg_path, "sentinel").unwrap();
cmd_init(dir.path().to_str().unwrap()).unwrap();
let after = std::fs::read_to_string(&cfg_path).unwrap();
assert_eq!(after, "sentinel");
}
#[test]
fn workspace_init_toml_contains_expected_core_sections() {
let toml = workspace_init_toml();
assert!(toml.contains("[server]"));
assert!(toml.contains("api_key = \"rk_"));
assert!(toml.contains("[memory]"));
assert!(toml.contains("relationship_budget_pct = 10.0"));
assert!(toml.contains("[skills]"));
assert!(toml.contains("[a2a]"));
}
#[test]
fn workspace_init_toml_generates_nonempty_api_key() {
let toml = workspace_init_toml();
let api_key_line = toml
.lines()
.find(|line| line.trim_start().starts_with("api_key = "))
.expect("api key line present");
let api_key = api_key_line.split('"').nth(1).expect("quoted api key");
assert!(api_key.starts_with("rk_"));
assert!(api_key.len() > 10);
}
#[test]
fn parse_oauth_callback_request_extracts_code_and_state() {
let request = "GET /callback?code=abc123&state=xyz789 HTTP/1.1\r\nHost: localhost\r\n\r\n";
let (code, state) = parse_oauth_callback_request(request);
assert_eq!(code, "abc123");
assert_eq!(state, "xyz789");
}
#[test]
fn parse_oauth_callback_request_handles_missing_query_values() {
let request = "GET /callback HTTP/1.1\r\nHost: localhost\r\n\r\n";
let (code, state) = parse_oauth_callback_request(request);
assert!(code.is_empty());
assert!(state.is_empty());
}
#[test]
fn resolve_web_url_uses_cli_url_without_config() {
let url = resolve_web_url(None, "http://localhost:18789").unwrap();
assert_eq!(url, "http://localhost:18789");
}
#[test]
fn resolve_web_url_rewrites_zero_bind_to_loopback() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("roboticus.toml");
std::fs::write(
&config_path,
r#"
[agent]
name = "T"
id = "t"
[server]
bind = "0.0.0.0"
port = 18789
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#,
)
.unwrap();
let url = resolve_web_url(config_path.to_str(), "http://ignored").unwrap();
assert_eq!(url, "http://127.0.0.1:18789");
}
#[test]
fn resolve_web_url_uses_explicit_bind_from_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("roboticus.toml");
std::fs::write(
&config_path,
r#"
[agent]
name = "T"
id = "t"
[server]
bind = "192.168.1.8"
port = 19000
[database]
path = ":memory:"
[models]
primary = "ollama/qwen3:8b"
"#,
)
.unwrap();
let url = resolve_web_url(config_path.to_str(), "http://ignored").unwrap();
assert_eq!(url, "http://192.168.1.8:19000");
}
#[test]
#[cfg(unix)]
fn find_listeners_returns_empty_for_closed_ephemeral_port() {
let pids = serve::find_roboticus_listeners(0).unwrap();
assert!(pids.is_empty());
}
#[test]
#[cfg(unix)]
fn find_roboticus_listeners_filters_by_process_name() {
let pids = serve::find_roboticus_listeners(0);
assert!(pids.unwrap().is_empty());
}
}