#![warn(clippy::all, clippy::pedantic)] #![allow( clippy::assigning_clones, clippy::bool_to_int_with_if, clippy::case_sensitive_file_extension_comparisons, clippy::cast_possible_wrap, clippy::doc_markdown, clippy::field_reassign_with_default, clippy::float_cmp, clippy::implicit_clone, clippy::items_after_statements, clippy::map_unwrap_or, clippy::manual_let_else, clippy::missing_errors_doc, clippy::missing_panics_doc, clippy::module_name_repetitions, clippy::needless_pass_by_value, clippy::needless_raw_string_hashes, clippy::redundant_closure_for_method_calls, clippy::similar_names, clippy::single_match_else, clippy::struct_field_names, clippy::too_many_lines, clippy::uninlined_format_args, clippy::unused_self, clippy::cast_precision_loss, clippy::unnecessary_cast, clippy::unnecessary_lazy_evaluations, clippy::unnecessary_literal_bound, clippy::unnecessary_map_or, clippy::unnecessary_wraps, dead_code )] use anyhow::{bail, Result}; use clap::{Parser, Subcommand}; use dialoguer::{Input, Password}; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use tracing_subscriber::{fmt, EnvFilter}; mod agent; mod approval; mod auth; mod channels; mod rag { pub use zeroclaw::rag::*; } mod config; mod cron; mod daemon; mod doctor; mod gateway; mod hardware; mod health; mod heartbeat; mod identity; mod integrations; mod memory; mod migration; mod observability; mod onboard; mod peripherals; mod providers; mod runtime; mod security; mod service; mod skillforge; mod skills; mod tools; mod tunnel; mod util; use config::Config; // Re-export so binary's hardware/peripherals modules can use crate::HardwareCommands etc. pub use zeroclaw::{HardwareCommands, PeripheralCommands}; /// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust. #[derive(Parser, Debug)] #[command(name = "zeroclaw")] #[command(author = "theonlyhennygod")] #[command(version = "0.1.0")] #[command(about = "The fastest, smallest AI assistant.", long_about = None)] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand, Debug)] enum ServiceCommands { /// Install daemon service unit for auto-start and restart Install, /// Start daemon service Start, /// Stop daemon service Stop, /// Check daemon service status Status, /// Uninstall daemon service unit Uninstall, } #[derive(Subcommand, Debug)] enum Commands { /// Initialize your workspace and configuration Onboard { /// Run the full interactive wizard (default is quick setup) #[arg(long)] interactive: bool, /// Reconfigure channels only (fast repair flow) #[arg(long)] channels_only: bool, /// API key (used in quick mode, ignored with --interactive) #[arg(long)] api_key: Option, /// Provider name (used in quick mode, default: openrouter) #[arg(long)] provider: Option, /// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite #[arg(long)] memory: Option, }, /// Start the AI agent loop Agent { /// Single message mode (don't enter interactive mode) #[arg(short, long)] message: Option, /// Provider to use (openrouter, anthropic, openai, openai-codex) #[arg(short, long)] provider: Option, /// Model to use #[arg(long)] model: Option, /// Temperature (0.0 - 2.0) #[arg(short, long, default_value = "0.7")] temperature: f64, /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0) #[arg(long)] peripheral: Vec, }, /// Start the gateway server (webhooks, websockets) Gateway { /// Port to listen on (use 0 for random available port); defaults to config gateway.port #[arg(short, long)] port: Option, /// Host to bind to; defaults to config gateway.host #[arg(long)] host: Option, }, /// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler) Daemon { /// Port to listen on (use 0 for random available port); defaults to config gateway.port #[arg(short, long)] port: Option, /// Host to bind to; defaults to config gateway.host #[arg(long)] host: Option, }, /// Manage OS service lifecycle (launchd/systemd user service) Service { #[command(subcommand)] service_command: ServiceCommands, }, /// Run diagnostics for daemon/scheduler/channel freshness Doctor, /// Show system status (full details) Status, /// Configure and manage scheduled tasks Cron { #[command(subcommand)] cron_command: CronCommands, }, /// Manage provider model catalogs Models { #[command(subcommand)] model_command: ModelCommands, }, /// List supported AI providers Providers, /// Manage channels (telegram, discord, slack) Channel { #[command(subcommand)] channel_command: ChannelCommands, }, /// Browse 50+ integrations Integrations { #[command(subcommand)] integration_command: IntegrationCommands, }, /// Manage skills (user-defined capabilities) Skills { #[command(subcommand)] skill_command: SkillCommands, }, /// Migrate data from other agent runtimes Migrate { #[command(subcommand)] migrate_command: MigrateCommands, }, /// Manage provider subscription authentication profiles Auth { #[command(subcommand)] auth_command: AuthCommands, }, /// Discover and introspect USB hardware Hardware { #[command(subcommand)] hardware_command: zeroclaw::HardwareCommands, }, /// Manage hardware peripherals (STM32, RPi GPIO, etc.) Peripheral { #[command(subcommand)] peripheral_command: zeroclaw::PeripheralCommands, }, } #[derive(Subcommand, Debug)] enum AuthCommands { /// Login with OpenAI Codex OAuth Login { /// Provider (`openai-codex`) #[arg(long)] provider: String, /// Profile name (default: default) #[arg(long, default_value = "default")] profile: String, /// Use OAuth device-code flow #[arg(long)] device_code: bool, }, /// Complete OAuth by pasting redirect URL or auth code PasteRedirect { /// Provider (`openai-codex`) #[arg(long)] provider: String, /// Profile name (default: default) #[arg(long, default_value = "default")] profile: String, /// Full redirect URL or raw OAuth code #[arg(long)] input: Option, }, /// Paste setup token / auth token (for Anthropic subscription auth) PasteToken { /// Provider (`anthropic`) #[arg(long)] provider: String, /// Profile name (default: default) #[arg(long, default_value = "default")] profile: String, /// Token value (if omitted, read interactively) #[arg(long)] token: Option, /// Auth kind override (`authorization` or `api-key`) #[arg(long)] auth_kind: Option, }, /// Alias for `paste-token` (interactive by default) SetupToken { /// Provider (`anthropic`) #[arg(long)] provider: String, /// Profile name (default: default) #[arg(long, default_value = "default")] profile: String, }, /// Refresh OpenAI Codex access token using refresh token Refresh { /// Provider (`openai-codex`) #[arg(long)] provider: String, /// Profile name or profile id #[arg(long)] profile: Option, }, /// Remove auth profile Logout { /// Provider #[arg(long)] provider: String, /// Profile name (default: default) #[arg(long, default_value = "default")] profile: String, }, /// Set active profile for a provider Use { /// Provider #[arg(long)] provider: String, /// Profile name or full profile id #[arg(long)] profile: String, }, /// List auth profiles List, /// Show auth status with active profile and token expiry info Status, } #[derive(Subcommand, Debug)] enum MigrateCommands { /// Import memory from an `OpenClaw` workspace into this `ZeroClaw` workspace Openclaw { /// Optional path to `OpenClaw` workspace (defaults to ~/.openclaw/workspace) #[arg(long)] source: Option, /// Validate and preview migration without writing any data #[arg(long)] dry_run: bool, }, } #[derive(Subcommand, Debug)] enum CronCommands { /// List all scheduled tasks List, /// Add a new scheduled task Add { /// Cron expression expression: String, /// Optional IANA timezone (e.g. America/Los_Angeles) #[arg(long)] tz: Option, /// Command to run command: String, }, /// Add a one-shot scheduled task at an RFC3339 timestamp AddAt { /// One-shot timestamp in RFC3339 format at: String, /// Command to run command: String, }, /// Add a fixed-interval scheduled task AddEvery { /// Interval in milliseconds every_ms: u64, /// Command to run command: String, }, /// Add a one-shot delayed task (e.g. "30m", "2h", "1d") Once { /// Delay duration delay: String, /// Command to run command: String, }, /// Remove a scheduled task Remove { /// Task ID id: String, }, /// Pause a scheduled task Pause { /// Task ID id: String, }, /// Resume a paused task Resume { /// Task ID id: String, }, } #[derive(Subcommand, Debug)] enum ModelCommands { /// Refresh and cache provider models Refresh { /// Provider name (defaults to configured default provider) #[arg(long)] provider: Option, /// Force live refresh and ignore fresh cache #[arg(long)] force: bool, }, } #[derive(Subcommand, Debug)] enum ChannelCommands { /// List configured channels List, /// Start all configured channels (Telegram, Discord, Slack) Start, /// Run health checks for configured channels Doctor, /// Add a new channel Add { /// Channel type channel_type: String, /// Configuration JSON config: String, }, /// Remove a channel Remove { /// Channel name name: String, }, /// Bind a Telegram identity (username or numeric user ID) into allowlist BindTelegram { /// Telegram identity to allow (username without '@' or numeric user ID) identity: String, }, } #[derive(Subcommand, Debug)] enum SkillCommands { /// List installed skills List, /// Install a skill from a GitHub URL or local path Install { /// GitHub URL or local path source: String, }, /// Remove an installed skill Remove { /// Skill name name: String, }, } #[derive(Subcommand, Debug)] enum IntegrationCommands { /// Show details about a specific integration Info { /// Integration name name: String, }, } #[tokio::main] #[allow(clippy::too_many_lines)] async fn main() -> Result<()> { // Install default crypto provider for Rustls TLS. // This prevents the error: "could not automatically determine the process-level CryptoProvider" // when both aws-lc-rs and ring features are available (or neither is explicitly selected). if let Err(e) = rustls::crypto::ring::default_provider().install_default() { eprintln!("Warning: Failed to install default crypto provider: {e:?}"); } let cli = Cli::parse(); // Initialize logging - respects RUST_LOG env var, defaults to INFO let subscriber = fmt::Subscriber::builder() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) .finish(); tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed"); // Onboard runs quick setup by default, or the interactive wizard with --interactive. // The onboard wizard uses reqwest::blocking internally, which creates its own // Tokio runtime. To avoid "Cannot drop a runtime in a context where blocking is // not allowed", we run the wizard on a blocking thread via spawn_blocking. if let Commands::Onboard { interactive, channels_only, api_key, provider, memory, } = &cli.command { let interactive = *interactive; let channels_only = *channels_only; let api_key = api_key.clone(); let provider = provider.clone(); let memory = memory.clone(); if interactive && channels_only { bail!("Use either --interactive or --channels-only, not both"); } if channels_only && (api_key.is_some() || provider.is_some() || memory.is_some()) { bail!("--channels-only does not accept --api-key, --provider, or --memory"); } let config = tokio::task::spawn_blocking(move || { if channels_only { onboard::run_channels_repair_wizard() } else if interactive { onboard::run_wizard() } else { onboard::run_quick_setup(api_key.as_deref(), provider.as_deref(), memory.as_deref()) } }) .await??; // Auto-start channels if user said yes during wizard if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") { channels::start_channels(config).await?; } return Ok(()); } // All other commands need config loaded first let mut config = Config::load_or_init()?; config.apply_env_overrides(); match cli.command { Commands::Onboard { .. } => unreachable!(), Commands::Agent { message, provider, model, temperature, peripheral, } => agent::run(config, message, provider, model, temperature, peripheral) .await .map(|_| ()), Commands::Gateway { port, host } => { let port = port.unwrap_or(config.gateway.port); let host = host.unwrap_or_else(|| config.gateway.host.clone()); if port == 0 { info!("🚀 Starting ZeroClaw Gateway on {host} (random port)"); } else { info!("🚀 Starting ZeroClaw Gateway on {host}:{port}"); } gateway::run_gateway(&host, port, config).await } Commands::Daemon { port, host } => { let port = port.unwrap_or(config.gateway.port); let host = host.unwrap_or_else(|| config.gateway.host.clone()); if port == 0 { info!("🧠 Starting ZeroClaw Daemon on {host} (random port)"); } else { info!("🧠 Starting ZeroClaw Daemon on {host}:{port}"); } daemon::run(config, host, port).await } Commands::Status => { println!("🦀 ZeroClaw Status"); println!(); println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("Workspace: {}", config.workspace_dir.display()); println!("Config: {}", config.config_path.display()); println!(); println!( "🤖 Provider: {}", config.default_provider.as_deref().unwrap_or("openrouter") ); println!( " Model: {}", config.default_model.as_deref().unwrap_or("(default)") ); println!("📊 Observability: {}", config.observability.backend); println!("🛡️ Autonomy: {:?}", config.autonomy.level); println!("⚙️ Runtime: {}", config.runtime.kind); let effective_memory_backend = memory::effective_memory_backend_name( &config.memory.backend, Some(&config.storage.provider.config), ); println!( "💓 Heartbeat: {}", if config.heartbeat.enabled { format!("every {}min", config.heartbeat.interval_minutes) } else { "disabled".into() } ); println!( "🧠 Memory: {} (auto-save: {})", effective_memory_backend, if config.memory.auto_save { "on" } else { "off" } ); println!(); println!("Security:"); println!(" Workspace only: {}", config.autonomy.workspace_only); println!( " Allowed commands: {}", config.autonomy.allowed_commands.join(", ") ); println!( " Max actions/hour: {}", config.autonomy.max_actions_per_hour ); println!( " Max cost/day: ${:.2}", f64::from(config.autonomy.max_cost_per_day_cents) / 100.0 ); println!(); println!("Channels:"); println!(" CLI: ✅ always"); for (name, configured) in [ ("Telegram", config.channels_config.telegram.is_some()), ("Discord", config.channels_config.discord.is_some()), ("Slack", config.channels_config.slack.is_some()), ("Webhook", config.channels_config.webhook.is_some()), ] { println!( " {name:9} {}", if configured { "✅ configured" } else { "❌ not configured" } ); } println!(); println!("Peripherals:"); println!( " Enabled: {}", if config.peripherals.enabled { "yes" } else { "no" } ); println!(" Boards: {}", config.peripherals.boards.len()); Ok(()) } Commands::Cron { cron_command } => cron::handle_command(cron_command, &config), Commands::Models { model_command } => match model_command { ModelCommands::Refresh { provider, force } => { onboard::run_models_refresh(&config, provider.as_deref(), force) } }, Commands::Providers => { let providers = providers::list_providers(); let current = config .default_provider .as_deref() .unwrap_or("openrouter") .trim() .to_ascii_lowercase(); println!("Supported providers ({} total):\n", providers.len()); println!(" ID (use in config) DESCRIPTION"); println!(" ─────────────────── ───────────"); for p in &providers { let is_active = p.name.eq_ignore_ascii_case(¤t) || p.aliases .iter() .any(|alias| alias.eq_ignore_ascii_case(¤t)); let marker = if is_active { " (active)" } else { "" }; let local_tag = if p.local { " [local]" } else { "" }; let aliases = if p.aliases.is_empty() { String::new() } else { format!(" (aliases: {})", p.aliases.join(", ")) }; println!( " {:<19} {}{}{}{}", p.name, p.display_name, local_tag, marker, aliases ); } println!("\n custom: Any OpenAI-compatible endpoint"); println!(" anthropic-custom: Any Anthropic-compatible endpoint"); Ok(()) } Commands::Service { service_command } => service::handle_command(&service_command, &config), Commands::Doctor => doctor::run(&config), Commands::Channel { channel_command } => match channel_command { ChannelCommands::Start => channels::start_channels(config).await, ChannelCommands::Doctor => channels::doctor_channels(config).await, other => channels::handle_command(other, &config), }, Commands::Integrations { integration_command, } => integrations::handle_command(integration_command, &config), Commands::Skills { skill_command } => { skills::handle_command(skill_command, &config.workspace_dir) } Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await } Commands::Auth { auth_command } => handle_auth_command(auth_command, &config).await, Commands::Hardware { hardware_command } => { hardware::handle_command(hardware_command.clone(), &config) } Commands::Peripheral { peripheral_command } => { peripherals::handle_command(peripheral_command.clone(), &config) } } } #[derive(Debug, Clone, Serialize, Deserialize)] struct PendingOpenAiLogin { profile: String, code_verifier: String, state: String, created_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] struct PendingOpenAiLoginFile { profile: String, #[serde(skip_serializing_if = "Option::is_none")] code_verifier: Option, #[serde(skip_serializing_if = "Option::is_none")] encrypted_code_verifier: Option, state: String, created_at: String, } fn pending_openai_login_path(config: &Config) -> std::path::PathBuf { auth::state_dir_from_config(config).join("auth-openai-pending.json") } fn pending_openai_secret_store(config: &Config) -> security::secrets::SecretStore { security::secrets::SecretStore::new( &auth::state_dir_from_config(config), config.secrets.encrypt, ) } #[cfg(unix)] fn set_owner_only_permissions(path: &std::path::Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; Ok(()) } #[cfg(not(unix))] fn set_owner_only_permissions(_path: &std::path::Path) -> Result<()> { Ok(()) } fn save_pending_openai_login(config: &Config, pending: &PendingOpenAiLogin) -> Result<()> { let path = pending_openai_login_path(config); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let secret_store = pending_openai_secret_store(config); let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?; let persisted = PendingOpenAiLoginFile { profile: pending.profile.clone(), code_verifier: None, encrypted_code_verifier: Some(encrypted_code_verifier), state: pending.state.clone(), created_at: pending.created_at.clone(), }; let tmp = path.with_extension(format!( "tmp.{}.{}", std::process::id(), chrono::Utc::now().timestamp_nanos_opt().unwrap_or_default() )); let json = serde_json::to_vec_pretty(&persisted)?; std::fs::write(&tmp, json)?; set_owner_only_permissions(&tmp)?; std::fs::rename(tmp, &path)?; set_owner_only_permissions(&path)?; Ok(()) } fn load_pending_openai_login(config: &Config) -> Result> { let path = pending_openai_login_path(config); if !path.exists() { return Ok(None); } let bytes = std::fs::read(path)?; if bytes.is_empty() { return Ok(None); } let persisted: PendingOpenAiLoginFile = serde_json::from_slice(&bytes)?; let secret_store = pending_openai_secret_store(config); let code_verifier = if let Some(encrypted) = persisted.encrypted_code_verifier { secret_store.decrypt(&encrypted)? } else if let Some(plaintext) = persisted.code_verifier { plaintext } else { bail!("Pending OpenAI login is missing code verifier"); }; Ok(Some(PendingOpenAiLogin { profile: persisted.profile, code_verifier, state: persisted.state, created_at: persisted.created_at, })) } fn clear_pending_openai_login(config: &Config) { let path = pending_openai_login_path(config); if let Ok(file) = std::fs::OpenOptions::new().write(true).open(&path) { let _ = file.set_len(0); let _ = file.sync_all(); } let _ = std::fs::remove_file(path); } fn read_auth_input(prompt: &str) -> Result { let input = Password::new() .with_prompt(prompt) .allow_empty_password(false) .interact()?; Ok(input.trim().to_string()) } fn read_plain_input(prompt: &str) -> Result { let input: String = Input::new().with_prompt(prompt).interact_text()?; Ok(input.trim().to_string()) } fn extract_openai_account_id_for_profile(access_token: &str) -> Option { let account_id = auth::openai_oauth::extract_account_id_from_jwt(access_token); if account_id.is_none() { warn!( "Could not extract OpenAI account id from OAuth access token; \ requests may fail until re-authentication." ); } account_id } fn format_expiry(profile: &auth::profiles::AuthProfile) -> String { match profile .token_set .as_ref() .and_then(|token_set| token_set.expires_at) { Some(ts) => { let now = chrono::Utc::now(); if ts <= now { format!("expired at {}", ts.to_rfc3339()) } else { let mins = (ts - now).num_minutes(); format!("expires in {mins}m ({})", ts.to_rfc3339()) } } None => "n/a".to_string(), } } #[allow(clippy::too_many_lines)] async fn handle_auth_command(auth_command: AuthCommands, config: &Config) -> Result<()> { let auth_service = auth::AuthService::from_config(config); match auth_command { AuthCommands::Login { provider, profile, device_code, } => { let provider = auth::normalize_provider(&provider)?; if provider != "openai-codex" { bail!("`auth login` currently supports only --provider openai-codex"); } let client = reqwest::Client::new(); if device_code { match auth::openai_oauth::start_device_code_flow(&client).await { Ok(device) => { println!("OpenAI device-code login started."); println!("Visit: {}", device.verification_uri); println!("Code: {}", device.user_code); if let Some(uri_complete) = &device.verification_uri_complete { println!("Fast link: {uri_complete}"); } if let Some(message) = &device.message { println!("{message}"); } let token_set = auth::openai_oauth::poll_device_code_tokens(&client, &device).await?; let account_id = extract_openai_account_id_for_profile(&token_set.access_token); let saved = auth_service .store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); println!("Saved profile {}", saved.id); println!("Active profile for openai-codex: {}", saved.id); return Ok(()); } Err(e) => { println!( "Device-code flow unavailable: {e}. Falling back to browser/paste flow." ); } } } let pkce = auth::openai_oauth::generate_pkce_state(); let pending = PendingOpenAiLogin { profile: profile.clone(), code_verifier: pkce.code_verifier.clone(), state: pkce.state.clone(), created_at: chrono::Utc::now().to_rfc3339(), }; save_pending_openai_login(config, &pending)?; let authorize_url = auth::openai_oauth::build_authorize_url(&pkce); println!("Open this URL in your browser and authorize access:"); println!("{authorize_url}"); println!(); println!("Waiting for callback at http://localhost:1455/auth/callback ..."); let code = match auth::openai_oauth::receive_loopback_code( &pkce.state, std::time::Duration::from_secs(180), ) .await { Ok(code) => code, Err(e) => { println!("Callback capture failed: {e}"); println!( "Run `zeroclaw auth paste-redirect --provider openai-codex --profile {profile}`" ); return Ok(()); } }; let token_set = auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; let account_id = extract_openai_account_id_for_profile(&token_set.access_token); let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); println!("Saved profile {}", saved.id); println!("Active profile for openai-codex: {}", saved.id); Ok(()) } AuthCommands::PasteRedirect { provider, profile, input, } => { let provider = auth::normalize_provider(&provider)?; if provider != "openai-codex" { bail!("`auth paste-redirect` currently supports only --provider openai-codex"); } let pending = load_pending_openai_login(config)?.ok_or_else(|| { anyhow::anyhow!( "No pending OpenAI login found. Run `zeroclaw auth login --provider openai-codex` first." ) })?; if pending.profile != profile { bail!( "Pending login profile mismatch: pending={}, requested={}", pending.profile, profile ); } let redirect_input = match input { Some(value) => value, None => read_plain_input("Paste redirect URL or OAuth code")?, }; let code = auth::openai_oauth::parse_code_from_redirect( &redirect_input, Some(&pending.state), )?; let pkce = auth::openai_oauth::PkceState { code_verifier: pending.code_verifier.clone(), code_challenge: String::new(), state: pending.state.clone(), }; let client = reqwest::Client::new(); let token_set = auth::openai_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?; let account_id = extract_openai_account_id_for_profile(&token_set.access_token); let saved = auth_service.store_openai_tokens(&profile, token_set, account_id, true)?; clear_pending_openai_login(config); println!("Saved profile {}", saved.id); println!("Active profile for openai-codex: {}", saved.id); Ok(()) } AuthCommands::PasteToken { provider, profile, token, auth_kind, } => { let provider = auth::normalize_provider(&provider)?; let token = match token { Some(token) => token.trim().to_string(), None => read_auth_input("Paste token")?, }; if token.is_empty() { bail!("Token cannot be empty"); } let kind = auth::anthropic_token::detect_auth_kind(&token, auth_kind.as_deref()); let mut metadata = std::collections::HashMap::new(); metadata.insert( "auth_kind".to_string(), kind.as_metadata_value().to_string(), ); let saved = auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; println!("Saved profile {}", saved.id); println!("Active profile for {provider}: {}", saved.id); Ok(()) } AuthCommands::SetupToken { provider, profile } => { let provider = auth::normalize_provider(&provider)?; let token = read_auth_input("Paste token")?; if token.is_empty() { bail!("Token cannot be empty"); } let kind = auth::anthropic_token::detect_auth_kind(&token, Some("authorization")); let mut metadata = std::collections::HashMap::new(); metadata.insert( "auth_kind".to_string(), kind.as_metadata_value().to_string(), ); let saved = auth_service.store_provider_token(&provider, &profile, &token, metadata, true)?; println!("Saved profile {}", saved.id); println!("Active profile for {provider}: {}", saved.id); Ok(()) } AuthCommands::Refresh { provider, profile } => { let provider = auth::normalize_provider(&provider)?; if provider != "openai-codex" { bail!("`auth refresh` currently supports only --provider openai-codex"); } match auth_service .get_valid_openai_access_token(profile.as_deref()) .await? { Some(_) => { println!("OpenAI Codex token is valid (refresh completed if needed)."); Ok(()) } None => { bail!( "No OpenAI Codex auth profile found. Run `zeroclaw auth login --provider openai-codex`." ) } } } AuthCommands::Logout { provider, profile } => { let provider = auth::normalize_provider(&provider)?; let removed = auth_service.remove_profile(&provider, &profile)?; if removed { println!("Removed auth profile {provider}:{profile}"); } else { println!("Auth profile not found: {provider}:{profile}"); } Ok(()) } AuthCommands::Use { provider, profile } => { let provider = auth::normalize_provider(&provider)?; let active = auth_service.set_active_profile(&provider, &profile)?; println!("Active profile for {provider}: {active}"); Ok(()) } AuthCommands::List => { let data = auth_service.load_profiles()?; if data.profiles.is_empty() { println!("No auth profiles configured."); return Ok(()); } for (id, profile) in &data.profiles { let active = data .active_profiles .get(&profile.provider) .is_some_and(|active_id| active_id == id); let marker = if active { "*" } else { " " }; println!("{marker} {id}"); } Ok(()) } AuthCommands::Status => { let data = auth_service.load_profiles()?; if data.profiles.is_empty() { println!("No auth profiles configured."); return Ok(()); } for (id, profile) in &data.profiles { let active = data .active_profiles .get(&profile.provider) .is_some_and(|active_id| active_id == id); let marker = if active { "*" } else { " " }; println!( "{} {} kind={:?} account={} expires={}", marker, id, profile.kind, profile.account_id.as_deref().unwrap_or("unknown"), format_expiry(profile) ); } println!(); println!("Active profiles:"); for (provider, active) in &data.active_profiles { println!(" {provider}: {active}"); } Ok(()) } } } #[cfg(test)] mod tests { use super::*; use clap::CommandFactory; #[test] fn cli_definition_has_no_flag_conflicts() { Cli::command().debug_assert(); } }