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>
276 lines
8.6 KiB
Rust
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(¶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());
|
|
}
|
|
}
|