zeroclaw/src/main.rs

594 lines
17 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 tracing::info;
use tracing_subscriber::{fmt, EnvFilter};
mod agent;
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)
#[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,
},
/// 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,
},
/// 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 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);
println!(
"💓 Heartbeat: {}",
if config.heartbeat.enabled {
format!("every {}min", config.heartbeat.interval_minutes)
} else {
"disabled".into()
}
);
println!(
"🧠 Memory: {} (auto-save: {})",
config.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::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::Hardware { hardware_command } => {
hardware::handle_command(hardware_command.clone(), &config)
}
Commands::Peripheral { peripheral_command } => {
peripherals::handle_command(peripheral_command.clone(), &config)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
#[test]
fn cli_definition_has_no_flag_conflicts() {
Cli::command().debug_assert();
}
}