Skip to main content

construct/tools/
read_skill.rs

1use super::traits::{Tool, ToolResult};
2use async_trait::async_trait;
3use serde_json::json;
4use std::path::PathBuf;
5
6/// Compact-mode helper for loading a skill's source file on demand.
7pub struct ReadSkillTool {
8    workspace_dir: PathBuf,
9    open_skills_enabled: bool,
10    open_skills_dir: Option<String>,
11}
12
13impl ReadSkillTool {
14    pub fn new(
15        workspace_dir: PathBuf,
16        open_skills_enabled: bool,
17        open_skills_dir: Option<String>,
18    ) -> Self {
19        Self {
20            workspace_dir,
21            open_skills_enabled,
22            open_skills_dir,
23        }
24    }
25}
26
27#[async_trait]
28impl Tool for ReadSkillTool {
29    fn name(&self) -> &str {
30        "read_skill"
31    }
32
33    fn description(&self) -> &str {
34        "Read the full source file for an available skill by name. Use this in compact skills mode when you need the complete skill instructions without remembering file paths."
35    }
36
37    fn parameters_schema(&self) -> serde_json::Value {
38        json!({
39            "type": "object",
40            "properties": {
41                "name": {
42                    "type": "string",
43                    "description": "The skill name exactly as listed in <available_skills>."
44                }
45            },
46            "required": ["name"]
47        })
48    }
49
50    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
51        let requested = args
52            .get("name")
53            .and_then(|value| value.as_str())
54            .map(str::trim)
55            .filter(|value| !value.is_empty())
56            .ok_or_else(|| anyhow::anyhow!("Missing 'name' parameter"))?;
57
58        let skills = crate::skills::load_skills_with_open_skills_settings(
59            &self.workspace_dir,
60            self.open_skills_enabled,
61            self.open_skills_dir.as_deref(),
62        );
63
64        let Some(skill) = skills
65            .iter()
66            .find(|skill| skill.name.eq_ignore_ascii_case(requested))
67        else {
68            let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
69            names.sort_unstable();
70            let available = if names.is_empty() {
71                "none".to_string()
72            } else {
73                names.join(", ")
74            };
75
76            return Ok(ToolResult {
77                success: false,
78                output: String::new(),
79                error: Some(format!(
80                    "Unknown skill '{requested}'. Available skills: {available}"
81                )),
82            });
83        };
84
85        let Some(location) = skill.location.as_ref() else {
86            return Ok(ToolResult {
87                success: false,
88                output: String::new(),
89                error: Some(format!(
90                    "Skill '{}' has no readable source location.",
91                    skill.name
92                )),
93            });
94        };
95
96        match tokio::fs::read_to_string(location).await {
97            Ok(output) => Ok(ToolResult {
98                success: true,
99                output,
100                error: None,
101            }),
102            Err(err) => Ok(ToolResult {
103                success: false,
104                output: String::new(),
105                error: Some(format!(
106                    "Failed to read skill '{}' from {}: {err}",
107                    skill.name,
108                    location.display()
109                )),
110            }),
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use tempfile::TempDir;
119
120    fn make_tool(tmp: &TempDir) -> ReadSkillTool {
121        ReadSkillTool::new(tmp.path().join("workspace"), false, None)
122    }
123
124    #[tokio::test]
125    async fn reads_markdown_skill_by_name() {
126        let tmp = TempDir::new().unwrap();
127        let skill_dir = tmp.path().join("workspace/skills/weather");
128        std::fs::create_dir_all(&skill_dir).unwrap();
129        std::fs::write(
130            skill_dir.join("SKILL.md"),
131            "# Weather\n\nUse this skill for forecast lookups.\n",
132        )
133        .unwrap();
134
135        let result = make_tool(&tmp)
136            .execute(json!({ "name": "weather" }))
137            .await
138            .unwrap();
139
140        assert!(result.success);
141        assert!(result.output.contains("# Weather"));
142        assert!(result.output.contains("forecast lookups"));
143    }
144
145    #[tokio::test]
146    async fn reads_toml_skill_manifest_by_name() {
147        let tmp = TempDir::new().unwrap();
148        let skill_dir = tmp.path().join("workspace/skills/deploy");
149        std::fs::create_dir_all(&skill_dir).unwrap();
150        std::fs::write(
151            skill_dir.join("SKILL.toml"),
152            r#"[skill]
153name = "deploy"
154description = "Ship safely"
155"#,
156        )
157        .unwrap();
158
159        let result = make_tool(&tmp)
160            .execute(json!({ "name": "deploy" }))
161            .await
162            .unwrap();
163
164        assert!(result.success);
165        assert!(result.output.contains("[skill]"));
166        assert!(result.output.contains("Ship safely"));
167    }
168
169    #[tokio::test]
170    async fn unknown_skill_lists_available_names() {
171        let tmp = TempDir::new().unwrap();
172        let skill_dir = tmp.path().join("workspace/skills/weather");
173        std::fs::create_dir_all(&skill_dir).unwrap();
174        std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap();
175
176        let result = make_tool(&tmp)
177            .execute(json!({ "name": "calendar" }))
178            .await
179            .unwrap();
180
181        assert!(!result.success);
182        assert_eq!(
183            result.error.as_deref(),
184            Some("Unknown skill 'calendar'. Available skills: weather")
185        );
186    }
187}