diff --git a/README.md b/README.md index 6618de5..4a7f4be 100644 --- a/README.md +++ b/README.md @@ -887,6 +887,18 @@ See [aieos.org](https://aieos.org) for the full schema and live examples. For a task-oriented command guide, see [`docs/commands-reference.md`](docs/commands-reference.md). +### Open-Skills Opt-In + +Community `open-skills` sync is disabled by default. Enable it explicitly in `config.toml`: + +```toml +[skills] +open_skills_enabled = true +# open_skills_dir = "/path/to/open-skills" # optional +``` + +You can also override at runtime with `ZEROCLAW_OPEN_SKILLS_ENABLED` and `ZEROCLAW_OPEN_SKILLS_DIR`. + ## Development ```bash diff --git a/docs/config-reference.md b/docs/config-reference.md index d56da1a..8291a3c 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -114,6 +114,21 @@ Notes: - `reasoning_enabled = true` explicitly requests reasoning for supported providers (`think: true` on `ollama`). - Unset keeps provider defaults. +## `[skills]` + +| Key | Default | Purpose | +|---|---|---| +| `open_skills_enabled` | `false` | Opt-in loading/sync of community `open-skills` repository | +| `open_skills_dir` | unset | Optional local path for `open-skills` (defaults to `$HOME/open-skills` when enabled) | + +Notes: + +- Security-first default: ZeroClaw does **not** clone or sync `open-skills` unless `open_skills_enabled = true`. +- Environment overrides: + - `ZEROCLAW_OPEN_SKILLS_ENABLED` accepts `1/0`, `true/false`, `yes/no`, `on/off`. + - `ZEROCLAW_OPEN_SKILLS_DIR` overrides the repository path when non-empty. +- Precedence for enable flag: `ZEROCLAW_OPEN_SKILLS_ENABLED` → `skills.open_skills_enabled` in `config.toml` → default `false`. + ## `[composio]` | Key | Default | Purpose | diff --git a/src/agent/agent.rs b/src/agent/agent.rs index 7b41d16..e96d797 100644 --- a/src/agent/agent.rs +++ b/src/agent/agent.rs @@ -308,7 +308,10 @@ impl Agent { .classification_config(config.query_classification.clone()) .available_hints(available_hints) .identity_config(config.identity.clone()) - .skills(crate::skills::load_skills(&config.workspace_dir)) + .skills(crate::skills::load_skills_with_config( + &config.workspace_dir, + config, + )) .auto_save(config.memory.auto_save) .build() } diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index 0deee67..cd6b862 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -1348,7 +1348,7 @@ pub async fn run( .collect(); // ── Build system prompt from workspace MD files (OpenClaw framework) ── - let skills = crate::skills::load_skills(&config.workspace_dir); + let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config); let mut tool_descs: Vec<(&str, &str)> = vec![ ( "shell", @@ -1778,7 +1778,7 @@ pub async fn process_message(config: Config, message: &str) -> Result { .map(|b| b.board.clone()) .collect(); - let skills = crate::skills::load_skills(&config.workspace_dir); + let skills = crate::skills::load_skills_with_config(&config.workspace_dir, &config); let mut tool_descs: Vec<(&str, &str)> = vec![ ("shell", "Execute terminal commands."), ("file_read", "Read file contents."), diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 96236fb..3d48c52 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -2302,7 +2302,7 @@ pub async fn start_channels(config: Config) -> Result<()> { &config, )); - let skills = crate::skills::load_skills(&workspace); + let skills = crate::skills::load_skills_with_config(&workspace, &config); // Collect tool descriptions for the prompt let mut tool_descs: Vec<(&str, &str)> = vec![ diff --git a/src/config/mod.rs b/src/config/mod.rs index 8187eec..4649f9c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -11,9 +11,9 @@ pub use schema::{ IdentityConfig, LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig, ObservabilityConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, - SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, - StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, - TunnelConfig, WebSearchConfig, WebhookConfig, + SandboxBackend, SandboxConfig, SchedulerConfig, SecretsConfig, SecurityConfig, SkillsConfig, + SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode, + TelegramConfig, TunnelConfig, WebSearchConfig, WebhookConfig, }; #[cfg(test)] diff --git a/src/config/schema.rs b/src/config/schema.rs index de007ce..04eee32 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -94,6 +94,10 @@ pub struct Config { #[serde(default)] pub agent: AgentConfig, + /// Skills loading and community repository behavior (`[skills]`). + #[serde(default)] + pub skills: SkillsConfig, + /// Model routing rules — route `hint:` to specific provider+model combos. #[serde(default)] pub model_routes: Vec, @@ -325,6 +329,28 @@ impl Default for AgentConfig { } } +/// Skills loading configuration (`[skills]` section). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SkillsConfig { + /// Enable loading and syncing the community open-skills repository. + /// Default: `false` (opt-in). + #[serde(default)] + pub open_skills_enabled: bool, + /// Optional path to a local open-skills repository. + /// If unset, defaults to `$HOME/open-skills` when enabled. + #[serde(default)] + pub open_skills_dir: Option, +} + +impl Default for SkillsConfig { + fn default() -> Self { + Self { + open_skills_enabled: false, + open_skills_dir: None, + } + } +} + /// Multimodal (image) handling configuration (`[multimodal]` section). #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct MultimodalConfig { @@ -2742,6 +2768,7 @@ impl Default for Config { reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), agent: AgentConfig::default(), + skills: SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), @@ -3235,6 +3262,27 @@ impl Config { } } + // Open-skills opt-in flag: ZEROCLAW_OPEN_SKILLS_ENABLED + if let Ok(flag) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") { + if !flag.trim().is_empty() { + match flag.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true, + "0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false, + _ => tracing::warn!( + "Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)" + ), + } + } + } + + // Open-skills directory override: ZEROCLAW_OPEN_SKILLS_DIR + if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") { + let trimmed = path.trim(); + if !trimmed.is_empty() { + self.skills.open_skills_dir = Some(trimmed.to_string()); + } + } + // Gateway port: ZEROCLAW_GATEWAY_PORT or PORT if let Ok(port_str) = std::env::var("ZEROCLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT")) @@ -3574,6 +3622,7 @@ mod tests { 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.skills.open_skills_enabled); assert!(c.workspace_dir.to_string_lossy().contains("workspace")); assert!(c.config_path.to_string_lossy().contains("config.toml")); } @@ -3596,6 +3645,7 @@ mod tests { .expect("schema should expose top-level properties"); assert!(properties.contains_key("default_provider")); + assert!(properties.contains_key("skills")); assert!(properties.contains_key("gateway")); assert!(properties.contains_key("channels_config")); assert!(!properties.contains_key("workspace_dir")); @@ -3745,6 +3795,7 @@ default_temperature = 0.7 }, reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), + skills: SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), query_classification: QueryClassificationConfig::default(), @@ -3941,6 +3992,7 @@ tool_dispatcher = "xml" runtime: RuntimeConfig::default(), reliability: ReliabilityConfig::default(), scheduler: SchedulerConfig::default(), + skills: SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), query_classification: QueryClassificationConfig::default(), @@ -4900,6 +4952,40 @@ default_temperature = 0.7 std::env::remove_var("ZEROCLAW_PROVIDER"); } + #[test] + async fn env_override_open_skills_enabled_and_dir() { + let _env_guard = env_override_lock().await; + let mut config = Config::default(); + assert!(!config.skills.open_skills_enabled); + assert!(config.skills.open_skills_dir.is_none()); + + std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "true"); + std::env::set_var("ZEROCLAW_OPEN_SKILLS_DIR", "/tmp/open-skills"); + config.apply_env_overrides(); + + assert!(config.skills.open_skills_enabled); + assert_eq!( + config.skills.open_skills_dir.as_deref(), + Some("/tmp/open-skills") + ); + + std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED"); + std::env::remove_var("ZEROCLAW_OPEN_SKILLS_DIR"); + } + + #[test] + async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() { + let _env_guard = env_override_lock().await; + let mut config = Config::default(); + config.skills.open_skills_enabled = true; + + std::env::set_var("ZEROCLAW_OPEN_SKILLS_ENABLED", "maybe"); + config.apply_env_overrides(); + + assert!(config.skills.open_skills_enabled); + std::env::remove_var("ZEROCLAW_OPEN_SKILLS_ENABLED"); + } + #[test] async fn env_override_provider_fallback() { let _env_guard = env_override_lock().await; diff --git a/src/main.rs b/src/main.rs index 44df971..488f8ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -884,9 +884,7 @@ async fn main() -> Result<()> { integration_command, } => integrations::handle_command(integration_command, &config), - Commands::Skills { skill_command } => { - skills::handle_command(skill_command, &config.workspace_dir) - } + Commands::Skills { skill_command } => skills::handle_command(skill_command, &config), Commands::Migrate { migrate_command } => { migration::handle_command(migrate_command, &config).await diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index da68994..9ba0975 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -160,6 +160,7 @@ pub async fn run_wizard() -> Result { reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), agent: crate::config::schema::AgentConfig::default(), + skills: crate::config::SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), @@ -398,6 +399,7 @@ async fn run_quick_setup_with_home( reliability: crate::config::ReliabilityConfig::default(), scheduler: crate::config::schema::SchedulerConfig::default(), agent: crate::config::schema::AgentConfig::default(), + skills: crate::config::SkillsConfig::default(), model_routes: Vec::new(), embedding_routes: Vec::new(), heartbeat: HeartbeatConfig::default(), diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 0c6e47c..bca6fff 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -71,9 +71,28 @@ fn default_version() -> String { /// Load all skills from the workspace skills directory pub fn load_skills(workspace_dir: &Path) -> Vec { + load_skills_with_open_skills_config(workspace_dir, None, None) +} + +/// Load skills using runtime config values (preferred at runtime). +pub fn load_skills_with_config(workspace_dir: &Path, config: &crate::config::Config) -> Vec { + load_skills_with_open_skills_config( + workspace_dir, + Some(config.skills.open_skills_enabled), + config.skills.open_skills_dir.as_deref(), + ) +} + +fn load_skills_with_open_skills_config( + workspace_dir: &Path, + config_open_skills_enabled: Option, + config_open_skills_dir: Option<&str>, +) -> Vec { let mut skills = Vec::new(); - if let Some(open_skills_dir) = ensure_open_skills_repo() { + if let Some(open_skills_dir) = + ensure_open_skills_repo(config_open_skills_enabled, config_open_skills_dir) + { skills.extend(load_open_skills(&open_skills_dir)); } @@ -158,33 +177,79 @@ fn load_open_skills(repo_dir: &Path) -> Vec { skills } -fn open_skills_enabled() -> bool { - if let Ok(raw) = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED") { - let value = raw.trim().to_ascii_lowercase(); - return !matches!(value.as_str(), "0" | "false" | "off" | "no"); +fn parse_open_skills_enabled(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, } - - // Keep tests deterministic and network-free by default. - !cfg!(test) } -fn resolve_open_skills_dir() -> Option { - if let Ok(path) = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR") { - let trimmed = path.trim(); - if !trimmed.is_empty() { - return Some(PathBuf::from(trimmed)); +fn open_skills_enabled_from_sources( + config_open_skills_enabled: Option, + env_override: Option<&str>, +) -> bool { + if let Some(raw) = env_override { + if let Some(enabled) = parse_open_skills_enabled(&raw) { + return enabled; + } + if !raw.trim().is_empty() { + tracing::warn!( + "Ignoring invalid ZEROCLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)" + ); } } - UserDirs::new().map(|dirs| dirs.home_dir().join("open-skills")) + config_open_skills_enabled.unwrap_or(false) } -fn ensure_open_skills_repo() -> Option { - if !open_skills_enabled() { +fn open_skills_enabled(config_open_skills_enabled: Option) -> bool { + let env_override = std::env::var("ZEROCLAW_OPEN_SKILLS_ENABLED").ok(); + open_skills_enabled_from_sources(config_open_skills_enabled, env_override.as_deref()) +} + +fn resolve_open_skills_dir_from_sources( + env_dir: Option<&str>, + config_dir: Option<&str>, + home_dir: Option<&Path>, +) -> Option { + let parse_dir = |raw: &str| { + let trimmed = raw.trim(); + if trimmed.is_empty() { + None + } else { + Some(PathBuf::from(trimmed)) + } + }; + + if let Some(env_dir) = env_dir.and_then(parse_dir) { + return Some(env_dir); + } + if let Some(config_dir) = config_dir.and_then(parse_dir) { + return Some(config_dir); + } + home_dir.map(|home| home.join("open-skills")) +} + +fn resolve_open_skills_dir(config_open_skills_dir: Option<&str>) -> Option { + let env_dir = std::env::var("ZEROCLAW_OPEN_SKILLS_DIR").ok(); + let home_dir = UserDirs::new().map(|dirs| dirs.home_dir().to_path_buf()); + resolve_open_skills_dir_from_sources( + env_dir.as_deref(), + config_open_skills_dir, + home_dir.as_deref(), + ) +} + +fn ensure_open_skills_repo( + config_open_skills_enabled: Option, + config_open_skills_dir: Option<&str>, +) -> Option { + if !open_skills_enabled(config_open_skills_enabled) { return None; } - let repo_dir = resolve_open_skills_dir()?; + let repo_dir = resolve_open_skills_dir(config_open_skills_dir)?; if !repo_dir.exists() { if !clone_open_skills_repo(&repo_dir) { @@ -542,10 +607,11 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { /// Handle the `skills` CLI command #[allow(clippy::too_many_lines)] -pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Result<()> { +pub fn handle_command(command: crate::SkillCommands, config: &crate::config::Config) -> Result<()> { + let workspace_dir = &config.workspace_dir; match command { crate::SkillCommands::List => { - let skills = load_skills(workspace_dir); + let skills = load_skills_with_config(workspace_dir, config); if skills.is_empty() { println!("No skills installed."); println!(); @@ -711,6 +777,35 @@ pub fn handle_command(command: crate::SkillCommands, workspace_dir: &Path) -> Re mod tests { use super::*; use std::fs; + use std::sync::{Mutex, OnceLock}; + + fn open_skills_env_lock() -> &'static Mutex<()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK.get_or_init(|| Mutex::new(())) + } + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn unset(key: &'static str) -> Self { + let original = std::env::var(key).ok(); + std::env::remove_var(key); + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + if let Some(value) = &self.original { + std::env::set_var(self.key, value); + } else { + std::env::remove_var(self.key); + } + } + } #[test] fn load_empty_skills_dir() { @@ -1071,6 +1166,78 @@ description = "Bare minimum" assert_eq!(skills.len(), 1); assert_eq!(skills[0].name, "from-toml"); // TOML takes priority } + + #[test] + fn open_skills_enabled_resolution_prefers_env_then_config_then_default_false() { + assert!(!open_skills_enabled_from_sources(None, None)); + assert!(open_skills_enabled_from_sources(Some(true), None)); + assert!(!open_skills_enabled_from_sources(Some(true), Some("0"))); + assert!(open_skills_enabled_from_sources(Some(false), Some("yes"))); + // Invalid env values should fall back to config. + assert!(open_skills_enabled_from_sources( + Some(true), + Some("invalid") + )); + assert!(!open_skills_enabled_from_sources( + Some(false), + Some("invalid") + )); + } + + #[test] + fn resolve_open_skills_dir_resolution_prefers_env_then_config_then_home() { + let home = Path::new("/tmp/home-dir"); + assert_eq!( + resolve_open_skills_dir_from_sources( + Some("/tmp/env-skills"), + Some("/tmp/config"), + Some(home) + ), + Some(PathBuf::from("/tmp/env-skills")) + ); + assert_eq!( + resolve_open_skills_dir_from_sources( + Some(" "), + Some("/tmp/config-skills"), + Some(home) + ), + Some(PathBuf::from("/tmp/config-skills")) + ); + assert_eq!( + resolve_open_skills_dir_from_sources(None, None, Some(home)), + Some(PathBuf::from("/tmp/home-dir/open-skills")) + ); + assert_eq!(resolve_open_skills_dir_from_sources(None, None, None), None); + } + + #[test] + fn load_skills_with_config_reads_open_skills_dir_without_network() { + let _env_guard = open_skills_env_lock().lock().unwrap(); + let _enabled_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_ENABLED"); + let _dir_guard = EnvVarGuard::unset("ZEROCLAW_OPEN_SKILLS_DIR"); + + let dir = tempfile::tempdir().unwrap(); + let workspace_dir = dir.path().join("workspace"); + fs::create_dir_all(workspace_dir.join("skills")).unwrap(); + + let open_skills_dir = dir.path().join("open-skills-local"); + fs::create_dir_all(&open_skills_dir).unwrap(); + fs::write(open_skills_dir.join("README.md"), "# open skills\n").unwrap(); + fs::write( + open_skills_dir.join("http_request.md"), + "# HTTP request\nFetch API responses.\n", + ) + .unwrap(); + + let mut config = crate::config::Config::default(); + config.workspace_dir = workspace_dir.clone(); + config.skills.open_skills_enabled = true; + config.skills.open_skills_dir = Some(open_skills_dir.to_string_lossy().to_string()); + + let skills = load_skills_with_config(&workspace_dir, &config); + assert_eq!(skills.len(), 1); + assert_eq!(skills[0].name, "http_request"); + } } #[cfg(test)]