feat: initial release — ZeroClaw v0.1.0

- 22 AI providers (OpenRouter, Anthropic, OpenAI, Mistral, etc.)
- 7 channels (CLI, Telegram, Discord, Slack, iMessage, Matrix, Webhook)
- 5-step onboarding wizard with Project Context personalization
- OpenClaw-aligned system prompt (SOUL.md, IDENTITY.md, USER.md, AGENTS.md, etc.)
- SQLite memory backend with auto-save
- Skills system with on-demand loading
- Security: autonomy levels, command allowlists, cost limits
- 532 tests passing, 0 clippy warnings
This commit is contained in:
argenis de la rosa 2026-02-13 12:19:14 -05:00
commit 05cb353f7f
71 changed files with 15757 additions and 0 deletions

580
src/config/schema.rs Normal file
View file

@ -0,0 +1,580 @@
use crate::security::AutonomyLevel;
use anyhow::{Context, Result};
use directories::UserDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
// ── Top-level config ──────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub workspace_dir: PathBuf,
pub config_path: PathBuf,
pub api_key: Option<String>,
pub default_provider: Option<String>,
pub default_model: Option<String>,
pub default_temperature: f64,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub autonomy: AutonomyConfig,
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub heartbeat: HeartbeatConfig,
#[serde(default)]
pub channels_config: ChannelsConfig,
#[serde(default)]
pub memory: MemoryConfig,
}
// ── Memory ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
/// "sqlite" | "markdown" | "none"
pub backend: String,
/// Auto-save conversation context to memory
pub auto_save: bool,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
backend: "sqlite".into(),
auto_save: true,
}
}
}
// ── Observability ─────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservabilityConfig {
/// "none" | "log" | "prometheus" | "otel"
pub backend: String,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
backend: "none".into(),
}
}
}
// ── Autonomy / Security ──────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutonomyConfig {
pub level: AutonomyLevel,
pub workspace_only: bool,
pub allowed_commands: Vec<String>,
pub forbidden_paths: Vec<String>,
pub max_actions_per_hour: u32,
pub max_cost_per_day_cents: u32,
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
level: AutonomyLevel::Supervised,
workspace_only: true,
allowed_commands: vec![
"git".into(),
"npm".into(),
"cargo".into(),
"ls".into(),
"cat".into(),
"grep".into(),
"find".into(),
"echo".into(),
"pwd".into(),
"wc".into(),
"head".into(),
"tail".into(),
],
forbidden_paths: vec![
"/etc".into(),
"/root".into(),
"~/.ssh".into(),
"~/.gnupg".into(),
],
max_actions_per_hour: 20,
max_cost_per_day_cents: 500,
}
}
}
// ── Runtime ──────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RuntimeConfig {
/// "native" | "docker" | "cloudflare"
pub kind: String,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
kind: "native".into(),
}
}
}
// ── Heartbeat ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeartbeatConfig {
pub enabled: bool,
pub interval_minutes: u32,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
}
}
}
// ── Channels ─────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelsConfig {
pub cli: bool,
pub telegram: Option<TelegramConfig>,
pub discord: Option<DiscordConfig>,
pub slack: Option<SlackConfig>,
pub webhook: Option<WebhookConfig>,
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TelegramConfig {
pub bot_token: String,
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiscordConfig {
pub bot_token: String,
pub guild_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SlackConfig {
pub bot_token: String,
pub app_token: Option<String>,
pub channel_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookConfig {
pub port: u16,
pub secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IMessageConfig {
pub allowed_contacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
pub room_id: String,
pub allowed_users: Vec<String>,
}
// ── Config impl ──────────────────────────────────────────────────
impl Default for Config {
fn default() -> Self {
let home =
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
let zeroclaw_dir = home.join(".zeroclaw");
Self {
workspace_dir: zeroclaw_dir.join("workspace"),
config_path: zeroclaw_dir.join("config.toml"),
api_key: None,
default_provider: Some("openrouter".to_string()),
default_model: Some("anthropic/claude-sonnet-4-20250514".to_string()),
default_temperature: 0.7,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
heartbeat: HeartbeatConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
}
}
}
impl Config {
pub fn load_or_init() -> Result<Self> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
let zeroclaw_dir = home.join(".zeroclaw");
let config_path = zeroclaw_dir.join("config.toml");
if !zeroclaw_dir.exists() {
fs::create_dir_all(&zeroclaw_dir).context("Failed to create .zeroclaw directory")?;
fs::create_dir_all(zeroclaw_dir.join("workspace"))
.context("Failed to create workspace directory")?;
}
if config_path.exists() {
let contents =
fs::read_to_string(&config_path).context("Failed to read config file")?;
let config: Config =
toml::from_str(&contents).context("Failed to parse config file")?;
Ok(config)
} else {
let config = Config::default();
config.save()?;
Ok(config)
}
}
pub fn save(&self) -> Result<()> {
let toml_str = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&self.config_path, toml_str).context("Failed to write config file")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
// ── Defaults ─────────────────────────────────────────────
#[test]
fn config_default_has_sane_values() {
let c = Config::default();
assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
assert!(c.default_model.as_deref().unwrap().contains("claude"));
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
assert!(c.api_key.is_none());
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[test]
fn observability_config_default() {
let o = ObservabilityConfig::default();
assert_eq!(o.backend, "none");
}
#[test]
fn autonomy_config_default() {
let a = AutonomyConfig::default();
assert_eq!(a.level, AutonomyLevel::Supervised);
assert!(a.workspace_only);
assert!(a.allowed_commands.contains(&"git".to_string()));
assert!(a.allowed_commands.contains(&"cargo".to_string()));
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
assert_eq!(a.max_actions_per_hour, 20);
assert_eq!(a.max_cost_per_day_cents, 500);
}
#[test]
fn runtime_config_default() {
let r = RuntimeConfig::default();
assert_eq!(r.kind, "native");
}
#[test]
fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
}
#[test]
fn channels_config_default() {
let c = ChannelsConfig::default();
assert!(c.cli);
assert!(c.telegram.is_none());
assert!(c.discord.is_none());
}
// ── Serde round-trip ─────────────────────────────────────
#[test]
fn config_toml_roundtrip() {
let config = Config {
workspace_dir: PathBuf::from("/tmp/test/workspace"),
config_path: PathBuf::from("/tmp/test/config.toml"),
api_key: Some("sk-test-key".into()),
default_provider: Some("openrouter".into()),
default_model: Some("gpt-4o".into()),
default_temperature: 0.5,
observability: ObservabilityConfig {
backend: "log".into(),
},
autonomy: AutonomyConfig {
level: AutonomyLevel::Full,
workspace_only: false,
allowed_commands: vec!["docker".into()],
forbidden_paths: vec!["/secret".into()],
max_actions_per_hour: 50,
max_cost_per_day_cents: 1000,
},
runtime: RuntimeConfig {
kind: "docker".into(),
},
heartbeat: HeartbeatConfig {
enabled: true,
interval_minutes: 15,
},
channels_config: ChannelsConfig {
cli: true,
telegram: Some(TelegramConfig {
bot_token: "123:ABC".into(),
allowed_users: vec!["user1".into()],
}),
discord: None,
slack: None,
webhook: None,
imessage: None,
matrix: None,
},
memory: MemoryConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.api_key, config.api_key);
assert_eq!(parsed.default_provider, config.default_provider);
assert_eq!(parsed.default_model, config.default_model);
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
assert_eq!(parsed.observability.backend, "log");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
assert!(!parsed.autonomy.workspace_only);
assert_eq!(parsed.runtime.kind, "docker");
assert!(parsed.heartbeat.enabled);
assert_eq!(parsed.heartbeat.interval_minutes, 15);
assert!(parsed.channels_config.telegram.is_some());
assert_eq!(
parsed.channels_config.telegram.unwrap().bot_token,
"123:ABC"
);
}
#[test]
fn config_minimal_toml_uses_defaults() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(parsed.api_key.is_none());
assert!(parsed.default_provider.is_none());
assert_eq!(parsed.observability.backend, "none");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
assert_eq!(parsed.runtime.kind, "native");
assert!(!parsed.heartbeat.enabled);
assert!(parsed.channels_config.cli);
}
#[test]
fn config_save_and_load_tmpdir() {
let dir = std::env::temp_dir().join("zeroclaw_test_config");
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.toml");
let config = Config {
workspace_dir: dir.join("workspace"),
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
default_provider: Some("openrouter".into()),
default_model: Some("test-model".into()),
default_temperature: 0.9,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
heartbeat: HeartbeatConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
};
config.save().unwrap();
assert!(config_path.exists());
let contents = fs::read_to_string(&config_path).unwrap();
let loaded: Config = toml::from_str(&contents).unwrap();
assert_eq!(loaded.api_key.as_deref(), Some("sk-roundtrip"));
assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
let _ = fs::remove_dir_all(&dir);
}
// ── Telegram / Discord config ────────────────────────────
#[test]
fn telegram_config_serde() {
let tc = TelegramConfig {
bot_token: "123:XYZ".into(),
allowed_users: vec!["alice".into(), "bob".into()],
};
let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bot_token, "123:XYZ");
assert_eq!(parsed.allowed_users.len(), 2);
}
#[test]
fn discord_config_serde() {
let dc = DiscordConfig {
bot_token: "discord-token".into(),
guild_id: Some("12345".into()),
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bot_token, "discord-token");
assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
}
#[test]
fn discord_config_optional_guild() {
let dc = DiscordConfig {
bot_token: "tok".into(),
guild_id: None,
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.guild_id.is_none());
}
// ── iMessage / Matrix config ────────────────────────────
#[test]
fn imessage_config_serde() {
let ic = IMessageConfig {
allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
};
let json = serde_json::to_string(&ic).unwrap();
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.allowed_contacts.len(), 2);
assert_eq!(parsed.allowed_contacts[0], "+1234567890");
}
#[test]
fn imessage_config_empty_contacts() {
let ic = IMessageConfig {
allowed_contacts: vec![],
};
let json = serde_json::to_string(&ic).unwrap();
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.allowed_contacts.is_empty());
}
#[test]
fn imessage_config_wildcard() {
let ic = IMessageConfig {
allowed_contacts: vec!["*".into()],
};
let toml_str = toml::to_string(&ic).unwrap();
let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.allowed_contacts, vec!["*"]);
}
#[test]
fn matrix_config_serde() {
let mc = MatrixConfig {
homeserver: "https://matrix.org".into(),
access_token: "syt_token_abc".into(),
room_id: "!room123:matrix.org".into(),
allowed_users: vec!["@user:matrix.org".into()],
};
let json = serde_json::to_string(&mc).unwrap();
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.homeserver, "https://matrix.org");
assert_eq!(parsed.access_token, "syt_token_abc");
assert_eq!(parsed.room_id, "!room123:matrix.org");
assert_eq!(parsed.allowed_users.len(), 1);
}
#[test]
fn matrix_config_toml_roundtrip() {
let mc = MatrixConfig {
homeserver: "https://synapse.local:8448".into(),
access_token: "tok".into(),
room_id: "!abc:synapse.local".into(),
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
};
let toml_str = toml::to_string(&mc).unwrap();
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.homeserver, "https://synapse.local:8448");
assert_eq!(parsed.allowed_users.len(), 2);
}
#[test]
fn channels_config_with_imessage_and_matrix() {
let c = ChannelsConfig {
cli: true,
telegram: None,
discord: None,
slack: None,
webhook: None,
imessage: Some(IMessageConfig {
allowed_contacts: vec!["+1".into()],
}),
matrix: Some(MatrixConfig {
homeserver: "https://m.org".into(),
access_token: "tok".into(),
room_id: "!r:m".into(),
allowed_users: vec!["@u:m".into()],
}),
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.imessage.is_some());
assert!(parsed.matrix.is_some());
assert_eq!(
parsed.imessage.unwrap().allowed_contacts,
vec!["+1"]
);
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
}
#[test]
fn channels_config_default_has_no_imessage_matrix() {
let c = ChannelsConfig::default();
assert!(c.imessage.is_none());
assert!(c.matrix.is_none());
}
}