use serde::Deserialize; use serde_json::json; use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::RwLock; use crate::config::SkillsConfig; #[derive(Debug, Clone)] pub struct Skill { pub name: String, pub description: String, pub body: String, pub source: SkillSource, pub path: PathBuf, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillSource { User, UserAgent, Project, ProjectAgent, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillScope { User, Project, } impl SkillScope { pub fn parse(value: &str) -> Option { match value { "user" => Some(Self::User), "project" => Some(Self::Project), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::User => "user", Self::Project => "project", } } } impl From for SkillSource { fn from(value: SkillScope) -> Self { match value { SkillScope::User => SkillSource::User, SkillScope::Project => SkillSource::Project, } } } #[derive(Debug)] pub struct SkillRuntime { config: SkillsConfig, catalog: RwLock, } impl Default for SkillRuntime { fn default() -> Self { Self { config: SkillsConfig::default(), catalog: RwLock::new(SkillCatalog::default()), } } } impl SkillRuntime { pub fn from_config(config: SkillsConfig) -> Self { let catalog = SkillCatalog::discover(&config); Self { config, catalog: RwLock::new(catalog), } } pub fn reload(&self) -> Result { let catalog = SkillCatalog::discover(&self.config); let mut guard = self.catalog.write().expect("skills rwlock poisoned"); *guard = catalog.clone(); Ok(catalog) } pub fn is_empty(&self) -> bool { self.catalog .read() .expect("skills rwlock poisoned") .is_empty() } pub fn len(&self) -> usize { self.catalog.read().expect("skills rwlock poisoned").len() } pub fn system_index_prompt(&self) -> Option { self.catalog .read() .expect("skills rwlock poisoned") .system_index_prompt() } pub fn discovery_event_payload(&self) -> serde_json::Value { self.catalog .read() .expect("skills rwlock poisoned") .discovery_event_payload() } pub fn offered_event_payload(&self) -> serde_json::Value { self.catalog .read() .expect("skills rwlock poisoned") .offered_event_payload() } pub fn activation_payload(&self, name: &str) -> Result { self.catalog .read() .expect("skills rwlock poisoned") .activation_payload(name) } pub fn activation_event_payload(&self, name: &str) -> Result { self.catalog .read() .expect("skills rwlock poisoned") .activation_event_payload(name) } pub fn list_skills(&self) -> Vec { self.catalog .read() .expect("skills rwlock poisoned") .skills .clone() } pub fn get_skill(&self, name: &str) -> Option { self.catalog .read() .expect("skills rwlock poisoned") .find_skill(name) .cloned() } pub fn create_skill( &self, scope: SkillScope, name: &str, description: &str, body: &str, reload: bool, ) -> Result { validate_skill_name(name)?; let path = skill_file_path(scope, name)?; if path.exists() { return Err(format!( "skill '{}' already exists at {}", name, path.display() )); } write_skill_file(&path, name, description, body)?; let skill = parse_skill_file(&path, scope.into())?; if reload { let _ = self.reload()?; } Ok(skill) } pub fn update_skill( &self, scope: SkillScope, name: &str, description: Option<&str>, body: Option<&str>, reload: bool, ) -> Result { validate_skill_name(name)?; let path = skill_file_path(scope, name)?; if !path.exists() { return Err(format!("skill '{}' not found at {}", name, path.display())); } let existing = parse_skill_file(&path, scope.into())?; let next_description = description.unwrap_or(&existing.description); let next_body = body.unwrap_or(&existing.body); write_skill_file(&path, name, next_description, next_body)?; let skill = parse_skill_file(&path, scope.into())?; if reload { let _ = self.reload()?; } Ok(skill) } pub fn delete_skill( &self, scope: SkillScope, name: &str, reload: bool, ) -> Result { validate_skill_name(name)?; let dir = skill_dir_path(scope, name)?; if !dir.exists() { return Err(format!("skill '{}' not found at {}", name, dir.display())); } fs::remove_dir_all(&dir) .map_err(|err| format!("failed to delete skill directory: {}", err))?; if reload { let _ = self.reload()?; } Ok(dir) } } impl crate::agent::SkillProvider for SkillRuntime { fn system_index_prompt(&self) -> Option { SkillRuntime::system_index_prompt(self) } } impl SkillSource { fn as_str(&self) -> &'static str { match self { SkillSource::User => "user", SkillSource::UserAgent => "user_agent", SkillSource::Project => "project", SkillSource::ProjectAgent => "project_agent", } } } #[derive(Debug, Clone)] pub struct SkillCatalog { skills: Vec, max_index_chars: usize, max_listed_skills: usize, } impl Default for SkillCatalog { fn default() -> Self { Self { skills: Vec::new(), max_index_chars: 4_000, max_listed_skills: 32, } } } impl SkillCatalog { pub fn discover(config: &SkillsConfig) -> Self { if !config.enabled { return Self { max_index_chars: config.max_index_chars, max_listed_skills: config.max_listed_skills, ..Self::default() }; } let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let mut merged: HashMap = HashMap::new(); let mut sources_seen = 0usize; // Load from least specific to most specific so later sources win on conflicts. for source in source_order(&config.sources) { sources_seen += 1; let root = source_root(source, &cwd); let Some(root) = root else { continue }; for skill in load_skills_from_root(&root, source) { if let Some(existing) = merged.get(&skill.name) { tracing::warn!( skill = %skill.name, old_source = %existing.source.as_str(), new_source = %skill.source.as_str(), "Duplicate skill name found; overriding with later source" ); } merged.insert(skill.name.clone(), skill); } } let mut skills: Vec = merged.into_values().collect(); skills.sort_by(|a, b| a.name.cmp(&b.name)); tracing::info!( sources_seen, discovered = skills.len(), "Skills discovery completed" ); Self { skills, max_index_chars: config.max_index_chars, max_listed_skills: config.max_listed_skills, } } pub fn is_empty(&self) -> bool { self.skills.is_empty() } pub fn len(&self) -> usize { self.skills.len() } pub fn system_index_prompt(&self) -> Option { if self.skills.is_empty() { return None; } let mut prompt = String::from( "You have access to skills discovered from local skill directories. Use a skill only when the user's request clearly matches the skill description.\nIf a skill is needed, call tool skill_activate with {\"name\": \"\"}.\nAvailable skills:\n", ); for skill in self.skills.iter().take(self.max_listed_skills) { let line = format!("- {}: {}\n", skill.name, skill.description); if prompt.len() + line.len() > self.max_index_chars { prompt.push_str("- ... (truncated)\n"); break; } prompt.push_str(&line); } Some(prompt) } pub fn discovery_event_payload(&self) -> serde_json::Value { self.catalog_event_payload() } pub fn offered_event_payload(&self) -> serde_json::Value { self.catalog_event_payload() } pub fn activation_payload(&self, name: &str) -> Result { let skill = self .find_skill(name) .ok_or_else(|| format!("skill '{}' not found", name))?; if skill.body.is_empty() { return Ok(format!( "SKILL LOADED: {}\nDescription: {}\nNo additional body instructions found.", skill.name, skill.description )); } Ok(format!( "SKILL LOADED: {}\nDescription: {}\nSource: {}\nPath: {}\n\n{}", skill.name, skill.description, skill.source.as_str(), skill.path.display(), skill.body )) } pub fn activation_event_payload(&self, name: &str) -> Result { let skill = self .find_skill(name) .ok_or_else(|| format!("skill '{}' not found", name))?; Ok(json!({ "name": skill.name, "description": skill.description, "source": skill.source.as_str(), "path": skill.path.display().to_string(), "body_chars": skill.body.len(), })) } fn find_skill(&self, name: &str) -> Option<&Skill> { self.skills.iter().find(|s| s.name == name) } fn catalog_event_payload(&self) -> serde_json::Value { json!({ "count": self.skills.len(), "skills": self.skills.iter().map(|skill| json!({ "name": skill.name, "description": skill.description, "source": skill.source.as_str(), "path": skill.path.display().to_string(), })).collect::>() }) } } fn source_order(sources: &[String]) -> Vec { let mut result = Vec::new(); for source in sources { match source.as_str() { "user" => { if !result.contains(&SkillSource::User) { result.push(SkillSource::User); } } "user_agent" => { if !result.contains(&SkillSource::UserAgent) { result.push(SkillSource::UserAgent); } } "project" => { if !result.contains(&SkillSource::Project) { result.push(SkillSource::Project); } } "project_agent" => { if !result.contains(&SkillSource::ProjectAgent) { result.push(SkillSource::ProjectAgent); } } unknown => { tracing::warn!(source = %unknown, "Unknown skills source ignored"); } } } if result.is_empty() { vec![ SkillSource::User, SkillSource::UserAgent, SkillSource::Project, SkillSource::ProjectAgent, ] } else { result } } fn validate_skill_name(name: &str) -> Result<(), String> { if name.trim().is_empty() { return Err("skill name cannot be empty".to_string()); } if name.contains('/') || name.contains('\\') || name.contains("..") { return Err("skill name must not contain path separators or '..'".to_string()); } Ok(()) } pub fn project_skills_root() -> Result { let cwd = std::env::current_dir().map_err(|err| format!("failed to get current dir: {}", err))?; Ok(cwd.join(".picobot").join("skills")) } fn project_agent_skills_root(cwd: &Path) -> PathBuf { cwd.join(".agents").join("skills") } fn user_skills_root() -> Option { dirs::home_dir().map(|p| p.join(".picobot").join("skills")) } fn user_agent_skills_root() -> Option { dirs::home_dir().map(|p| p.join(".agents").join("skills")) } fn source_root(source: SkillSource, cwd: &Path) -> Option { match source { SkillSource::User => user_skills_root(), SkillSource::UserAgent => user_agent_skills_root(), SkillSource::Project => Some(cwd.join(".picobot").join("skills")), SkillSource::ProjectAgent => Some(project_agent_skills_root(cwd)), } } fn root_for_scope(scope: SkillScope) -> Result { match scope { SkillScope::User => { user_skills_root().ok_or_else(|| "failed to resolve home directory".to_string()) } SkillScope::Project => project_skills_root(), } } fn skill_dir_path(scope: SkillScope, name: &str) -> Result { Ok(root_for_scope(scope)?.join(name)) } fn skill_file_path(scope: SkillScope, name: &str) -> Result { Ok(skill_dir_path(scope, name)?.join("SKILL.md")) } fn render_skill_file(name: &str, description: &str, body: &str) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); } #[derive(serde::Serialize)] struct SkillFrontmatterOwned { name: String, description: String, } let yaml = serde_yaml::to_string(&SkillFrontmatterOwned { name: name.to_string(), description: description.to_string(), }) .map_err(|err| format!("failed to render skill frontmatter: {}", err))?; let yaml = yaml.trim_start_matches("---\n"); let body = body.trim(); if body.is_empty() { Ok(format!("---\n{}---\n", yaml)) } else { Ok(format!("---\n{}---\n{}\n", yaml, body)) } } fn write_skill_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> { let content = render_skill_file(name, description, body)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create skill directory: {}", err))?; } fs::write(path, content).map_err(|err| format!("failed to write skill file: {}", err)) } fn load_skills_from_root(root: &Path, source: SkillSource) -> Vec { let mut out = Vec::new(); if !root.exists() { return out; } let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(err) => { tracing::warn!(path = %root.display(), error = %err, "Failed to read skills directory"); return out; } }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let skill_md = path.join("SKILL.md"); if !skill_md.exists() { continue; } match parse_skill_file(&skill_md, source) { Ok(skill) => out.push(skill), Err(err) => { tracing::warn!(path = %skill_md.display(), error = %err, "Skipping invalid skill file"); } } } out } #[derive(Debug, Deserialize)] struct SkillFrontmatter { description: String, #[serde(default)] name: Option, } fn parse_skill_file(path: &Path, source: SkillSource) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?; let (frontmatter_raw, body) = split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?; let frontmatter: SkillFrontmatter = serde_yaml::from_str(frontmatter_raw) .map_err(|e| format!("invalid YAML frontmatter: {}", e))?; let description = frontmatter.description.trim(); if description.is_empty() { return Err("description is required and cannot be empty".to_string()); } let dir_name = path .parent() .and_then(|p| p.file_name()) .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown-skill".to_string()); let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); Ok(Skill { name, description: description.to_string(), body: body.trim().to_string(), source, path: path.to_path_buf(), }) } fn split_frontmatter(content: &str) -> Option<(&str, &str)> { let rest = content.strip_prefix("---\n")?; let marker = "\n---\n"; let idx = rest.find(marker)?; let frontmatter = &rest[..idx]; let body = &rest[idx + marker.len()..]; Some((frontmatter, body)) } #[cfg(test)] mod tests { use super::*; use std::sync::Mutex; static CWD_TEST_LOCK: Mutex<()> = Mutex::new(()); struct CurrentDirGuard { previous: PathBuf, } impl CurrentDirGuard { fn enter(path: &Path) -> Self { let previous = std::env::current_dir().unwrap(); std::env::set_current_dir(path).unwrap(); Self { previous } } } impl Drop for CurrentDirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.previous); } } #[test] fn test_split_frontmatter() { let input = "---\ndescription: demo\n---\nhello"; let (fm, body) = split_frontmatter(input).unwrap(); assert!(fm.contains("description")); assert_eq!(body, "hello"); } #[test] fn test_parse_skill_file_requires_description() { let dir = tempfile::tempdir().unwrap(); let skill_dir = dir.path().join("demo"); fs::create_dir_all(&skill_dir).unwrap(); let skill_md = skill_dir.join("SKILL.md"); fs::write(&skill_md, "---\nname: demo\n---\ncontent").unwrap(); let err = parse_skill_file(&skill_md, SkillSource::Project).unwrap_err(); assert!(err.contains("description")); } #[test] fn test_activation_payload_contains_body() { let dir = tempfile::tempdir().unwrap(); let skill_dir = dir.path().join("demo"); fs::create_dir_all(&skill_dir).unwrap(); let skill_md = skill_dir.join("SKILL.md"); fs::write( &skill_md, "---\nname: demo\ndescription: demo skill\n---\nStep A\nStep B", ) .unwrap(); let skill = parse_skill_file(&skill_md, SkillSource::Project).unwrap(); let catalog = SkillCatalog { skills: vec![skill], max_index_chars: 1000, max_listed_skills: 10, }; let payload = catalog.activation_payload("demo").unwrap(); assert!(payload.contains("SKILL LOADED: demo")); assert!(payload.contains("Step A")); } #[test] fn test_runtime_create_update_delete_reload() { let _lock = CWD_TEST_LOCK.lock().unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let _guard = CurrentDirGuard::enter(temp_dir.path()); let runtime = SkillRuntime::from_config(SkillsConfig { enabled: true, sources: vec!["project".to_string()], max_index_chars: 4000, max_listed_skills: 32, }); assert_eq!(runtime.len(), 0); let created = runtime .create_skill( SkillScope::Project, "demo-skill", "demo desc", "line 1", true, ) .unwrap(); assert_eq!(created.name, "demo-skill"); assert_eq!(runtime.len(), 1); let updated = runtime .update_skill( SkillScope::Project, "demo-skill", Some("updated desc"), Some("line 2"), true, ) .unwrap(); assert_eq!(updated.description, "updated desc"); assert!( runtime .activation_payload("demo-skill") .unwrap() .contains("line 2") ); let deleted_path = runtime .delete_skill(SkillScope::Project, "demo-skill", true) .unwrap(); assert!(!deleted_path.exists()); assert_eq!(runtime.len(), 0); } #[test] fn test_source_order_supports_agent_sources() { let ordered = source_order(&[ "user".to_string(), "user_agent".to_string(), "project".to_string(), "project_agent".to_string(), "project".to_string(), "unknown".to_string(), ]); assert_eq!( ordered, vec![ SkillSource::User, SkillSource::UserAgent, SkillSource::Project, SkillSource::ProjectAgent, ] ); } #[test] fn test_discover_loads_project_agent_skills() { let _lock = CWD_TEST_LOCK.lock().unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let _guard = CurrentDirGuard::enter(temp_dir.path()); let agent_skill_dir = temp_dir .path() .join(".agents") .join("skills") .join("demo-agent"); fs::create_dir_all(&agent_skill_dir).unwrap(); fs::write( agent_skill_dir.join("SKILL.md"), "---\ndescription: agent skill\n---\nUse agent", ) .unwrap(); let catalog = SkillCatalog::discover(&SkillsConfig { enabled: true, sources: vec!["project_agent".to_string()], max_index_chars: 4000, max_listed_skills: 32, }); assert_eq!(catalog.len(), 1); let payload = catalog.activation_event_payload("demo-agent").unwrap(); assert_eq!(payload["source"], "project_agent"); } #[test] fn test_discover_prefers_project_agent_on_conflict() { let _lock = CWD_TEST_LOCK.lock().unwrap(); let temp_dir = tempfile::tempdir().unwrap(); let _guard = CurrentDirGuard::enter(temp_dir.path()); let project_skill_dir = temp_dir.path().join(".picobot").join("skills").join("demo"); fs::create_dir_all(&project_skill_dir).unwrap(); fs::write( project_skill_dir.join("SKILL.md"), "---\ndescription: project version\n---\nProject body", ) .unwrap(); let agent_skill_dir = temp_dir.path().join(".agents").join("skills").join("demo"); fs::create_dir_all(&agent_skill_dir).unwrap(); fs::write( agent_skill_dir.join("SKILL.md"), "---\ndescription: agent version\n---\nAgent body", ) .unwrap(); let catalog = SkillCatalog::discover(&SkillsConfig { enabled: true, sources: vec!["project".to_string(), "project_agent".to_string()], max_index_chars: 4000, max_listed_skills: 32, }); let payload = catalog.activation_payload("demo").unwrap(); assert!(payload.contains("Source: project_agent")); assert!(payload.contains("Agent body")); } }