//! Skill invocation tool for the Owlen agent //! //! Provides the Skill tool that allows the agent to invoke skills //! from plugins programmatically during a conversation. use color_eyre::eyre::{Result, eyre}; use plugins::PluginManager; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; /// Parameters for the Skill tool #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillParams { /// Name of the skill to invoke (e.g., "pdf", "xlsx", or "plugin:skill") pub skill: String, } /// Result of skill invocation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillResult { /// The skill name that was invoked pub skill_name: String, /// The skill content (instructions) pub content: String, /// Source of the skill (plugin name) pub source: String, } /// Skill registry for looking up and invoking skills pub struct SkillRegistry { /// Local skills directory (e.g., .owlen/skills/) local_skills_dir: PathBuf, /// Plugin manager for finding plugin skills plugin_manager: Option, } impl SkillRegistry { /// Create a new skill registry pub fn new() -> Self { Self { local_skills_dir: PathBuf::from(".owlen/skills"), plugin_manager: None, } } /// Create with custom local skills directory pub fn with_local_dir(mut self, dir: PathBuf) -> Self { self.local_skills_dir = dir; self } /// Set the plugin manager for discovering plugin skills pub fn with_plugin_manager(mut self, pm: PluginManager) -> Self { self.plugin_manager = Some(pm); self } /// Find and load a skill by name /// /// Skill names can be: /// - Simple name: "pdf" (searches local, then plugins) /// - Fully qualified: "plugin-name:skill-name" pub fn invoke(&self, skill_name: &str) -> Result { // Check for fully qualified name (plugin:skill) if let Some((plugin_name, skill_id)) = skill_name.split_once(':') { return self.load_plugin_skill(plugin_name, skill_id); } // Try local skills first if let Ok(result) = self.load_local_skill(skill_name) { return Ok(result); } // Try plugins if let Some(pm) = &self.plugin_manager { for plugin in pm.plugins() { if let Ok(result) = self.load_skill_from_plugin(plugin, skill_name) { return Ok(result); } } } Err(eyre!( "Skill '{}' not found.\n\nAvailable skills:\n{}", skill_name, self.list_available_skills().join("\n") )) } /// Load a local skill from .owlen/skills/ fn load_local_skill(&self, skill_name: &str) -> Result { // Try with and without .md extension let skill_file = self.local_skills_dir.join(format!("{}.md", skill_name)); let skill_dir = self.local_skills_dir.join(skill_name).join("SKILL.md"); let content = if skill_file.exists() { fs::read_to_string(&skill_file)? } else if skill_dir.exists() { fs::read_to_string(&skill_dir)? } else { return Err(eyre!("Local skill '{}' not found", skill_name)); }; Ok(SkillResult { skill_name: skill_name.to_string(), content: parse_skill_content(&content), source: "local".to_string(), }) } /// Load a skill from a specific plugin fn load_plugin_skill(&self, plugin_name: &str, skill_name: &str) -> Result { let pm = self.plugin_manager.as_ref() .ok_or_else(|| eyre!("Plugin manager not available"))?; for plugin in pm.plugins() { if plugin.manifest.name == plugin_name { return self.load_skill_from_plugin(plugin, skill_name); } } Err(eyre!("Plugin '{}' not found", plugin_name)) } /// Load a skill from a plugin fn load_skill_from_plugin(&self, plugin: &plugins::Plugin, skill_name: &str) -> Result { let skill_names = plugin.all_skill_names(); if !skill_names.contains(&skill_name.to_string()) { return Err(eyre!("Skill '{}' not found in plugin '{}'", skill_name, plugin.manifest.name)); } // Skills are in skills//SKILL.md let skill_path = plugin.base_path.join("skills").join(skill_name).join("SKILL.md"); if !skill_path.exists() { return Err(eyre!("Skill file not found: {:?}", skill_path)); } let content = fs::read_to_string(&skill_path)?; Ok(SkillResult { skill_name: skill_name.to_string(), content: parse_skill_content(&content), source: format!("plugin:{}", plugin.manifest.name), }) } /// List all available skills pub fn list_available_skills(&self) -> Vec { let mut skills = Vec::new(); // Local skills if self.local_skills_dir.exists() { if let Ok(entries) = fs::read_dir(&self.local_skills_dir) { for entry in entries.filter_map(|e| e.ok()) { let path = entry.path(); if path.is_file() && path.extension().map_or(false, |e| e == "md") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { skills.push(format!(" - {} (local)", name)); } } else if path.is_dir() && path.join("SKILL.md").exists() { if let Some(name) = path.file_name().and_then(|s| s.to_str()) { skills.push(format!(" - {} (local)", name)); } } } } } // Plugin skills if let Some(pm) = &self.plugin_manager { for plugin in pm.plugins() { for skill_name in plugin.all_skill_names() { skills.push(format!(" - {} (plugin:{})", skill_name, plugin.manifest.name)); } } } if skills.is_empty() { skills.push(" (no skills available)".to_string()); } skills } } impl Default for SkillRegistry { fn default() -> Self { Self::new() } } /// Parse skill content, extracting the body (stripping YAML frontmatter) fn parse_skill_content(content: &str) -> String { // Check for YAML frontmatter if content.starts_with("---") { // Find the end of frontmatter if let Some(end_idx) = content[3..].find("---") { let body_start = end_idx + 6; // Skip past the closing --- if body_start < content.len() { return content[body_start..].trim().to_string(); } } } content.trim().to_string() } /// Execute the Skill tool pub fn execute_skill(params: &SkillParams, registry: &SkillRegistry) -> Result { let result = registry.invoke(¶ms.skill)?; // Format output for injection into conversation Ok(format!( "## Skill: {} ({})\n\n{}", result.skill_name, result.source, result.content )) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn test_parse_skill_content_with_frontmatter() { let content = r#"--- name: test-skill description: A test skill --- # Test Skill This is the skill content."#; let parsed = parse_skill_content(content); assert!(parsed.starts_with("# Test Skill")); assert!(!parsed.contains("name: test-skill")); } #[test] fn test_parse_skill_content_without_frontmatter() { let content = "# Just Content\n\nNo frontmatter here."; let parsed = parse_skill_content(content); assert_eq!(parsed, content.trim()); } #[test] fn test_skill_registry_local() { let temp_dir = TempDir::new().unwrap(); let skills_dir = temp_dir.path().join(".owlen/skills"); fs::create_dir_all(&skills_dir).unwrap(); // Create a test skill fs::write(skills_dir.join("test.md"), "# Test Skill\n\nTest content.").unwrap(); let registry = SkillRegistry::new().with_local_dir(skills_dir); let result = registry.invoke("test").unwrap(); assert_eq!(result.skill_name, "test"); assert_eq!(result.source, "local"); assert!(result.content.contains("Test Skill")); } #[test] fn test_skill_not_found() { let registry = SkillRegistry::new(); assert!(registry.invoke("nonexistent").is_err()); } }