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::{Value, json};
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    /// Get the skill cache for internal tracking
43    pub fn cache(&self) -> &HashMap<String, String> {
44        &self.cache
45    }
46
47    async fn list_skills(&self) -> Result<Vec<String>> {
48        let mut skills = Vec::new();
49
50        if !self.skills_dir.exists() {
51            return Ok(skills);
52        }
53
54        let mut entries = tokio::fs::read_dir(&self.skills_dir).await?;
55        while let Some(entry) = entries.next_entry().await? {
56            let path = entry.path();
57            if path.is_dir() {
58                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
59                    // Check if SKILL.md exists in the directory
60                    let skill_md = path.join("SKILL.md");
61                    if skill_md.exists() {
62                        skills.push(name.to_string());
63                    }
64                }
65            } else if path.extension().is_some_and(|e| e == "md") {
66                if let Some(stem) = path.file_stem().and_then(|n| n.to_str()) {
67                    skills.push(stem.to_string());
68                }
69            }
70        }
71
72        Ok(skills)
73    }
74
75    async fn load_skill(&self, name: &str) -> Result<String> {
76        // Check for directory-based skill first
77        let dir_skill = self.skills_dir.join(name).join("SKILL.md");
78        if dir_skill.exists() {
79            return Ok(tokio::fs::read_to_string(&dir_skill).await?);
80        }
81
82        // Check for file-based skill
83        let file_skill = self.skills_dir.join(format!("{}.md", name));
84        if file_skill.exists() {
85            return Ok(tokio::fs::read_to_string(&file_skill).await?);
86        }
87
88        anyhow::bail!("Skill '{}' not found", name)
89    }
90}
91
92#[async_trait]
93impl Tool for SkillTool {
94    fn id(&self) -> &str {
95        "skill"
96    }
97
98    fn name(&self) -> &str {
99        "Skill"
100    }
101
102    fn description(&self) -> &str {
103        "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."
104    }
105
106    fn parameters(&self) -> Value {
107        json!({
108            "type": "object",
109            "properties": {
110                "action": {
111                    "type": "string",
112                    "description": "Action to perform: 'list' (show available skills) or 'load' (load a skill)",
113                    "enum": ["list", "load"]
114                },
115                "skill_name": {
116                    "type": "string",
117                    "description": "Name of the skill to load (required for 'load' action)"
118                }
119            },
120            "required": ["action"]
121        })
122    }
123
124    async fn execute(&self, args: Value) -> Result<ToolResult> {
125        let action = args["action"]
126            .as_str()
127            .ok_or_else(|| anyhow::anyhow!("action is required"))?;
128
129        match action {
130            "list" => {
131                let skills = self.list_skills().await?;
132                if skills.is_empty() {
133                    Ok(ToolResult::success(format!(
134                        "No skills found. Create skills in: {}\n\
135                        \n\
136                        Skill format:\n\
137                        - Directory: ~/.codetether/skills/<skill-name>/SKILL.md\n\
138                        - File: ~/.codetether/skills/<skill-name>.md\n\
139                        \n\
140                        A skill file contains markdown instructions the agent follows.",
141                        self.skills_dir.display()
142                    )))
143                } else {
144                    Ok(ToolResult::success(format!(
145                        "Available skills ({}):\n{}",
146                        skills.len(),
147                        skills
148                            .iter()
149                            .map(|s| format!("  - {}", s))
150                            .collect::<Vec<_>>()
151                            .join("\n")
152                    )))
153                }
154            }
155            "load" => {
156                let name = args["skill_name"]
157                    .as_str()
158                    .ok_or_else(|| anyhow::anyhow!("skill_name is required for 'load' action"))?;
159
160                match self.load_skill(name).await {
161                    Ok(content) => Ok(ToolResult::success(format!(
162                        "=== Skill: {} ===\n\n{}",
163                        name, content
164                    ))),
165                    Err(e) => Ok(ToolResult::error(format!("Failed to load skill: {}", e))),
166                }
167            }
168            _ => Ok(ToolResult::error(format!(
169                "Unknown action: {}. Use 'list' or 'load'.",
170                action
171            ))),
172        }
173    }
174}