Files
owlen/crates/tools/skill/src/lib.rs
vikingowl 4a07b97eab feat(ui): add autocomplete, command help, and streaming improvements
TUI Enhancements:
- Add autocomplete dropdown with fuzzy filtering for slash commands
- Fix autocomplete: Tab confirms selection, Enter submits message
- Add command help overlay with scroll support (j/k, arrows, Page Up/Down)
- Brighten Tokyo Night theme colors for better readability
- Add todo panel component for task display
- Add rich command output formatting (tables, trees, lists)

Streaming Fixes:
- Refactor to non-blocking background streaming with channel events
- Add StreamStart/StreamEnd/StreamError events
- Fix LlmChunk to append instead of creating new messages
- Display user message immediately before LLM call

New Components:
- completions.rs: Command completion engine with fuzzy matching
- autocomplete.rs: Inline autocomplete dropdown
- command_help.rs: Modal help overlay with scrolling
- todo_panel.rs: Todo list display panel
- output.rs: Rich formatted output (tables, trees, code blocks)
- commands.rs: Built-in command implementations

Planning Mode Groundwork:
- Add EnterPlanMode/ExitPlanMode tools scaffolding
- Add Skill tool for plugin skill invocation
- Extend permissions with planning mode support
- Add compact.rs stub for context compaction

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 19:03:33 +01:00

276 lines
8.6 KiB
Rust

//! 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<PluginManager>,
}
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<SkillResult> {
// 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<SkillResult> {
// 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<SkillResult> {
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<SkillResult> {
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/<name>/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<String> {
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<String> {
let result = registry.invoke(&params.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());
}
}