#![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::{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};
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 deploy;
mod skillforge;
use velaclaw::{
agent, auth, channels, config, cron, daemon, doctor, gateway, hardware, integrations, memory,
migration, onboard, peripherals, providers, security, service, skills, ChannelCommands, Config,
CronCommands, HardwareCommands, IntegrationCommands, MemoryCommands, MigrateCommands,
PeripheralCommands, ServiceCommands, SkillCommands, DEFAULT_PROTOCOL_MODEL_ID,
};
#[derive(Parser, Debug)]
#[command(name = "velaclaw")]
#[command(author = "Luqiang Wang")]
#[command(version = "0.2.0")]
#[command(about = "Protocol-driven autonomous AI agent runtime with intelligent model selection.", long_about = None)]
struct Cli {
#[arg(long, global = true)]
config_dir: Option<String>,
#[command(subcommand)]
command: Commands,
}
#[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(Subcommand, Debug)]
enum Commands {
Onboard {
#[arg(long)]
interactive: bool,
#[arg(long)]
force: bool,
#[arg(long)]
channels_only: bool,
#[arg(long)]
api_key: Option<String>,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
memory: Option<String>,
},
#[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:
velaclaw agent # interactive session
velaclaw agent -m \"Summarize today's logs\" # single message
velaclaw agent -p anthropic --model claude-sonnet-4-20250514
velaclaw agent --peripheral nucleo-f401re:/dev/ttyACM0")]
Agent {
#[arg(short, long)]
message: Option<String>,
#[arg(short, long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(short, long, default_value = "0.7", value_parser = parse_temperature)]
temperature: f64,
#[arg(long)]
peripheral: Vec<String>,
},
#[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:
velaclaw gateway # use config defaults
velaclaw gateway -p 8080 # listen on port 8080
velaclaw gateway --host 0.0.0.0 # bind to all interfaces
velaclaw gateway -p 0 # random available port")]
Gateway {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
},
#[command(long_about = "\
Start the long-running autonomous daemon.
Launches the full VelaClaw runtime: gateway server, all configured \
channels (Telegram, Discord, Slack, etc.), heartbeat monitor, and \
the cron scheduler. This is the recommended way to run VelaClaw in \
production or as an always-on assistant.
Use 'velaclaw service install' to register the daemon as an OS \
service (systemd/launchd) for auto-start on boot.
Examples:
velaclaw daemon # use config defaults
velaclaw daemon -p 9090 # gateway on port 9090
velaclaw daemon --host 127.0.0.1 # localhost only")]
Daemon {
#[arg(short, long)]
port: Option<u16>,
#[arg(long)]
host: Option<String>,
},
Service {
#[arg(long, default_value = "auto", value_parser = ["auto", "systemd", "openrc"])]
service_init: String,
#[command(subcommand)]
service_command: ServiceCommands,
},
Doctor {
#[command(subcommand)]
doctor_command: Option<DoctorCommands>,
},
Status,
#[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:
velaclaw cron list
velaclaw cron add '0 9 * * 1-5' 'Good morning' --tz America/New_York
velaclaw cron add '*/30 * * * *' 'Check system health'
velaclaw cron add-at 2025-01-15T14:00:00Z 'Send reminder'
velaclaw cron add-every 60000 'Ping heartbeat'
velaclaw cron once 30m 'Run backup in 30 minutes'
velaclaw cron pause <task-id>
velaclaw cron update <task-id> --expression '0 8 * * *' --tz Europe/London")]
Cron {
#[command(subcommand)]
cron_command: CronCommands,
},
Models {
#[command(subcommand)]
model_command: ModelCommands,
},
Providers,
#[command(long_about = "\
Manage communication channels.
Add, remove, list, and health-check channels that connect VelaClaw \
to messaging platforms. Supported channel types: telegram, discord, \
slack, whatsapp, matrix, imessage, email.
Examples:
velaclaw channel list
velaclaw channel doctor
velaclaw channel add telegram '{\"bot_token\":\"...\",\"name\":\"my-bot\"}'
velaclaw channel remove my-bot
velaclaw channel bind-telegram velaclaw_user")]
Channel {
#[command(subcommand)]
channel_command: ChannelCommands,
},
Integrations {
#[command(subcommand)]
integration_command: IntegrationCommands,
},
Skills {
#[command(subcommand)]
skill_command: SkillCommands,
},
Migrate {
#[command(subcommand)]
migrate_command: MigrateCommands,
},
Auth {
#[command(subcommand)]
auth_command: AuthCommands,
},
#[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:
velaclaw hardware discover
velaclaw hardware introspect /dev/ttyACM0
velaclaw hardware info --chip STM32F401RETx")]
Hardware {
#[command(subcommand)]
hardware_command: HardwareCommands,
},
#[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:
velaclaw peripheral list
velaclaw peripheral add nucleo-f401re /dev/ttyACM0
velaclaw peripheral add rpi-gpio native
velaclaw peripheral flash --port /dev/cu.usbmodem12345
velaclaw peripheral flash-nucleo")]
Peripheral {
#[command(subcommand)]
peripheral_command: PeripheralCommands,
},
#[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:
velaclaw memory stats
velaclaw memory list
velaclaw memory list --category core --limit 10
velaclaw memory get <key>
velaclaw memory clear --category conversation --yes")]
Memory {
#[command(subcommand)]
memory_command: MemoryCommands,
},
#[command(long_about = "\
Manage VelaClaw configuration.
Inspect and export configuration settings. Use 'schema' to dump \
the full JSON Schema for the config file, which documents every \
available key, type, and default value.
Examples:
velaclaw config schema # print JSON Schema to stdout
velaclaw config schema > schema.json")]
Config {
#[command(subcommand)]
config_command: ConfigCommands,
},
#[command(long_about = "\
Deploy VelaClaw to remote servers via SSH.
Manage remote deployments with support for direct binary deployment, Docker containers, and systemd services. Includes health checks, status monitoring, rollback, and configuration sync capabilities.
Examples:
velaclaw deploy deploy --server prod-001
velaclaw deploy status --server prod-001
velaclaw deploy health-check --server prod-001
velaclaw deploy list
velaclaw deploy rollback --server prod-001")]
Deploy {
#[command(subcommand)]
deploy_command: deploy::DeployCommands,
},
#[command(long_about = "\
Generate shell completion scripts for `velaclaw`.
The script is printed to stdout so it can be sourced directly:
Examples:
source <(velaclaw completions bash)
velaclaw completions zsh > ~/.zfunc/_velaclaw
velaclaw completions fish > ~/.config/fish/completions/velaclaw.fish")]
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
}
#[derive(Subcommand, Debug)]
enum ConfigCommands {
Schema,
}
#[derive(Subcommand, Debug)]
enum AuthCommands {
Login {
#[arg(long)]
provider: String,
#[arg(long, default_value = "default")]
profile: String,
#[arg(long)]
device_code: bool,
},
PasteRedirect {
#[arg(long)]
provider: String,
#[arg(long, default_value = "default")]
profile: String,
#[arg(long)]
input: Option<String>,
},
PasteToken {
#[arg(long)]
provider: String,
#[arg(long, default_value = "default")]
profile: String,
#[arg(long)]
token: Option<String>,
#[arg(long)]
auth_kind: Option<String>,
},
SetupToken {
#[arg(long)]
provider: String,
#[arg(long, default_value = "default")]
profile: String,
},
Refresh {
#[arg(long)]
provider: String,
#[arg(long)]
profile: Option<String>,
},
Logout {
#[arg(long)]
provider: String,
#[arg(long, default_value = "default")]
profile: String,
},
Use {
#[arg(long)]
provider: String,
#[arg(long)]
profile: String,
},
List,
Status,
}
#[derive(Subcommand, Debug)]
enum ModelCommands {
Refresh {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
force: bool,
},
ProtocolProviders {
#[arg(long)]
json: bool,
},
ProtocolModels {
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand, Debug)]
enum DoctorCommands {
Models {
#[arg(long)]
provider: Option<String>,
#[arg(long)]
use_cache: bool,
},
}
#[tokio::main]
#[allow(clippy::too_many_lines)]
async fn main() -> Result<()> {
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("VELACLAW_CONFIG_DIR", config_dir);
}
if let Commands::Completions { shell } = &cli.command {
let mut stdout = std::io::stdout().lock();
write_shell_completion(*shell, &mut stdout)?;
return Ok(());
}
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");
if let Commands::Onboard {
interactive,
force,
channels_only,
api_key,
provider,
model,
memory,
} = &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();
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())
{
bail!("--channels-only does not accept --api-key, --provider, --model, or --memory");
}
if channels_only && force {
bail!("--channels-only does not accept --force");
}
let config = if channels_only {
onboard::wizard::run_channels_repair_wizard().await
} else if interactive {
onboard::wizard::run_wizard(force).await
} else {
onboard::wizard::run_quick_setup(
api_key.as_deref(),
provider.as_deref(),
model.as_deref(),
memory.as_deref(),
force,
)
.await
}?;
if std::env::var("VELACLAW_AUTOSTART_CHANNELS").as_deref() == Ok("1") {
channels::start_channels(config).await?;
}
return Ok(());
}
let mut config = Config::load_or_init().await?;
config.apply_env_overrides();
match cli.command {
Commands::Onboard { .. } | Commands::Completions { .. } => 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 VelaClaw Gateway on {host} (random port)");
} else {
info!("🚀 Starting VelaClaw 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 VelaClaw Daemon on {host} (random port)");
} else {
info!("🧠 Starting VelaClaw Daemon on {host}:{port}");
}
daemon::run(config, host, port).await
}
Commands::Status => {
println!("🦀 VelaClaw 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(DEFAULT_PROTOCOL_MODEL_ID)
);
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()),
("Nextcloud", config.channels_config.nextcloud_talk.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 } => {
let config_for_refresh = config.clone();
tokio::task::spawn_blocking(move || {
onboard::run_models_refresh(&config_for_refresh, provider.as_deref(), force)
})
.await
.map_err(|e| anyhow::anyhow!("models refresh task failed: {e}"))?
}
#[cfg(feature = "ai-protocol")]
ModelCommands::ProtocolProviders { json } => {
use velaclaw::protocol_registry::{
resolve_local_protocol_root, scan_protocol_root,
};
let Some(root) = resolve_local_protocol_root() else {
anyhow::bail!(
"Set AI_PROTOCOL_DIR to a local ai-protocol checkout (not a URL) to list manifests."
);
};
let snap = scan_protocol_root(&root)?;
if json {
println!("{}", serde_json::to_string_pretty(&snap)?);
} else {
println!("Protocol root: {}\n", snap.protocol_root.display());
println!("{:<24} {:<6} REQUIRED_ENVS", "PROVIDER_ID", "OK");
for p in &snap.providers {
println!(
"{:<24} {:<6} [{}]",
p.id,
if p.available { "yes" } else { "no" },
p.required_envs.join(", ")
);
}
}
Ok(())
}
#[cfg(not(feature = "ai-protocol"))]
ModelCommands::ProtocolProviders { .. } => {
anyhow::bail!("Rebuild with --features ai-protocol to use this command.")
}
#[cfg(feature = "ai-protocol")]
ModelCommands::ProtocolModels { json } => {
use velaclaw::protocol_registry::{
resolve_local_protocol_root, scan_protocol_root,
};
let Some(root) = resolve_local_protocol_root() else {
anyhow::bail!(
"Set AI_PROTOCOL_DIR to a local ai-protocol checkout (not a URL) to list models."
);
};
let snap = scan_protocol_root(&root)?;
if json {
println!("{}", serde_json::to_string_pretty(&snap.models)?);
} else {
println!("Models under {}:\n", root.display());
println!("{:<40} PROVIDER", "LOGICAL_ID");
for m in &snap.models {
println!("{:<40} {}", m.logical_id, m.provider);
}
}
Ok(())
}
#[cfg(not(feature = "ai-protocol"))]
ModelCommands::ProtocolModels { .. } => {
anyhow::bail!("Rebuild with --features ai-protocol to use this command.")
}
},
Commands::Providers => {
let providers = providers::list_providers();
let current = config
.default_provider
.as_deref()
.unwrap_or(DEFAULT_PROTOCOL_MODEL_ID)
.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 Legacy string keys and custom:<URL> endpoints were removed in ZS-ML-015."
);
println!(" Use provider/model ids backed by ai-protocol manifests.");
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,
}) => {
let config_for_models = config.clone();
tokio::task::spawn_blocking(move || {
doctor::run_models(&config_for_models, provider.as_deref(), use_cache)
})
.await
.map_err(|e| anyhow::anyhow!("doctor models task failed: {e}"))?
}
None => 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).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::Deploy { deploy_command } => {
deploy::cli::handle_command(deploy_command, &config).await
}
Commands::Config { config_command } => match config_command {
ConfigCommands::Schema => {
let schema = schemars::schema_for!(config::Config);
println!(
"{}",
serde_json::to_string_pretty(&schema).expect("failed to serialize JSON Schema")
);
Ok(())
}
},
}
}
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(())
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
encrypted_code_verifier: Option<String>,
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<Option<PendingOpenAiLogin>> {
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<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)?;
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);
auth_service
.store_openai_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_openai_login(config);
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
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 `velaclaw 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_openai_login(config);
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
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 `velaclaw 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);
auth_service
.store_openai_tokens(&profile, token_set, account_id, true)
.await?;
clear_pending_openai_login(config);
println!("Saved profile {profile}");
println!("Active profile for openai-codex: {profile}");
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)?;
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 `velaclaw auth login --provider openai-codex`."
)
}
}
}
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([
"velaclaw",
"onboard",
"--provider",
DEFAULT_PROTOCOL_MODEL_ID,
"--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(DEFAULT_PROTOCOL_MODEL_ID));
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(["velaclaw", "completions", shell])
.expect("completions invocation should parse");
match cli.command {
Commands::Completions { .. } => {}
other => panic!("expected completions 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("velaclaw"),
"completion script should reference binary name"
);
}
#[test]
fn onboard_cli_accepts_force_flag() {
let cli = Cli::try_parse_from(["velaclaw", "onboard", "--force"])
.expect("onboard --force should parse");
match cli.command {
Commands::Onboard { force, .. } => assert!(force),
other => panic!("expected onboard command, got {other:?}"),
}
}
}