Skip to main content

codetether_agent/tool/
skill.rs

1//! Skill tool: Load and invoke learned skill patterns
2
3use super::{Tool, ToolResult};
4use anyhow::Result;
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10/// Skill Tool - Load and execute skill patterns from skill files
11/// Skills are markdown files in ~/.codetether/skills/ that contain instructions
12/// for specific tasks like "code review", "testing", etc.
13pub struct SkillTool {
14    skills_dir: PathBuf,
15    #[allow(dead_code)]
16    cache: HashMap<String, String>,
17}
18
19impl Default for SkillTool {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl SkillTool {
26    pub fn new() -> Self {
27        let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
28        Self {
29            skills_dir: PathBuf::from(home).join(".codetether").join("skills"),
30            cache: HashMap::new(),
31        }
32    }
33
34    #[allow(dead_code)]
35    pub fn with_dir(dir: PathBuf) -> Self {
36        Self {
37            skills_dir: dir,
38            cache: HashMap::new(),
39        }
40    }
41
42    async fn list_skills(&self) -> Result<Vec<String>> {
43        let mut skills = Vec::new();
44        
45        if !self.skills_dir.exists() {
46            return Ok(skills);
47        }
48        
49        let mut entries = tokio::fs::read_dir(&self.skills_dir).await?;
50        while let Some(entry) = entries.next_entry().await? {
51            let path = entry.path();
52            if path.is_dir() {
53                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
54                    // Check if SKILL.md exists in the directory
55                    let skill_md = path.join("SKILL.md");
56                    if skill_md.exists() {
57                        skills.push(name.to_string());
58                    }
59                }
60            } else if path.extension().is_some_and(|e| e == "md") {
61                if let Some(stem) = path.file_stem().and_then(|n| n.to_str()) {
62                    skills.push(stem.to_string());
63                }
64            }
65        }
66        
67        Ok(skills)
68    }
69
70    async fn load_skill(&self, name: &str) -> Result<String> {
71        // Check for directory-based skill first
72        let dir_skill = self.skills_dir.join(name).join("SKILL.md");
73        if dir_skill.exists() {
74            return Ok(tokio::fs::read_to_string(&dir_skill).await?);
75        }
76        
77        // Check for file-based skill
78        let file_skill = self.skills_dir.join(format!("{}.md", name));
79        if file_skill.exists() {
80            return Ok(tokio::fs::read_to_string(&file_skill).await?);
81        }
82        
83        anyhow::bail!("Skill '{}' not found", name)
84    }
85}
86
87#[async_trait]
88impl Tool for SkillTool {
89    fn id(&self) -> &str {
90        "skill"
91    }
92
93    fn name(&self) -> &str {
94        "Skill"
95    }
96
97    fn description(&self) -> &str {
98        "Load and invoke learned skill patterns. Skills are reusable instruction sets for specific tasks like code review, testing, documentation, etc. Use 'list' action to see available skills, 'load' to read a skill's instructions."
99    }
100
101    fn parameters(&self) -> Value {
102        json!({
103            "type": "object",
104            "properties": {
105                "action": {
106                    "type": "string",
107                    "description": "Action to perform: 'list' (show available skills) or 'load' (load a skill)",
108                    "enum": ["list", "load"]
109                },
110                "skill_name": {
111                    "type": "string",
112                    "description": "Name of the skill to load (required for 'load' action)"
113                }
114            },
115            "required": ["action"]
116        })
117    }
118
119    async fn execute(&self, args: Value) -> Result<ToolResult> {
120        let action = args["action"]
121            .as_str()
122            .ok_or_else(|| anyhow::anyhow!("action is required"))?;
123
124        match action {
125            "list" => {
126                let skills = self.list_skills().await?;
127                if skills.is_empty() {
128                    Ok(ToolResult::success(format!(
129                        "No skills found. Create skills in: {}\n\
130                        \n\
131                        Skill format:\n\
132                        - Directory: ~/.codetether/skills/<skill-name>/SKILL.md\n\
133                        - File: ~/.codetether/skills/<skill-name>.md\n\
134                        \n\
135                        A skill file contains markdown instructions the agent follows.",
136                        self.skills_dir.display()
137                    )))
138                } else {
139                    Ok(ToolResult::success(format!(
140                        "Available skills ({}):\n{}",
141                        skills.len(),
142                        skills.iter().map(|s| format!("  - {}", s)).collect::<Vec<_>>().join("\n")
143                    )))
144                }
145            }
146            "load" => {
147                let name = args["skill_name"]
148                    .as_str()
149                    .ok_or_else(|| anyhow::anyhow!("skill_name is required for 'load' action"))?;
150                
151                match self.load_skill(name).await {
152                    Ok(content) => Ok(ToolResult::success(format!(
153                        "=== Skill: {} ===\n\n{}",
154                        name, content
155                    ))),
156                    Err(e) => Ok(ToolResult::error(format!("Failed to load skill: {}", e))),
157                }
158            }
159            _ => Ok(ToolResult::error(format!("Unknown action: {}. Use 'list' or 'load'.", action))),
160        }
161    }
162}