use anyhow::Result; use directories::UserDirs; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime}; const OPEN_SKILLS_REPO_URL: &str = "https://github.com/besoeasy/open-skills"; const OPEN_SKILLS_SYNC_MARKER: &str = ".zeroclaw-open-skills-sync"; const OPEN_SKILLS_SYNC_INTERVAL_SECS: u64 = 60 * 60 * 24 * 7; /// A skill is a user-defined or community-built capability. /// Skills live in `~/.zeroclaw/workspace/skills//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, #[serde(default)] pub tags: Vec, #[serde(default)] pub tools: Vec, #[serde(default)] pub prompts: Vec, #[serde(skip)] pub location: Option, } /// 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, } /// Skill manifest parsed from SKILL.toml #[derive(Debug, Clone, Serialize, Deserialize)] struct SkillManifest { skill: SkillMeta, #[serde(default)] tools: Vec, #[serde(default)] prompts: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] struct SkillMeta { name: String, description: String, #[serde(default = "default_version")] version: String, #[serde(default)] author: Option, #[serde(default)] tags: Vec, } 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 { let mut skills = Vec::new(); if let Some(open_skills_dir) = ensure_open_skills_repo() { skills.extend(load_open_skills(&open_skills_dir)); } skills.extend(load_workspace_skills(workspace_dir)); skills } fn load_workspace_skills(workspace_dir: &Path) -> Vec { let skills_dir = workspace_dir.join("skills"); load_skills_from_directory(&skills_dir) } fn load_skills_from_directory(skills_dir: &Path) -> Vec { 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 } fn load_open_skills(repo_dir: &Path) -> Vec { let mut skills = Vec::new(); let Ok(entries) = std::fs::read_dir(repo_dir) else { return skills; }; for entry in entries.flatten() { let path = entry.path(); if !path.is_file() { continue; } let is_markdown = path .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| ext.eq_ignore_ascii_case("md")); if !is_markdown { continue; } let is_readme = path .file_name() .and_then(|name| name.to_str()) .is_some_and(|name| name.eq_ignore_ascii_case("README.md")); if is_readme { continue; } if let Ok(skill) = load_open_skill_md(&path) { skills.push(skill); } } 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"); } // 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)); } } UserDirs::new().map(|dirs| dirs.home_dir().join("open-skills")) } fn ensure_open_skills_repo() -> Option { if !open_skills_enabled() { return None; } let repo_dir = resolve_open_skills_dir()?; if !repo_dir.exists() { if !clone_open_skills_repo(&repo_dir) { return None; } let _ = mark_open_skills_synced(&repo_dir); return Some(repo_dir); } if should_sync_open_skills(&repo_dir) { if pull_open_skills_repo(&repo_dir) { let _ = mark_open_skills_synced(&repo_dir); } else { tracing::warn!( "open-skills update failed; using local copy from {}", repo_dir.display() ); } } Some(repo_dir) } fn clone_open_skills_repo(repo_dir: &Path) -> bool { if let Some(parent) = repo_dir.parent() { if let Err(err) = std::fs::create_dir_all(parent) { tracing::warn!( "failed to create open-skills parent directory {}: {err}", parent.display() ); return false; } } let output = Command::new("git") .args(["clone", "--depth", "1", OPEN_SKILLS_REPO_URL]) .arg(repo_dir) .output(); match output { Ok(result) if result.status.success() => { tracing::info!("initialized open-skills at {}", repo_dir.display()); true } Ok(result) => { let stderr = String::from_utf8_lossy(&result.stderr); tracing::warn!("failed to clone open-skills: {stderr}"); false } Err(err) => { tracing::warn!("failed to run git clone for open-skills: {err}"); false } } } fn pull_open_skills_repo(repo_dir: &Path) -> bool { // If user points to a non-git directory via env var, keep using it without pulling. if !repo_dir.join(".git").exists() { return true; } let output = Command::new("git") .arg("-C") .arg(repo_dir) .args(["pull", "--ff-only"]) .output(); match output { Ok(result) if result.status.success() => true, Ok(result) => { let stderr = String::from_utf8_lossy(&result.stderr); tracing::warn!("failed to pull open-skills updates: {stderr}"); false } Err(err) => { tracing::warn!("failed to run git pull for open-skills: {err}"); false } } } fn should_sync_open_skills(repo_dir: &Path) -> bool { let marker = repo_dir.join(OPEN_SKILLS_SYNC_MARKER); let Ok(metadata) = std::fs::metadata(marker) else { return true; }; let Ok(modified_at) = metadata.modified() else { return true; }; let Ok(age) = SystemTime::now().duration_since(modified_at) else { return true; }; age >= Duration::from_secs(OPEN_SKILLS_SYNC_INTERVAL_SECS) } fn mark_open_skills_synced(repo_dir: &Path) -> Result<()> { std::fs::write(repo_dir.join(OPEN_SKILLS_SYNC_MARKER), b"synced")?; Ok(()) } /// Load a skill from a SKILL.toml manifest fn load_skill_toml(path: &Path) -> Result { 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, location: Some(path.to_path_buf()), }) } /// Load a skill from a SKILL.md file (simpler format) fn load_skill_md(path: &Path, dir: &Path) -> Result { let content = std::fs::read_to_string(path)?; let name = dir .file_name() .and_then(|n| n.to_str()) .unwrap_or("unknown") .to_string(); Ok(Skill { name, description: extract_description(&content), version: "0.1.0".to_string(), author: None, tags: Vec::new(), tools: Vec::new(), prompts: vec![content], location: Some(path.to_path_buf()), }) } fn load_open_skill_md(path: &Path) -> Result { let content = std::fs::read_to_string(path)?; let name = path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("open-skill") .to_string(); Ok(Skill { name, description: extract_description(&content), version: "open-skills".to_string(), author: Some("besoeasy/open-skills".to_string()), tags: vec!["open-skills".to_string()], tools: Vec::new(), prompts: vec![content], location: Some(path.to_path_buf()), }) } fn extract_description(content: &str) -> String { content .lines() .find(|line| !line.starts_with('#') && !line.trim().is_empty()) .unwrap_or("No description") .trim() .to_string() } /// 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 \n\ zeroclaw skills list\n\ ```\n", )?; } Ok(()) } /// Recursively copy a directory (used as fallback when symlinks aren't available) #[cfg(any(windows, not(unix)))] fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { std::fs::create_dir_all(dest)?; for entry in std::fs::read_dir(src)? { let entry = entry?; let src_path = entry.path(); let dest_path = dest.join(entry.file_name()); if src_path.is_dir() { copy_dir_recursive(&src_path, &dest_path)?; } else { std::fs::copy(&src_path, &dest_path)?; } } Ok(()) } /// Handle the `skills` CLI command #[allow(clippy::too_many_lines)] 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 "); } 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::>() .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)?; println!( " {} Skill linked: {}", console::style("✓").green().bold(), dest.display() ); } #[cfg(windows)] { // On Windows, try symlink first (requires admin or developer mode), // fall back to directory junction, then copy use std::os::windows::fs::symlink_dir; if symlink_dir(&src, &dest).is_ok() { println!( " {} Skill linked: {}", console::style("✓").green().bold(), dest.display() ); } else { // Try junction as fallback (works without admin) let junction_result = std::process::Command::new("cmd") .args(["/C", "mklink", "/J"]) .arg(&dest) .arg(&src) .output(); if junction_result.is_ok() && junction_result.unwrap().status.success() { println!( " {} Skill linked (junction): {}", console::style("✓").green().bold(), dest.display() ); } else { // Final fallback: copy the directory copy_dir_recursive(&src, &dest)?; println!( " {} Skill copied: {}", console::style("✓").green().bold(), dest.display() ); } } } #[cfg(not(any(unix, windows)))] { // On other platforms, copy the directory copy_dir_recursive(&src, &dest)?; println!( " {} Skill copied: {}", 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)] #[allow(clippy::similar_names)] 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()], location: None, }]; 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![], location: None, }]; 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 } } #[cfg(test)] mod symlink_tests;