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

615
src/skills/mod.rs Normal file
View file

@ -0,0 +1,615 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// A skill is a user-defined or community-built capability.
/// Skills live in `~/.zeroclaw/workspace/skills/<name>/SKILL.md`
/// and can include tool definitions, prompts, and automation scripts.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Skill {
pub name: String,
pub description: String,
pub version: String,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub tools: Vec<SkillTool>,
#[serde(default)]
pub prompts: Vec<String>,
}
/// A tool defined by a skill (shell command, HTTP call, etc.)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillTool {
pub name: String,
pub description: String,
/// "shell", "http", "script"
pub kind: String,
/// The command/URL/script to execute
pub command: String,
#[serde(default)]
pub args: HashMap<String, String>,
}
/// Skill manifest parsed from SKILL.toml
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SkillManifest {
skill: SkillMeta,
#[serde(default)]
tools: Vec<SkillTool>,
#[serde(default)]
prompts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SkillMeta {
name: String,
description: String,
#[serde(default = "default_version")]
version: String,
#[serde(default)]
author: Option<String>,
#[serde(default)]
tags: Vec<String>,
}
fn default_version() -> String {
"0.1.0".to_string()
}
/// Load all skills from the workspace skills directory
pub fn load_skills(workspace_dir: &Path) -> Vec<Skill> {
let skills_dir = workspace_dir.join("skills");
if !skills_dir.exists() {
return Vec::new();
}
let mut skills = Vec::new();
let Ok(entries) = std::fs::read_dir(&skills_dir) else {
return skills;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
// Try SKILL.toml first, then SKILL.md
let manifest_path = path.join("SKILL.toml");
let md_path = path.join("SKILL.md");
if manifest_path.exists() {
if let Ok(skill) = load_skill_toml(&manifest_path) {
skills.push(skill);
}
} else if md_path.exists() {
if let Ok(skill) = load_skill_md(&md_path, &path) {
skills.push(skill);
}
}
}
skills
}
/// Load a skill from a SKILL.toml manifest
fn load_skill_toml(path: &Path) -> Result<Skill> {
let content = std::fs::read_to_string(path)?;
let manifest: SkillManifest = toml::from_str(&content)?;
Ok(Skill {
name: manifest.skill.name,
description: manifest.skill.description,
version: manifest.skill.version,
author: manifest.skill.author,
tags: manifest.skill.tags,
tools: manifest.tools,
prompts: manifest.prompts,
})
}
/// Load a skill from a SKILL.md file (simpler format)
fn load_skill_md(path: &Path, dir: &Path) -> Result<Skill> {
let content = std::fs::read_to_string(path)?;
let name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
// Extract description from first non-heading line
let description = content
.lines()
.find(|l| !l.starts_with('#') && !l.trim().is_empty())
.unwrap_or("No description")
.trim()
.to_string();
Ok(Skill {
name,
description,
version: "0.1.0".to_string(),
author: None,
tags: Vec::new(),
tools: Vec::new(),
prompts: vec![content],
})
}
/// Build a system prompt addition from all loaded skills
pub fn skills_to_prompt(skills: &[Skill]) -> String {
use std::fmt::Write;
if skills.is_empty() {
return String::new();
}
let mut prompt = String::from("\n## Active Skills\n\n");
for skill in skills {
let _ = writeln!(prompt, "### {} (v{})", skill.name, skill.version);
let _ = writeln!(prompt, "{}", skill.description);
if !skill.tools.is_empty() {
prompt.push_str("Tools:\n");
for tool in &skill.tools {
let _ = writeln!(prompt, "- **{}**: {} ({})", tool.name, tool.description, tool.kind);
}
}
for p in &skill.prompts {
prompt.push_str(p);
prompt.push('\n');
}
prompt.push('\n');
}
prompt
}
/// Get the skills directory path
pub fn skills_dir(workspace_dir: &Path) -> PathBuf {
workspace_dir.join("skills")
}
/// Initialize the skills directory with a README
pub fn init_skills_dir(workspace_dir: &Path) -> Result<()> {
let dir = skills_dir(workspace_dir);
std::fs::create_dir_all(&dir)?;
let readme = dir.join("README.md");
if !readme.exists() {
std::fs::write(
&readme,
"# ZeroClaw Skills\n\n\
Each subdirectory is a skill. Create a `SKILL.toml` or `SKILL.md` file inside.\n\n\
## SKILL.toml format\n\n\
```toml\n\
[skill]\n\
name = \"my-skill\"\n\
description = \"What this skill does\"\n\
version = \"0.1.0\"\n\
author = \"your-name\"\n\
tags = [\"productivity\", \"automation\"]\n\n\
[[tools]]\n\
name = \"my_tool\"\n\
description = \"What this tool does\"\n\
kind = \"shell\"\n\
command = \"echo hello\"\n\
```\n\n\
## SKILL.md format (simpler)\n\n\
Just write a markdown file with instructions for the agent.\n\
The agent will read it and follow the instructions.\n\n\
## Installing community skills\n\n\
```bash\n\
zeroclaw skills install <github-url>\n\
zeroclaw skills list\n\
```\n",
)?;
}
Ok(())
}
/// Handle the `skills` CLI command
pub fn handle_command(command: super::SkillCommands, workspace_dir: &Path) -> Result<()> {
match command {
super::SkillCommands::List => {
let skills = load_skills(workspace_dir);
if skills.is_empty() {
println!("No skills installed.");
println!();
println!(" Create one: mkdir -p ~/.zeroclaw/workspace/skills/my-skill");
println!(" echo '# My Skill' > ~/.zeroclaw/workspace/skills/my-skill/SKILL.md");
println!();
println!(" Or install: zeroclaw skills install <github-url>");
} else {
println!("Installed skills ({}):", skills.len());
println!();
for skill in &skills {
println!(
" {} {} — {}",
console::style(&skill.name).white().bold(),
console::style(format!("v{}", skill.version)).dim(),
skill.description
);
if !skill.tools.is_empty() {
println!(
" Tools: {}",
skill.tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
);
}
if !skill.tags.is_empty() {
println!(
" Tags: {}",
skill.tags.join(", ")
);
}
}
}
println!();
Ok(())
}
super::SkillCommands::Install { source } => {
println!("Installing skill from: {source}");
let skills_path = skills_dir(workspace_dir);
std::fs::create_dir_all(&skills_path)?;
if source.starts_with("http") || source.contains("github.com") {
// Git clone
let output = std::process::Command::new("git")
.args(["clone", "--depth", "1", &source])
.current_dir(&skills_path)
.output()?;
if output.status.success() {
println!(" {} Skill installed successfully!", console::style("").green().bold());
println!(" Restart `zeroclaw channel start` to activate.");
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Git clone failed: {stderr}");
}
} else {
// Local path — symlink or copy
let src = PathBuf::from(&source);
if !src.exists() {
anyhow::bail!("Source path does not exist: {source}");
}
let name = src.file_name().unwrap_or_default();
let dest = skills_path.join(name);
#[cfg(unix)]
std::os::unix::fs::symlink(&src, &dest)?;
#[cfg(not(unix))]
{
// On non-unix, copy the directory
anyhow::bail!("Symlink not supported on this platform. Copy the skill directory manually.");
}
println!(" {} Skill linked: {}", console::style("").green().bold(), dest.display());
}
Ok(())
}
super::SkillCommands::Remove { name } => {
let skill_path = skills_dir(workspace_dir).join(&name);
if !skill_path.exists() {
anyhow::bail!("Skill not found: {name}");
}
std::fs::remove_dir_all(&skill_path)?;
println!(" {} Skill '{}' removed.", console::style("").green().bold(), name);
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn load_empty_skills_dir() {
let dir = tempfile::tempdir().unwrap();
let skills = load_skills(dir.path());
assert!(skills.is_empty());
}
#[test]
fn load_skill_from_toml() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("test-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "test-skill"
description = "A test skill"
version = "1.0.0"
tags = ["test"]
[[tools]]
name = "hello"
description = "Says hello"
kind = "shell"
command = "echo hello"
"#,
)
.unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "test-skill");
assert_eq!(skills[0].tools.len(), 1);
assert_eq!(skills[0].tools[0].name, "hello");
}
#[test]
fn load_skill_from_md() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("md-skill");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
"# My Skill\nThis skill does cool things.\n",
)
.unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "md-skill");
assert!(skills[0].description.contains("cool things"));
}
#[test]
fn skills_to_prompt_empty() {
let prompt = skills_to_prompt(&[]);
assert!(prompt.is_empty());
}
#[test]
fn skills_to_prompt_with_skills() {
let skills = vec![Skill {
name: "test".to_string(),
description: "A test".to_string(),
version: "1.0.0".to_string(),
author: None,
tags: vec![],
tools: vec![],
prompts: vec!["Do the thing.".to_string()],
}];
let prompt = skills_to_prompt(&skills);
assert!(prompt.contains("test"));
assert!(prompt.contains("Do the thing"));
}
#[test]
fn init_skills_creates_readme() {
let dir = tempfile::tempdir().unwrap();
init_skills_dir(dir.path()).unwrap();
assert!(dir.path().join("skills").join("README.md").exists());
}
#[test]
fn init_skills_idempotent() {
let dir = tempfile::tempdir().unwrap();
init_skills_dir(dir.path()).unwrap();
init_skills_dir(dir.path()).unwrap(); // second call should not fail
assert!(dir.path().join("skills").join("README.md").exists());
}
#[test]
fn load_nonexistent_dir() {
let dir = tempfile::tempdir().unwrap();
let fake = dir.path().join("nonexistent");
let skills = load_skills(&fake);
assert!(skills.is_empty());
}
#[test]
fn load_ignores_files_in_skills_dir() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
fs::create_dir_all(&skills_dir).unwrap();
// A file, not a directory — should be ignored
fs::write(skills_dir.join("not-a-skill.txt"), "hello").unwrap();
let skills = load_skills(dir.path());
assert!(skills.is_empty());
}
#[test]
fn load_ignores_dir_without_manifest() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let empty_skill = skills_dir.join("empty-skill");
fs::create_dir_all(&empty_skill).unwrap();
// Directory exists but no SKILL.toml or SKILL.md
let skills = load_skills(dir.path());
assert!(skills.is_empty());
}
#[test]
fn load_multiple_skills() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
for name in ["alpha", "beta", "gamma"] {
let skill_dir = skills_dir.join(name);
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.md"),
format!("# {name}\nSkill {name} description.\n"),
)
.unwrap();
}
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 3);
}
#[test]
fn toml_skill_with_multiple_tools() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("multi-tool");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "multi-tool"
description = "Has many tools"
version = "2.0.0"
author = "tester"
tags = ["automation", "devops"]
[[tools]]
name = "build"
description = "Build the project"
kind = "shell"
command = "cargo build"
[[tools]]
name = "test"
description = "Run tests"
kind = "shell"
command = "cargo test"
[[tools]]
name = "deploy"
description = "Deploy via HTTP"
kind = "http"
command = "https://api.example.com/deploy"
"#,
)
.unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
let s = &skills[0];
assert_eq!(s.name, "multi-tool");
assert_eq!(s.version, "2.0.0");
assert_eq!(s.author.as_deref(), Some("tester"));
assert_eq!(s.tags, vec!["automation", "devops"]);
assert_eq!(s.tools.len(), 3);
assert_eq!(s.tools[0].name, "build");
assert_eq!(s.tools[1].kind, "shell");
assert_eq!(s.tools[2].kind, "http");
}
#[test]
fn toml_skill_minimal() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("minimal");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "minimal"
description = "Bare minimum"
"#,
)
.unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].version, "0.1.0"); // default version
assert!(skills[0].author.is_none());
assert!(skills[0].tags.is_empty());
assert!(skills[0].tools.is_empty());
}
#[test]
fn toml_skill_invalid_syntax_skipped() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("broken");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.toml"), "this is not valid toml {{{{").unwrap();
let skills = load_skills(dir.path());
assert!(skills.is_empty()); // broken skill is skipped
}
#[test]
fn md_skill_heading_only() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("heading-only");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# Just a Heading\n").unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].description, "No description");
}
#[test]
fn skills_to_prompt_includes_tools() {
let skills = vec![Skill {
name: "weather".to_string(),
description: "Get weather".to_string(),
version: "1.0.0".to_string(),
author: None,
tags: vec![],
tools: vec![SkillTool {
name: "get_weather".to_string(),
description: "Fetch forecast".to_string(),
kind: "shell".to_string(),
command: "curl wttr.in".to_string(),
args: HashMap::new(),
}],
prompts: vec![],
}];
let prompt = skills_to_prompt(&skills);
assert!(prompt.contains("weather"));
assert!(prompt.contains("get_weather"));
assert!(prompt.contains("Fetch forecast"));
assert!(prompt.contains("shell"));
}
#[test]
fn skills_dir_path() {
let base = std::path::Path::new("/home/user/.zeroclaw");
let dir = skills_dir(base);
assert_eq!(dir, PathBuf::from("/home/user/.zeroclaw/skills"));
}
#[test]
fn toml_prefers_over_md() {
let dir = tempfile::tempdir().unwrap();
let skills_dir = dir.path().join("skills");
let skill_dir = skills_dir.join("dual");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
"[skill]\nname = \"from-toml\"\ndescription = \"TOML wins\"\n",
)
.unwrap();
fs::write(skill_dir.join("SKILL.md"), "# From MD\nMD description\n").unwrap();
let skills = load_skills(dir.path());
assert_eq!(skills.len(), 1);
assert_eq!(skills[0].name, "from-toml"); // TOML takes priority
}
}