zeroclaw/src/main.rs
Preventnetworkhacking 84b43ba4b2 feat(memory): add reindex command to rebuild embeddings [CDV-28]
Adds `zeroclaw memory reindex` CLI command to rebuild embeddings for all
stored memories. Use this after changing the embedding model/provider to
ensure vector search works correctly with the new embeddings.

Changes:
- Add `Reindex` variant to `MemoryCommands` enum (lib.rs, main.rs)
- Add `reindex` method to `Memory` trait with default not-supported impl
- Implement `reindex` in SqliteMemory:
  - Clears embedding_cache table
  - Iterates all memories and recomputes embeddings
  - Updates embedding column in memories table
- Add CLI handler with confirmation prompt and progress output

Usage:
  zeroclaw memory reindex        # Interactive confirmation
  zeroclaw memory reindex --yes  # Skip confirmation
  zeroclaw memory reindex --progress=false  # Hide progress

Fixes #2273
2026-02-28 20:56:03 -05:00

2634 lines
93 KiB
Rust
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![warn(clippy::all, clippy::pedantic)]
#![forbid(unsafe_code)]
#![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, Context, Result};
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use dialoguer::{Input, Password};
use serde::{Deserialize, Serialize};
use std::io::Write;
use tracing::{info, warn};
use tracing_subscriber::{fmt, EnvFilter};
const PROFILE_MISMATCH_PREFIX: &str = "Pending login profile mismatch:";
#[derive(Debug, Clone, ValueEnum)]
enum QuotaFormat {
Text,
Json,
}
fn parse_temperature(s: &str) -> std::result::Result<f64, String> {
let t: f64 = s.parse().map_err(|e| format!("{e}"))?;
if !(0.0..=2.0).contains(&t) {
return Err("temperature must be between 0.0 and 2.0".to_string());
}
Ok(t)
}
mod agent;
mod approval;
mod auth;
mod channels;
mod config;
mod coordination;
mod cost;
mod cron;
mod daemon;
mod doctor;
mod gateway;
mod goals;
mod hardware;
mod health;
mod heartbeat;
mod hooks;
mod identity;
mod integrations;
mod memory;
mod migration;
mod multimodal;
mod observability;
mod onboard;
mod peripherals;
mod plugins;
mod providers;
mod rag;
mod runtime;
mod security;
mod service;
mod skillforge;
mod skills;
mod tools;
mod tunnel;
mod update;
mod util;
use config::Config;
// Re-export so binary modules can use crate::<CommandEnum> while keeping a single source of truth.
pub use zeroclaw::{
ChannelCommands, CronCommands, HardwareCommands, IntegrationCommands, MigrateCommands,
PeripheralCommands, ServiceCommands, SkillCommands,
};
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum CompletionShell {
#[value(name = "bash")]
Bash,
#[value(name = "fish")]
Fish,
#[value(name = "zsh")]
Zsh,
#[value(name = "powershell")]
PowerShell,
#[value(name = "elvish")]
Elvish,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
enum EstopLevelArg {
#[value(name = "kill-all")]
KillAll,
#[value(name = "network-kill")]
NetworkKill,
#[value(name = "domain-block")]
DomainBlock,
#[value(name = "tool-freeze")]
ToolFreeze,
}
/// `ZeroClaw` - Zero overhead. Zero compromise. 100% Rust.
#[derive(Parser, Debug)]
#[command(name = "zeroclaw")]
#[command(author = "theonlyhennygod")]
#[command(version)]
#[command(about = "The fastest, smallest AI assistant.", long_about = None)]
struct Cli {
#[arg(long, global = true)]
config_dir: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Initialize your workspace and configuration
Onboard {
/// Run the full interactive wizard (default is quick setup)
#[arg(long)]
interactive: bool,
/// Overwrite existing config without confirmation
#[arg(long)]
force: 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<String>,
/// Provider name (used in quick mode, default: openrouter)
#[arg(long)]
provider: Option<String>,
/// Model ID override (used in quick mode)
#[arg(long)]
model: Option<String>,
/// Memory backend (sqlite, lucid, markdown, none) - used in quick mode, default: sqlite
#[arg(long)]
memory: Option<String>,
/// Disable OTP in quick setup (not recommended)
#[arg(long)]
no_totp: bool,
/// Merge-migrate data from OpenClaw during onboarding
#[arg(long)]
migrate_openclaw: bool,
/// Optional OpenClaw workspace path (defaults to ~/.openclaw/workspace)
#[arg(long)]
openclaw_source: Option<std::path::PathBuf>,
/// Optional OpenClaw config path (defaults to ~/.openclaw/openclaw.json)
#[arg(long)]
openclaw_config: Option<std::path::PathBuf>,
},
/// Start the AI agent loop
#[command(long_about = "\
Start the AI agent loop.
Launches an interactive chat session with the configured AI provider. \
Use --message for single-shot queries without entering interactive mode.
Examples:
zeroclaw agent # interactive session
zeroclaw agent -m \"Summarize today's logs\" # single message
zeroclaw agent -p anthropic --model claude-sonnet-4-20250514
zeroclaw agent --peripheral nucleo-f401re:/dev/ttyACM0
zeroclaw agent --autonomy-level full --max-actions-per-hour 100
zeroclaw agent -m \"quick task\" --memory-backend none --compact-context")]
Agent {
/// Single message mode (don't enter interactive mode)
#[arg(short, long)]
message: Option<String>,
/// Provider to use (openrouter, anthropic, openai, openai-codex)
#[arg(short, long)]
provider: Option<String>,
/// Model to use
#[arg(long)]
model: Option<String>,
/// Temperature (0.0 - 2.0)
#[arg(short, long, default_value = "0.7", value_parser = parse_temperature)]
temperature: f64,
/// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
#[arg(long)]
peripheral: Vec<String>,
/// Autonomy level (read_only, supervised, full)
#[arg(long, value_parser = clap::value_parser!(security::AutonomyLevel))]
autonomy_level: Option<security::AutonomyLevel>,
/// Maximum shell/tool actions per hour
#[arg(long)]
max_actions_per_hour: Option<u32>,
/// Maximum tool-call iterations per message
#[arg(long)]
max_tool_iterations: Option<usize>,
/// Maximum conversation history messages
#[arg(long)]
max_history_messages: Option<usize>,
/// Enable compact context mode (smaller prompts for limited models)
#[arg(long)]
compact_context: bool,
/// Memory backend (sqlite, markdown, none)
#[arg(long)]
memory_backend: Option<String>,
},
/// Start the gateway server (webhooks, websockets)
#[command(long_about = "\
Start the gateway server (webhooks, websockets).
Runs the HTTP/WebSocket gateway that accepts incoming webhook events \
and WebSocket connections. Bind address defaults to the values in \
your config file (gateway.host / gateway.port).
Examples:
zeroclaw gateway # use config defaults
zeroclaw gateway -p 8080 # listen on port 8080
zeroclaw gateway --host 0.0.0.0 # bind to all interfaces
zeroclaw gateway -p 0 # random available port
zeroclaw gateway --new-pairing # clear tokens and generate fresh pairing code")]
Gateway {
/// Port to listen on (use 0 for random available port); defaults to config gateway.port
#[arg(short, long)]
port: Option<u16>,
/// Host to bind to; defaults to config gateway.host
#[arg(long)]
host: Option<String>,
/// Clear all paired tokens and generate a fresh pairing code
#[arg(long)]
new_pairing: bool,
},
/// Start long-running autonomous runtime (gateway + channels + heartbeat + scheduler)
#[command(long_about = "\
Start the long-running autonomous daemon.
Launches the full ZeroClaw runtime: gateway server, all configured \
channels (Telegram, Discord, Slack, etc.), heartbeat monitor, and \
the cron scheduler. This is the recommended way to run ZeroClaw in \
production or as an always-on assistant.
Use 'zeroclaw service install' to register the daemon as an OS \
service (systemd/launchd) for auto-start on boot.
Examples:
zeroclaw daemon # use config defaults
zeroclaw daemon -p 9090 # gateway on port 9090
zeroclaw daemon --host 127.0.0.1 # localhost only")]
Daemon {
/// Port to listen on (use 0 for random available port); defaults to config gateway.port
#[arg(short, long)]
port: Option<u16>,
/// Host to bind to; defaults to config gateway.host
#[arg(long)]
host: Option<String>,
},
/// Manage OS service lifecycle (launchd/systemd user service)
Service {
/// Init system to use: auto (detect), systemd, or openrc
#[arg(long, default_value = "auto", value_parser = ["auto", "systemd", "openrc"])]
service_init: String,
#[command(subcommand)]
service_command: ServiceCommands,
},
/// Run diagnostics for daemon/scheduler/channel freshness
Doctor {
#[command(subcommand)]
doctor_command: Option<DoctorCommands>,
},
/// Show system status (full details)
Status,
/// Self-update ZeroClaw to the latest version
#[command(long_about = "\
Self-update ZeroClaw to the latest release from GitHub.
Downloads the appropriate pre-built binary for your platform and
replaces the current executable. Requires write permissions to
the binary location.
Examples:
zeroclaw update # Update to latest version
zeroclaw update --check # Check for updates without installing
zeroclaw update --force # Reinstall even if already up to date")]
Update {
/// Check for updates without installing
#[arg(long)]
check: bool,
/// Force update even if already at latest version
#[arg(long)]
force: bool,
},
/// Engage, inspect, and resume emergency-stop states.
///
/// Examples:
/// - `zeroclaw estop`
/// - `zeroclaw estop --level network-kill`
/// - `zeroclaw estop --level domain-block --domain "*.chase.com"`
/// - `zeroclaw estop --level tool-freeze --tool shell --tool browser`
/// - `zeroclaw estop status`
/// - `zeroclaw estop resume --network`
/// - `zeroclaw estop resume --domain "*.chase.com"`
/// - `zeroclaw estop resume --tool shell`
Estop {
#[command(subcommand)]
estop_command: Option<EstopSubcommands>,
/// Level used when engaging estop from `zeroclaw estop`.
#[arg(long, value_enum)]
level: Option<EstopLevelArg>,
/// Domain pattern(s) for `domain-block` (repeatable).
#[arg(long = "domain")]
domains: Vec<String>,
/// Tool name(s) for `tool-freeze` (repeatable).
#[arg(long = "tool")]
tools: Vec<String>,
},
/// Configure and manage scheduled tasks
#[command(long_about = "\
Configure and manage scheduled tasks.
Schedule recurring, one-shot, or interval-based tasks using cron \
expressions, RFC 3339 timestamps, durations, or fixed intervals.
Cron expressions use the standard 5-field format: \
'min hour day month weekday'. Timezones default to UTC; \
override with --tz and an IANA timezone name.
Examples:
zeroclaw cron list
zeroclaw cron add '0 9 * * 1-5' 'Good morning' --tz America/New_York
zeroclaw cron add '*/30 * * * *' 'Check system health'
zeroclaw cron add-at 2025-01-15T14:00:00Z 'Send reminder'
zeroclaw cron add-every 60000 'Ping heartbeat'
zeroclaw cron once 30m 'Run backup in 30 minutes'
zeroclaw cron pause <task-id>
zeroclaw cron update <task-id> --expression '0 8 * * *' --tz Europe/London")]
Cron {
#[command(subcommand)]
cron_command: CronCommands,
},
/// Manage provider model catalogs
Models {
#[command(subcommand)]
model_command: ModelCommands,
},
/// List supported AI providers
Providers,
/// Show provider quota and rate limit status
#[command(
name = "providers-quota",
long_about = "\
Show provider quota and rate limit status.
Displays quota remaining, rate limit resets, circuit breaker state, \
and per-profile breakdown for all configured providers. Helps diagnose \
quota exhaustion and rate limiting issues.
Examples:
zeroclaw providers-quota # text output, all providers
zeroclaw providers-quota --format json # JSON output
zeroclaw providers-quota --provider gemini # filter by provider"
)]
ProvidersQuota {
/// Filter by provider name (optional, shows all if omitted)
#[arg(long)]
provider: Option<String>,
/// Output format (text or json)
#[arg(long, value_enum, default_value_t = QuotaFormat::Text)]
format: QuotaFormat,
},
/// Manage channels (telegram, discord, slack, github)
#[command(long_about = "\
Manage communication channels.
Add, remove, list, and health-check channels that connect ZeroClaw \
to messaging platforms. Supported channel types: telegram, discord, \
slack, whatsapp, github, matrix, imessage, email.
Examples:
zeroclaw channel list
zeroclaw channel doctor
zeroclaw channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
zeroclaw channel remove my-bot
zeroclaw channel bind-telegram zeroclaw_user")]
Channel {
#[command(subcommand)]
channel_command: ChannelCommands,
},
/// Browse 50+ integrations
Integrations {
#[command(subcommand)]
integration_command: IntegrationCommands,
},
/// Manage skills (user-defined capabilities)
#[command(name = "skill", alias = "skills")]
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
#[command(long_about = "\
Discover and introspect USB hardware.
Enumerate connected USB devices, identify known development boards \
(STM32 Nucleo, Arduino, ESP32), and retrieve chip information via \
probe-rs / ST-Link.
Examples:
zeroclaw hardware discover
zeroclaw hardware introspect /dev/ttyACM0
zeroclaw hardware info --chip STM32F401RETx")]
Hardware {
#[command(subcommand)]
hardware_command: zeroclaw::HardwareCommands,
},
/// Manage hardware peripherals (STM32, RPi GPIO, etc.)
#[command(long_about = "\
Manage hardware peripherals.
Add, list, flash, and configure hardware boards that expose tools \
to the agent (GPIO, sensors, actuators). Supported boards: \
nucleo-f401re, rpi-gpio, esp32, arduino-uno.
Examples:
zeroclaw peripheral list
zeroclaw peripheral add nucleo-f401re /dev/ttyACM0
zeroclaw peripheral add rpi-gpio native
zeroclaw peripheral flash --port /dev/cu.usbmodem12345
zeroclaw peripheral flash-nucleo")]
Peripheral {
#[command(subcommand)]
peripheral_command: zeroclaw::PeripheralCommands,
},
/// Manage agent memory (list, get, stats, clear)
#[command(long_about = "\
Manage agent memory entries.
List, inspect, and clear memory entries stored by the agent. \
Supports filtering by category and session, pagination, and \
batch clearing with confirmation.
Examples:
zeroclaw memory stats
zeroclaw memory list
zeroclaw memory list --category core --limit 10
zeroclaw memory get <key>
zeroclaw memory clear --category conversation --yes")]
Memory {
#[command(subcommand)]
memory_command: MemoryCommands,
},
/// Manage configuration
#[command(long_about = "\
Manage ZeroClaw configuration.
Inspect, query, and modify configuration settings.
Examples:
zeroclaw config show # show effective config (secrets masked)
zeroclaw config get gateway.port # query a specific value by dot-path
zeroclaw config set gateway.port 8080 # update a value and save to config.toml
zeroclaw config schema # print full JSON Schema to stdout")]
Config {
#[command(subcommand)]
config_command: ConfigCommands,
},
/// Generate shell completion script to stdout
#[command(long_about = "\
Generate shell completion scripts for `zeroclaw`.
The script is printed to stdout so it can be sourced directly:
Examples:
source <(zeroclaw completions bash)
zeroclaw completions zsh > ~/.zfunc/_zeroclaw
zeroclaw completions fish > ~/.config/fish/completions/zeroclaw.fish")]
Completions {
/// Target shell
#[arg(value_enum)]
shell: CompletionShell,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommands {
/// Show the current effective configuration (secrets masked)
Show,
/// Get a specific configuration value by dot-path (e.g. "gateway.port")
Get {
/// Dot-separated config path, e.g. "security.estop.enabled"
key: String,
},
/// Set a configuration value and save to config.toml
Set {
/// Dot-separated config path, e.g. "gateway.port"
key: String,
/// New value (string, number, boolean, or JSON for objects/arrays)
value: String,
},
/// Dump the full configuration JSON Schema to stdout
Schema,
}
#[derive(Subcommand, Debug)]
enum EstopSubcommands {
/// Print current estop status.
Status,
/// Resume from an engaged estop level.
Resume {
/// Resume only network kill.
#[arg(long)]
network: bool,
/// Resume one or more blocked domain patterns.
#[arg(long = "domain")]
domains: Vec<String>,
/// Resume one or more frozen tools.
#[arg(long = "tool")]
tools: Vec<String>,
/// OTP code. If omitted and OTP is required, a prompt is shown.
#[arg(long)]
otp: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum AuthCommands {
/// Login with OAuth (OpenAI Codex or Gemini)
Login {
/// Provider (`openai-codex` or `gemini`)
#[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<String>,
},
/// 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<String>,
/// Auth kind override (`authorization` or `api-key`)
#[arg(long)]
auth_kind: Option<String>,
},
/// 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<String>,
},
/// 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 ModelCommands {
/// Refresh and cache provider models
Refresh {
/// Provider name (defaults to configured default provider)
#[arg(long)]
provider: Option<String>,
/// Refresh all providers that support live model discovery
#[arg(long)]
all: bool,
/// Force live refresh and ignore fresh cache
#[arg(long)]
force: bool,
},
/// List cached models for a provider
List {
/// Provider name (defaults to configured default provider)
#[arg(long)]
provider: Option<String>,
},
/// Set the default model in config
Set {
/// Model name to set as default
model: String,
},
/// Show current model configuration and cache status
Status,
}
#[derive(Subcommand, Debug)]
enum DoctorCommands {
/// Probe model catalogs across providers and report availability
Models {
/// Probe a specific provider only (default: all known providers)
#[arg(long)]
provider: Option<String>,
/// Prefer cached catalogs when available (skip forced live refresh)
#[arg(long)]
use_cache: bool,
},
/// Query runtime trace events (tool diagnostics and model replies)
Traces {
/// Show a specific trace event by id
#[arg(long)]
id: Option<String>,
/// Filter list output by event type
#[arg(long)]
event: Option<String>,
/// Case-insensitive text match across message/payload
#[arg(long)]
contains: Option<String>,
/// Maximum number of events to display
#[arg(long, default_value = "20")]
limit: usize,
},
}
#[derive(Subcommand, Debug)]
enum MemoryCommands {
/// List memory entries with optional filters
List {
#[arg(long)]
category: Option<String>,
#[arg(long)]
session: Option<String>,
#[arg(long, default_value = "50")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
},
/// Get a specific memory entry by key
Get { key: String },
/// Show memory backend statistics and health
Stats,
/// Clear memories by category, by key, or clear all
Clear {
/// Delete a single entry by key (supports prefix match)
#[arg(long)]
key: Option<String>,
#[arg(long)]
category: Option<String>,
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
},
/// Rebuild embeddings for all memories (use after changing embedding model)
Reindex {
/// Skip confirmation prompt
#[arg(long)]
yes: bool,
/// Show progress during reindex
#[arg(long, default_value = "true")]
progress: bool,
},
}
#[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();
if let Some(config_dir) = &cli.config_dir {
if config_dir.trim().is_empty() {
bail!("--config-dir cannot be empty");
}
std::env::set_var("ZEROCLAW_CONFIG_DIR", config_dir);
}
// Completions must remain stdout-only and should not load config or initialize logging.
// This avoids warnings/log lines corrupting sourced completion scripts.
if let Commands::Completions { shell } = &cli.command {
let mut stdout = std::io::stdout().lock();
write_shell_completion(*shell, &mut stdout)?;
return Ok(());
}
// Initialize logging - respects RUST_LOG env var, defaults to INFO
let subscriber = fmt::Subscriber::builder()
.with_timer(tracing_subscriber::fmt::time::ChronoLocal::rfc_3339())
.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,
force,
channels_only,
api_key,
provider,
model,
memory,
no_totp,
migrate_openclaw,
openclaw_source,
openclaw_config,
} = &cli.command
{
let interactive = *interactive;
let force = *force;
let channels_only = *channels_only;
let api_key = api_key.clone();
let provider = provider.clone();
let model = model.clone();
let memory = memory.clone();
let no_totp = *no_totp;
let migrate_openclaw = *migrate_openclaw;
let openclaw_source = openclaw_source.clone();
let openclaw_config = openclaw_config.clone();
let openclaw_migration_enabled =
migrate_openclaw || openclaw_source.is_some() || openclaw_config.is_some();
if interactive && channels_only {
bail!("Use either --interactive or --channels-only, not both");
}
if channels_only
&& (api_key.is_some()
|| provider.is_some()
|| model.is_some()
|| memory.is_some()
|| no_totp
|| migrate_openclaw
|| openclaw_source.is_some()
|| openclaw_config.is_some())
{
bail!(
"--channels-only does not accept --api-key, --provider, --model, --memory, --no-totp, or OpenClaw migration flags"
);
}
if channels_only && force {
bail!("--channels-only does not accept --force");
}
let config = if channels_only {
Box::pin(onboard::run_channels_repair_wizard()).await
} else if interactive {
Box::pin(onboard::run_wizard_with_migration(
force,
onboard::OpenClawOnboardMigrationOptions {
enabled: openclaw_migration_enabled,
source_workspace: openclaw_source,
source_config: openclaw_config,
},
))
.await
} else {
onboard::run_quick_setup_with_migration(
api_key.as_deref(),
provider.as_deref(),
model.as_deref(),
memory.as_deref(),
force,
no_totp,
onboard::OpenClawOnboardMigrationOptions {
enabled: openclaw_migration_enabled,
source_workspace: openclaw_source,
source_config: openclaw_config,
},
)
.await
}?;
// Auto-start channels if user said yes during wizard
if std::env::var("ZEROCLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
Box::pin(channels::start_channels(config)).await?;
}
return Ok(());
}
// All other commands need config loaded first
let mut config = Config::load_or_init().await?;
config.apply_env_overrides();
observability::runtime_trace::init_from_config(&config.observability, &config.workspace_dir);
if config.security.otp.enabled {
let config_dir = config
.config_path
.parent()
.context("Config path must have a parent directory")?;
let store = security::SecretStore::new(config_dir, config.secrets.encrypt);
let (_validator, enrollment_uri) =
security::OtpValidator::from_config(&config.security.otp, config_dir, &store)?;
if let Some(uri) = enrollment_uri {
println!("Initialized OTP secret for ZeroClaw.");
println!("Enrollment URI: {uri}");
}
}
match cli.command {
Commands::Onboard { .. } | Commands::Completions { .. } => unreachable!(),
Commands::Agent {
message,
provider,
model,
temperature,
peripheral,
autonomy_level,
max_actions_per_hour,
max_tool_iterations,
max_history_messages,
compact_context,
memory_backend,
} => {
if let Some(level) = autonomy_level {
config.autonomy.level = level;
}
if let Some(n) = max_actions_per_hour {
config.autonomy.max_actions_per_hour = n;
}
if let Some(n) = max_tool_iterations {
config.agent.max_tool_iterations = n;
}
if let Some(n) = max_history_messages {
config.agent.max_history_messages = n;
}
if compact_context {
config.agent.compact_context = true;
}
if let Some(ref backend) = memory_backend {
config.memory.backend = backend.clone();
}
// interactive=true only when no --message flag (real REPL session).
// Single-shot mode (-m) runs non-interactively: no TTY approval prompt,
// so tools are not denied by a stdin read returning EOF.
let interactive = message.is_none();
Box::pin(agent::run(
config,
message,
provider,
model,
temperature,
peripheral,
interactive,
None,
))
.await
.map(|_| ())
}
Commands::Gateway {
port,
host,
new_pairing,
} => {
if new_pairing {
// Persist token reset from raw config so env-derived overrides are not written to disk.
let mut persisted_config = Config::load_or_init().await?;
persisted_config.gateway.paired_tokens.clear();
persisted_config.save().await?;
config.gateway.paired_tokens.clear();
info!("🔐 Cleared paired tokens — a fresh pairing code will be generated");
}
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!(
"🧾 Trace storage: {} ({})",
config.observability.runtime_trace_mode, config.observability.runtime_trace_path
);
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 roots: {}",
if config.autonomy.allowed_roots.is_empty() {
"(none)".to_string()
} else {
config.autonomy.allowed_roots.join(", ")
}
);
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!(" OTP enabled: {}", config.security.otp.enabled);
println!(" E-stop enabled: {}", config.security.estop.enabled);
println!();
println!("Channels:");
println!(" CLI: ✅ always");
for (channel, configured) in config.channels_config.channels() {
println!(
" {:9} {}",
channel.name(),
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::Update { check, force } => {
update::self_update(force, check).await?;
Ok(())
}
Commands::Estop {
estop_command,
level,
domains,
tools,
} => handle_estop_command(&config, estop_command, level, domains, tools),
Commands::Cron { cron_command } => cron::handle_command(cron_command, &config),
Commands::Models { model_command } => match model_command {
ModelCommands::Refresh {
provider,
all,
force,
} => {
if all {
if provider.is_some() {
bail!("`models refresh --all` cannot be combined with --provider");
}
onboard::run_models_refresh_all(&config, force).await
} else {
onboard::run_models_refresh(&config, provider.as_deref(), force).await
}
}
ModelCommands::List { provider } => {
onboard::run_models_list(&config, provider.as_deref()).await
}
ModelCommands::Set { model } => onboard::run_models_set(&config, &model).await,
ModelCommands::Status => onboard::run_models_status(&config).await,
},
Commands::ProvidersQuota { provider, format } => {
let format_str = match format {
QuotaFormat::Text => "text",
QuotaFormat::Json => "json",
};
providers::quota_cli::run(&config, provider.as_deref(), format_str).await
}
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(&current)
|| p.aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(&current));
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:<URL> Any OpenAI-compatible endpoint");
println!(" anthropic-custom:<URL> Any Anthropic-compatible endpoint");
Ok(())
}
Commands::Service {
service_command,
service_init,
} => {
let init_system = service_init.parse()?;
service::handle_command(&service_command, &config, init_system)
}
Commands::Doctor { doctor_command } => match doctor_command {
Some(DoctorCommands::Models {
provider,
use_cache,
}) => doctor::run_models(&config, provider.as_deref(), use_cache).await,
Some(DoctorCommands::Traces {
id,
event,
contains,
limit,
}) => doctor::run_traces(
&config,
id.as_deref(),
event.as_deref(),
contains.as_deref(),
limit,
),
None => doctor::run(&config),
},
Commands::Channel { channel_command } => match channel_command {
ChannelCommands::Start => Box::pin(channels::start_channels(config)).await,
ChannelCommands::Doctor => Box::pin(channels::doctor_channels(config)).await,
other => channels::handle_command(other, &config).await,
},
Commands::Integrations {
integration_command,
} => integrations::handle_command(integration_command, &config),
Commands::Skills { skill_command } => skills::handle_command(skill_command, &config),
Commands::Migrate { migrate_command } => {
migration::handle_command(migrate_command, &config).await
}
Commands::Memory { memory_command } => {
memory::cli::handle_command(memory_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).await
}
Commands::Config { config_command } => match config_command {
ConfigCommands::Show => {
let mut json =
serde_json::to_value(&config).context("Failed to serialize config")?;
redact_config_secrets(&mut json);
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
ConfigCommands::Get { key } => {
let mut json =
serde_json::to_value(&config).context("Failed to serialize config")?;
redact_config_secrets(&mut json);
let mut current = &json;
for segment in key.split('.') {
current = current
.get(segment)
.with_context(|| format!("Config path not found: {key}"))?;
}
match current {
serde_json::Value::String(s) => println!("{s}"),
serde_json::Value::Bool(b) => println!("{b}"),
serde_json::Value::Number(n) => println!("{n}"),
serde_json::Value::Null => println!("null"),
_ => println!("{}", serde_json::to_string_pretty(current)?),
}
Ok(())
}
ConfigCommands::Set { key, value } => {
let mut json =
serde_json::to_value(&config).context("Failed to serialize config")?;
// Parse the new value: try bool, then integer, then float, then JSON, then string
let new_value = if value == "true" {
serde_json::Value::Bool(true)
} else if value == "false" {
serde_json::Value::Bool(false)
} else if value == "null" {
serde_json::Value::Null
} else if let Ok(n) = value.parse::<i64>() {
serde_json::json!(n)
} else if let Ok(n) = value.parse::<f64>() {
serde_json::json!(n)
} else if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&value) {
// JSON object/array (e.g. '["a","b"]' or '{"key":"val"}')
parsed
} else {
serde_json::Value::String(value.clone())
};
// Navigate to the parent and set the leaf
let segments: Vec<&str> = key.split('.').collect();
if segments.is_empty() {
bail!("Config key cannot be empty");
}
let (parents, leaf) = segments.split_at(segments.len() - 1);
let mut target = &mut json;
for segment in parents {
target = target
.get_mut(*segment)
.with_context(|| format!("Config path not found: {key}"))?;
}
let leaf_key = leaf[0];
if target.get(leaf_key).is_none() {
bail!("Config path not found: {key}");
}
target[leaf_key] = new_value.clone();
// Deserialize back to Config and save.
// Preserve runtime-only fields lost during JSON round-trip (#[serde(skip)]).
let config_path = config.config_path.clone();
let workspace_dir = config.workspace_dir.clone();
config = serde_json::from_value(json)
.context("Invalid value for this config key — type mismatch")?;
config.config_path = config_path;
config.workspace_dir = workspace_dir;
config.save().await?;
// Show the saved value
let display = match &new_value {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
println!("Set {key} = {display}");
Ok(())
}
ConfigCommands::Schema => {
let schema = schemars::schema_for!(config::Config);
println!(
"{}",
serde_json::to_string_pretty(&schema).expect("failed to serialize JSON Schema")
);
Ok(())
}
},
}
}
/// Keys whose values are masked in `config show` / `config get` output.
const REDACTED_CONFIG_KEYS: &[&str] = &[
"api_key",
"api_keys",
"bot_token",
"paired_tokens",
"db_url",
"http_proxy",
"https_proxy",
"all_proxy",
"secret_key",
"webhook_secret",
];
fn redact_config_secrets(value: &mut serde_json::Value) {
match value {
serde_json::Value::Object(map) => {
for (k, v) in map.iter_mut() {
if REDACTED_CONFIG_KEYS.contains(&k.as_str()) {
match v {
serde_json::Value::String(s) if !s.is_empty() => {
*v = serde_json::Value::String("***REDACTED***".to_string());
}
serde_json::Value::Array(arr) if !arr.is_empty() => {
*v = serde_json::json!(["***REDACTED***"]);
}
_ => {}
}
} else {
redact_config_secrets(v);
}
}
}
serde_json::Value::Array(arr) => {
for item in arr.iter_mut() {
redact_config_secrets(item);
}
}
_ => {}
}
}
fn handle_estop_command(
config: &Config,
estop_command: Option<EstopSubcommands>,
level: Option<EstopLevelArg>,
domains: Vec<String>,
tools: Vec<String>,
) -> Result<()> {
if !config.security.estop.enabled {
bail!("Emergency stop is disabled. Enable [security.estop].enabled = true in config.toml");
}
let config_dir = config
.config_path
.parent()
.context("Config path must have a parent directory")?;
let mut manager = security::EstopManager::load(&config.security.estop, config_dir)?;
match estop_command {
Some(EstopSubcommands::Status) => {
print_estop_status(&manager.status());
Ok(())
}
Some(EstopSubcommands::Resume {
network,
domains,
tools,
otp,
}) => {
let selector = build_resume_selector(network, domains, tools)?;
let mut otp_code = otp;
let otp_validator = if config.security.estop.require_otp_to_resume {
if !config.security.otp.enabled {
bail!(
"security.estop.require_otp_to_resume=true but security.otp.enabled=false"
);
}
if otp_code.is_none() {
let entered = Password::new()
.with_prompt("Enter OTP code")
.allow_empty_password(false)
.interact()?;
otp_code = Some(entered);
}
let store = security::SecretStore::new(config_dir, config.secrets.encrypt);
let (validator, enrollment_uri) =
security::OtpValidator::from_config(&config.security.otp, config_dir, &store)?;
if let Some(uri) = enrollment_uri {
println!("Initialized OTP secret for ZeroClaw.");
println!("Enrollment URI: {uri}");
}
Some(validator)
} else {
None
};
manager.resume(selector, otp_code.as_deref(), otp_validator.as_ref())?;
println!("Estop resume completed.");
print_estop_status(&manager.status());
Ok(())
}
None => {
let engage_level = build_engage_level(level, domains, tools)?;
manager.engage(engage_level)?;
println!("Estop engaged.");
print_estop_status(&manager.status());
Ok(())
}
}
}
fn build_engage_level(
level: Option<EstopLevelArg>,
domains: Vec<String>,
tools: Vec<String>,
) -> Result<security::EstopLevel> {
let requested = level.unwrap_or(EstopLevelArg::KillAll);
match requested {
EstopLevelArg::KillAll => {
if !domains.is_empty() || !tools.is_empty() {
bail!("--domain/--tool are only valid with --level domain-block/tool-freeze");
}
Ok(security::EstopLevel::KillAll)
}
EstopLevelArg::NetworkKill => {
if !domains.is_empty() || !tools.is_empty() {
bail!("--domain/--tool are not valid with --level network-kill");
}
Ok(security::EstopLevel::NetworkKill)
}
EstopLevelArg::DomainBlock => {
if domains.is_empty() {
bail!("--level domain-block requires at least one --domain");
}
if !tools.is_empty() {
bail!("--tool is not valid with --level domain-block");
}
Ok(security::EstopLevel::DomainBlock(domains))
}
EstopLevelArg::ToolFreeze => {
if tools.is_empty() {
bail!("--level tool-freeze requires at least one --tool");
}
if !domains.is_empty() {
bail!("--domain is not valid with --level tool-freeze");
}
Ok(security::EstopLevel::ToolFreeze(tools))
}
}
}
fn build_resume_selector(
network: bool,
domains: Vec<String>,
tools: Vec<String>,
) -> Result<security::ResumeSelector> {
let selected =
usize::from(network) + usize::from(!domains.is_empty()) + usize::from(!tools.is_empty());
if selected > 1 {
bail!("Use only one of --network, --domain, or --tool for estop resume");
}
if network {
return Ok(security::ResumeSelector::Network);
}
if !domains.is_empty() {
return Ok(security::ResumeSelector::Domains(domains));
}
if !tools.is_empty() {
return Ok(security::ResumeSelector::Tools(tools));
}
Ok(security::ResumeSelector::KillAll)
}
fn print_estop_status(state: &security::EstopState) {
println!("Estop status:");
println!(
" engaged: {}",
if state.is_engaged() { "yes" } else { "no" }
);
println!(
" kill_all: {}",
if state.kill_all { "active" } else { "inactive" }
);
println!(
" network_kill: {}",
if state.network_kill {
"active"
} else {
"inactive"
}
);
if state.blocked_domains.is_empty() {
println!(" domain_blocks: (none)");
} else {
println!(" domain_blocks: {}", state.blocked_domains.join(", "));
}
if state.frozen_tools.is_empty() {
println!(" tool_freeze: (none)");
} else {
println!(" tool_freeze: {}", state.frozen_tools.join(", "));
}
if let Some(updated_at) = &state.updated_at {
println!(" updated_at: {updated_at}");
}
}
fn write_shell_completion<W: Write>(shell: CompletionShell, writer: &mut W) -> Result<()> {
use clap_complete::generate;
use clap_complete::shells;
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
match shell {
CompletionShell::Bash => generate(shells::Bash, &mut cmd, bin_name.clone(), writer),
CompletionShell::Fish => generate(shells::Fish, &mut cmd, bin_name.clone(), writer),
CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, bin_name.clone(), writer),
CompletionShell::PowerShell => {
generate(shells::PowerShell, &mut cmd, bin_name.clone(), writer);
}
CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, bin_name, writer),
}
writer.flush()?;
Ok(())
}
// ─── Generic Pending OAuth Login ────────────────────────────────────────────
/// Generic pending OAuth login state, shared across providers.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingOAuthLogin {
provider: String,
profile: String,
code_verifier: String,
state: String,
created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct PendingOAuthLoginFile {
#[serde(default)]
provider: Option<String>,
profile: String,
#[serde(skip_serializing_if = "Option::is_none")]
code_verifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted_code_verifier: Option<String>,
state: String,
created_at: String,
}
fn pending_oauth_login_path(config: &Config, provider: &str) -> std::path::PathBuf {
let filename = format!("auth-{}-pending.json", provider);
auth::state_dir_from_config(config).join(filename)
}
fn pending_oauth_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(())
}
/// Check if a pending OAuth login is stale (older than 24 hours).
fn is_pending_login_stale(pending: &PendingOAuthLogin) -> bool {
if let Ok(created) = chrono::DateTime::parse_from_rfc3339(&pending.created_at) {
let age = chrono::Utc::now().signed_duration_since(created);
age > chrono::Duration::hours(24)
} else {
// If we can't parse the timestamp, consider it stale
true
}
}
fn save_pending_oauth_login(config: &Config, pending: &PendingOAuthLogin) -> Result<()> {
let path = pending_oauth_login_path(config, &pending.provider);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let secret_store = pending_oauth_secret_store(config);
let encrypted_code_verifier = secret_store.encrypt(&pending.code_verifier)?;
let persisted = PendingOAuthLoginFile {
provider: Some(pending.provider.clone()),
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_oauth_login(config: &Config, provider: &str) -> Result<Option<PendingOAuthLogin>> {
let path = pending_oauth_login_path(config, provider);
if !path.exists() {
return Ok(None);
}
let bytes = std::fs::read(&path)?;
if bytes.is_empty() {
return Ok(None);
}
let persisted: PendingOAuthLoginFile = serde_json::from_slice(&bytes)?;
let secret_store = pending_oauth_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 {} login is missing code verifier", provider);
};
let pending = PendingOAuthLogin {
provider: persisted.provider.unwrap_or_else(|| provider.to_string()),
profile: persisted.profile,
code_verifier,
state: persisted.state,
created_at: persisted.created_at,
};
// Auto-cleanup if stale (older than 24 hours)
if is_pending_login_stale(&pending) {
println!(" Removing stale pending auth file (older than 24h)");
let _ = std::fs::remove_file(&path);
return Ok(None);
}
Ok(Some(pending))
}
fn clear_pending_oauth_login(config: &Config, provider: &str) {
let path = pending_oauth_login_path(config, provider);
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<String> {
let input = Password::new()
.with_prompt(prompt)
.allow_empty_password(false)
.interact()?;
Ok(input.trim().to_string())
}
fn read_plain_input(prompt: &str) -> Result<String> {
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<String> {
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)?;
let client = reqwest::Client::new();
match provider.as_str() {
"gemini" => {
// Gemini OAuth flow
if device_code {
match auth::gemini_oauth::start_device_code_flow(&client).await {
Ok(device) => {
println!("Google/Gemini 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}");
}
let token_set =
auth::gemini_oauth::poll_device_code_tokens(&client, &device)
.await?;
let account_id = token_set.id_token.as_deref().and_then(
auth::gemini_oauth::extract_account_email_from_id_token,
);
auth_service
.store_gemini_tokens(&profile, token_set, account_id, true)
.await?;
println!("Saved profile {profile}");
println!("Active profile for gemini: {profile}");
return Ok(());
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("403")
|| err_msg.contains("Forbidden")
|| err_msg.contains("Cloudflare")
{
println!(
" Device-code flow is blocked by Cloudflare protection."
);
println!(" This is normal for server environments.");
println!(" Switching to browser authorization flow...");
} else if err_msg.contains("invalid_client") {
println!("⚠️ OAuth client configuration error: {}", err_msg);
println!(" Check your GEMINI_OAUTH_CLIENT_ID and GEMINI_OAUTH_CLIENT_SECRET");
} else {
println!(" Device-code flow unavailable: {}", err_msg);
println!(" Falling back to browser flow.");
}
}
}
}
let pkce = auth::gemini_oauth::generate_pkce_state();
let authorize_url = auth::gemini_oauth::build_authorize_url(&pkce)?;
// Save pending login for paste-redirect fallback
let pending = PendingOAuthLogin {
provider: "gemini".to_string(),
profile: profile.clone(),
code_verifier: pkce.code_verifier.clone(),
state: pkce.state.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
};
save_pending_oauth_login(config, &pending)?;
println!("Open this URL in your browser and authorize access:");
println!("{authorize_url}");
println!();
let code = match auth::gemini_oauth::receive_loopback_code(
&pkce.state,
std::time::Duration::from_secs(180),
)
.await
{
Ok(code) => {
clear_pending_oauth_login(config, "gemini");
code
}
Err(e) => {
println!("Callback capture failed: {e}");
println!(
"Run `zeroclaw auth paste-redirect --provider gemini --profile {profile}`"
);
return Ok(());
}
};
let token_set =
auth::gemini_oauth::exchange_code_for_tokens(&client, &code, &pkce).await?;
let account_id = token_set
.id_token
.as_deref()
.and_then(auth::gemini_oauth::extract_account_email_from_id_token);
auth_service
.store_gemini_tokens(&profile, token_set, account_id, true)
.await?;
println!("Saved profile {profile}");
println!("Active profile for gemini: {profile}");
Ok(())
}
"openai-codex" => {
// OpenAI Codex OAuth flow
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);
auth_service
.store_openai_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_oauth_login(config, "openai");
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
return Ok(());
}
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("403")
|| err_msg.contains("Forbidden")
|| err_msg.contains("Cloudflare")
{
println!(
" Device-code flow is blocked by Cloudflare protection."
);
println!(" This is normal for server environments.");
println!(" Switching to browser authorization flow...");
} else {
println!(" Device-code flow unavailable: {}", err_msg);
println!(" Falling back to browser flow.");
}
}
}
}
let pkce = auth::openai_oauth::generate_pkce_state();
let pending = PendingOAuthLogin {
provider: "openai".to_string(),
profile: profile.clone(),
code_verifier: pkce.code_verifier.clone(),
state: pkce.state.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
};
save_pending_oauth_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);
auth_service
.store_openai_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_oauth_login(config, "openai");
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
Ok(())
}
_ => {
bail!(
"`auth login` supports --provider openai-codex or gemini, got: {provider}"
);
}
}
}
AuthCommands::PasteRedirect {
provider,
profile,
input,
} => {
let provider = auth::normalize_provider(&provider)?;
match provider.as_str() {
"openai-codex" => {
let result = async {
let pending =
load_pending_oauth_login(config, "openai")?.ok_or_else(|| {
anyhow::anyhow!(
"No pending OpenAI login found.\n\n\
💡 Please start the login flow first:\n \
zeroclaw auth login --provider openai-codex --profile {}\n\n\
Then paste the callback URL or code here.",
profile
)
})?;
if pending.profile != profile {
bail!(
"{} pending={}, requested={}",
PROFILE_MISMATCH_PREFIX,
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);
auth_service
.store_openai_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_oauth_login(config, "openai");
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
Ok(())
}
.await;
if let Err(e) = result {
// Cleanup pending file on error
if e.to_string().starts_with(PROFILE_MISMATCH_PREFIX) {
clear_pending_oauth_login(config, "openai");
eprintln!("{}", e);
eprintln!(
"\n💡 Tip: A previous login attempt was for a different profile."
);
eprintln!(" The pending auth file has been cleared.");
eprintln!(" Please start fresh with:");
eprintln!(
" zeroclaw auth login --provider openai-codex --profile {}",
profile
);
std::process::exit(1);
}
return Err(e);
}
}
"gemini" => {
let result = async {
let pending =
load_pending_oauth_login(config, "gemini")?.ok_or_else(|| {
anyhow::anyhow!(
"No pending Gemini login found.\n\n\
💡 Please start the login flow first:\n \
zeroclaw auth login --provider gemini --profile {}\n\n\
Then paste the callback URL or code here.",
profile
)
})?;
if pending.profile != profile {
bail!(
"{} pending={}, requested={}",
PROFILE_MISMATCH_PREFIX,
pending.profile,
profile
);
}
let redirect_input = match input {
Some(value) => value,
None => read_plain_input("Paste redirect URL or OAuth code")?,
};
let code = auth::gemini_oauth::parse_code_from_redirect(
&redirect_input,
Some(&pending.state),
)?;
let pkce = auth::gemini_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::gemini_oauth::exchange_code_for_tokens(&client, &code, &pkce)
.await?;
let account_id = token_set
.id_token
.as_deref()
.and_then(auth::gemini_oauth::extract_account_email_from_id_token);
auth_service
.store_gemini_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_oauth_login(config, "gemini");
println!("Saved profile {profile}");
println!("Active profile for gemini: {profile}");
Ok(())
}
.await;
if let Err(e) = result {
// Cleanup pending file on error
if e.to_string().starts_with(PROFILE_MISMATCH_PREFIX) {
clear_pending_oauth_login(config, "gemini");
eprintln!("{}", e);
eprintln!(
"\n💡 Tip: A previous login attempt was for a different profile."
);
eprintln!(" The pending auth file has been cleared.");
eprintln!(" Please start fresh with:");
eprintln!(
" zeroclaw auth login --provider gemini --profile {}",
profile
);
std::process::exit(1);
}
return Err(e);
}
}
_ => {
bail!("`auth paste-redirect` supports --provider openai-codex or gemini");
}
}
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(),
);
auth_service
.store_provider_token(&provider, &profile, &token, metadata, true)
.await?;
println!("Saved profile {profile}");
println!("Active profile for {provider}: {profile}");
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(),
);
auth_service
.store_provider_token(&provider, &profile, &token, metadata, true)
.await?;
println!("Saved profile {profile}");
println!("Active profile for {provider}: {profile}");
Ok(())
}
AuthCommands::Refresh { provider, profile } => {
let provider = auth::normalize_provider(&provider)?;
match provider.as_str() {
"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`."
)
}
}
}
"gemini" => {
match auth_service
.get_valid_gemini_access_token(profile.as_deref())
.await?
{
Some(_) => {
let profile_name = profile.as_deref().unwrap_or("default");
println!("✓ Gemini token refreshed successfully");
println!(" Profile: gemini:{}", profile_name);
Ok(())
}
None => {
bail!(
"No Gemini auth profile found. Run `zeroclaw auth login --provider gemini`."
)
}
}
}
_ => bail!("`auth refresh` supports --provider openai-codex or gemini"),
}
}
AuthCommands::Logout { provider, profile } => {
let provider = auth::normalize_provider(&provider)?;
let removed = auth_service.remove_profile(&provider, &profile).await?;
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)?;
auth_service.set_active_profile(&provider, &profile).await?;
println!("Active profile for {provider}: {profile}");
Ok(())
}
AuthCommands::List => {
let data = auth_service.load_profiles().await?;
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().await?;
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,
crate::security::redact(profile.account_id.as_deref().unwrap_or("unknown")),
format_expiry(profile)
);
}
println!();
println!("Active profiles:");
for (provider, profile_id) in &data.active_profiles {
println!(" {provider}: {profile_id}");
}
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
#[test]
fn cli_definition_has_no_flag_conflicts() {
Cli::command().debug_assert();
}
#[test]
fn onboard_help_includes_model_flag() {
let cmd = Cli::command();
let onboard = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "onboard")
.expect("onboard subcommand must exist");
let has_model_flag = onboard
.get_arguments()
.any(|arg| arg.get_id().as_str() == "model" && arg.get_long() == Some("model"));
assert!(
has_model_flag,
"onboard help should include --model for quick setup overrides"
);
}
#[test]
fn onboard_cli_accepts_model_provider_and_api_key_in_quick_mode() {
let cli = Cli::try_parse_from([
"zeroclaw",
"onboard",
"--provider",
"openrouter",
"--model",
"custom-model-946",
"--api-key",
"sk-issue946",
])
.expect("quick onboard invocation should parse");
match cli.command {
Commands::Onboard {
interactive,
force,
channels_only,
api_key,
provider,
model,
..
} => {
assert!(!interactive);
assert!(!force);
assert!(!channels_only);
assert_eq!(provider.as_deref(), Some("openrouter"));
assert_eq!(model.as_deref(), Some("custom-model-946"));
assert_eq!(api_key.as_deref(), Some("sk-issue946"));
}
other => panic!("expected onboard command, got {other:?}"),
}
}
#[test]
fn completions_cli_parses_supported_shells() {
for shell in ["bash", "fish", "zsh", "powershell", "elvish"] {
let cli = Cli::try_parse_from(["zeroclaw", "completions", shell])
.expect("completions invocation should parse");
match cli.command {
Commands::Completions { .. } => {}
other => panic!("expected completions command, got {other:?}"),
}
}
}
#[test]
fn gateway_help_includes_new_pairing_flag() {
let cmd = Cli::command();
let gateway = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "gateway")
.expect("gateway subcommand must exist");
let has_new_pairing_flag = gateway.get_arguments().any(|arg| {
arg.get_id().as_str() == "new_pairing" && arg.get_long() == Some("new-pairing")
});
assert!(
has_new_pairing_flag,
"gateway help should include --new-pairing"
);
}
#[test]
fn gateway_cli_accepts_new_pairing_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"])
.expect("gateway --new-pairing should parse");
match cli.command {
Commands::Gateway { new_pairing, .. } => assert!(new_pairing),
other => panic!("expected gateway command, got {other:?}"),
}
}
#[test]
fn gateway_cli_defaults_new_pairing_to_false() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway"]).expect("gateway should parse");
match cli.command {
Commands::Gateway { new_pairing, .. } => assert!(!new_pairing),
other => panic!("expected gateway command, got {other:?}"),
}
}
#[test]
fn completion_generation_mentions_binary_name() {
let mut output = Vec::new();
write_shell_completion(CompletionShell::Bash, &mut output)
.expect("completion generation should succeed");
let script = String::from_utf8(output).expect("completion output should be valid utf-8");
assert!(
script.contains("zeroclaw"),
"completion script should reference binary name"
);
}
#[test]
fn onboard_cli_accepts_force_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "onboard", "--force"])
.expect("onboard --force should parse");
match cli.command {
Commands::Onboard { force, .. } => assert!(force),
other => panic!("expected onboard command, got {other:?}"),
}
}
#[test]
fn onboard_cli_accepts_no_totp_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "onboard", "--no-totp"])
.expect("onboard --no-totp should parse");
match cli.command {
Commands::Onboard { no_totp, .. } => assert!(no_totp),
other => panic!("expected onboard command, got {other:?}"),
}
}
#[test]
fn onboard_cli_accepts_openclaw_migration_flags() {
let cli = Cli::try_parse_from([
"zeroclaw",
"onboard",
"--migrate-openclaw",
"--openclaw-source",
"/tmp/openclaw-workspace",
"--openclaw-config",
"/tmp/openclaw.json",
])
.expect("onboard openclaw migration flags should parse");
match cli.command {
Commands::Onboard {
migrate_openclaw,
openclaw_source,
openclaw_config,
..
} => {
assert!(migrate_openclaw);
assert_eq!(
openclaw_source.as_deref(),
Some(std::path::Path::new("/tmp/openclaw-workspace"))
);
assert_eq!(
openclaw_config.as_deref(),
Some(std::path::Path::new("/tmp/openclaw.json"))
);
}
other => panic!("expected onboard command, got {other:?}"),
}
}
#[test]
fn migrate_openclaw_cli_accepts_source_and_module_flags() {
let cli = Cli::try_parse_from([
"zeroclaw",
"migrate",
"openclaw",
"--source",
"/tmp/openclaw-workspace",
"--source-config",
"/tmp/openclaw.json",
"--dry-run",
"--no-config",
])
.expect("migrate openclaw flags should parse");
match cli.command {
Commands::Migrate {
migrate_command:
MigrateCommands::Openclaw {
source,
source_config,
dry_run,
no_memory,
no_config,
},
} => {
assert_eq!(
source.as_deref(),
Some(std::path::Path::new("/tmp/openclaw-workspace"))
);
assert_eq!(
source_config.as_deref(),
Some(std::path::Path::new("/tmp/openclaw.json"))
);
assert!(dry_run);
assert!(!no_memory);
assert!(no_config);
}
other => panic!("expected migrate openclaw command, got {other:?}"),
}
}
#[test]
fn cli_parses_estop_default_engage() {
let cli = Cli::try_parse_from(["zeroclaw", "estop"]).expect("estop command should parse");
match cli.command {
Commands::Estop {
estop_command,
level,
domains,
tools,
} => {
assert!(estop_command.is_none());
assert!(level.is_none());
assert!(domains.is_empty());
assert!(tools.is_empty());
}
other => panic!("expected estop command, got {other:?}"),
}
}
#[test]
fn cli_parses_estop_resume_domain() {
let cli = Cli::try_parse_from(["zeroclaw", "estop", "resume", "--domain", "*.chase.com"])
.expect("estop resume command should parse");
match cli.command {
Commands::Estop {
estop_command: Some(EstopSubcommands::Resume { domains, .. }),
..
} => assert_eq!(domains, vec!["*.chase.com".to_string()]),
other => panic!("expected estop resume command, got {other:?}"),
}
}
#[test]
fn config_help_mentions_show_get_set_examples() {
let cmd = Cli::command();
let config_cmd = cmd
.get_subcommands()
.find(|subcommand| subcommand.get_name() == "config")
.expect("config subcommand must exist");
let mut output = Vec::new();
config_cmd
.clone()
.write_long_help(&mut output)
.expect("help generation should succeed");
let help = String::from_utf8(output).expect("help output should be utf-8");
assert!(help.contains("zeroclaw config show"));
assert!(help.contains("zeroclaw config get gateway.port"));
assert!(help.contains("zeroclaw config set gateway.port 8080"));
}
#[test]
fn config_cli_parses_show_get_set_subcommands() {
let show =
Cli::try_parse_from(["zeroclaw", "config", "show"]).expect("config show should parse");
match show.command {
Commands::Config {
config_command: ConfigCommands::Show,
} => {}
other => panic!("expected config show, got {other:?}"),
}
let get = Cli::try_parse_from(["zeroclaw", "config", "get", "gateway.port"])
.expect("config get should parse");
match get.command {
Commands::Config {
config_command: ConfigCommands::Get { key },
} => assert_eq!(key, "gateway.port"),
other => panic!("expected config get, got {other:?}"),
}
let set = Cli::try_parse_from(["zeroclaw", "config", "set", "gateway.port", "8080"])
.expect("config set should parse");
match set.command {
Commands::Config {
config_command: ConfigCommands::Set { key, value },
} => {
assert_eq!(key, "gateway.port");
assert_eq!(value, "8080");
}
other => panic!("expected config set, got {other:?}"),
}
}
#[test]
fn redact_config_secrets_masks_nested_sensitive_values() {
let mut payload = serde_json::json!({
"api_key": "sk-test",
"nested": {
"bot_token": "token",
"paired_tokens": ["abc", "def"],
"non_secret": "ok"
}
});
redact_config_secrets(&mut payload);
assert_eq!(payload["api_key"], serde_json::json!("***REDACTED***"));
assert_eq!(
payload["nested"]["bot_token"],
serde_json::json!("***REDACTED***")
);
assert_eq!(
payload["nested"]["paired_tokens"],
serde_json::json!(["***REDACTED***"])
);
assert_eq!(payload["nested"]["non_secret"], serde_json::json!("ok"));
}
}