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