Skip to main content

zeroclaw_runtime/tools/
read_skill.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use zeroclaw_api::tool::{Tool, ToolResult};
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    allow_scripts: bool,
12}
13
14impl ReadSkillTool {
15    pub fn new(
16        workspace_dir: PathBuf,
17        open_skills_enabled: bool,
18        open_skills_dir: Option<String>,
19        allow_scripts: bool,
20    ) -> Self {
21        Self {
22            workspace_dir,
23            open_skills_enabled,
24            open_skills_dir,
25            allow_scripts,
26        }
27    }
28}
29
30#[async_trait]
31impl Tool for ReadSkillTool {
32    fn name(&self) -> &str {
33        "read_skill"
34    }
35
36    fn description(&self) -> &str {
37        "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."
38    }
39
40    fn parameters_schema(&self) -> serde_json::Value {
41        json!({
42            "type": "object",
43            "properties": {
44                "name": {
45                    "type": "string",
46                    "description": "The skill name exactly as listed in <available_skills>."
47                }
48            },
49            "required": ["name"]
50        })
51    }
52
53    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
54        let requested = args
55            .get("name")
56            .and_then(|value| value.as_str())
57            .map(str::trim)
58            .filter(|value| !value.is_empty())
59            .ok_or_else(|| {
60                ::zeroclaw_log::record!(
61                    WARN,
62                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
63                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
64                        .with_attrs(::serde_json::json!({"param": "name"})),
65                    "tool argument validation failed"
66                );
67
68                anyhow::Error::msg("Missing 'name' parameter")
69            })?;
70
71        let skills = crate::skills::load_skills_with_open_skills_settings(
72            &self.workspace_dir,
73            self.open_skills_enabled,
74            self.open_skills_dir.as_deref(),
75            self.allow_scripts,
76        );
77
78        let Some(skill) = skills
79            .iter()
80            .find(|skill| skill.name.eq_ignore_ascii_case(requested))
81        else {
82            let mut names: Vec<&str> = skills.iter().map(|skill| skill.name.as_str()).collect();
83            names.sort_unstable();
84            let available = if names.is_empty() {
85                "none".to_string()
86            } else {
87                names.join(", ")
88            };
89
90            return Ok(ToolResult {
91                success: false,
92                output: String::new(),
93                error: Some(format!(
94                    "Unknown skill '{requested}'. Available skills: {available}"
95                )),
96            });
97        };
98
99        let Some(location) = skill.location.as_ref() else {
100            return Ok(ToolResult {
101                success: false,
102                output: String::new(),
103                error: Some(format!(
104                    "Skill '{}' has no readable source location.",
105                    skill.name
106                )),
107            });
108        };
109
110        match tokio::fs::read_to_string(location).await {
111            Ok(output) => Ok(ToolResult {
112                success: true,
113                output,
114                error: None,
115            }),
116            Err(err) => Ok(ToolResult {
117                success: false,
118                output: String::new(),
119                error: Some(format!(
120                    "Failed to read skill '{}' from {}: {err}",
121                    skill.name,
122                    location.display()
123                )),
124            }),
125        }
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use tempfile::TempDir;
133
134    fn make_tool(tmp: &TempDir) -> ReadSkillTool {
135        ReadSkillTool::new(tmp.path().join("workspace"), false, None, false)
136    }
137
138    #[tokio::test]
139    async fn reads_markdown_skill_by_name() {
140        let tmp = TempDir::new().unwrap();
141        let skill_dir = tmp.path().join("workspace/skills/weather");
142        std::fs::create_dir_all(&skill_dir).unwrap();
143        std::fs::write(
144            skill_dir.join("SKILL.md"),
145            "# Weather\n\nUse this skill for forecast lookups.\n",
146        )
147        .unwrap();
148
149        let result = make_tool(&tmp)
150            .execute(json!({ "name": "weather" }))
151            .await
152            .unwrap();
153
154        assert!(result.success);
155        assert!(result.output.contains("# Weather"));
156        assert!(result.output.contains("forecast lookups"));
157    }
158
159    #[tokio::test]
160    async fn reads_toml_skill_manifest_by_name() {
161        let tmp = TempDir::new().unwrap();
162        let skill_dir = tmp.path().join("workspace/skills/deploy");
163        std::fs::create_dir_all(&skill_dir).unwrap();
164        std::fs::write(
165            skill_dir.join("SKILL.toml"),
166            r#"[skill]
167name = "deploy"
168description = "Ship safely"
169"#,
170        )
171        .unwrap();
172
173        let result = make_tool(&tmp)
174            .execute(json!({ "name": "deploy" }))
175            .await
176            .unwrap();
177
178        assert!(result.success);
179        assert!(result.output.contains("[skill]"));
180        assert!(result.output.contains("Ship safely"));
181    }
182
183    #[tokio::test]
184    async fn unknown_skill_lists_available_names() {
185        let tmp = TempDir::new().unwrap();
186        let skill_dir = tmp.path().join("workspace/skills/weather");
187        std::fs::create_dir_all(&skill_dir).unwrap();
188        std::fs::write(skill_dir.join("SKILL.md"), "# Weather\n").unwrap();
189
190        let result = make_tool(&tmp)
191            .execute(json!({ "name": "calendar" }))
192            .await
193            .unwrap();
194
195        assert!(!result.success);
196        assert_eq!(
197            result.error.as_deref(),
198            Some("Unknown skill 'calendar'. Available skills: weather")
199        );
200    }
201
202    #[tokio::test]
203    async fn script_skill_is_returned_when_allow_scripts_true() {
204        // Regression pin for #5697: a skill directory containing a script
205        // file (.sh) must be returned by read_skill when the tool was
206        // constructed with allow_scripts=true. Prior to the fix,
207        // ReadSkillTool forwarded a hardcoded None to
208        // load_skills_with_open_skills_settings, which unwrap_or(false)
209        // resolved to false, silently blocking the skill.
210        let tmp = TempDir::new().unwrap();
211        let skill_dir = tmp.path().join("workspace/skills/setup");
212        std::fs::create_dir_all(&skill_dir).unwrap();
213        std::fs::write(
214            skill_dir.join("SKILL.md"),
215            "# Setup\n\nRuns ./configure and logs.\n",
216        )
217        .unwrap();
218        std::fs::write(skill_dir.join("configure.sh"), "#!/bin/sh\necho ok\n").unwrap();
219
220        // Construct with allow_scripts=true. Pre-fix this resolved to false
221        // inside the loader and the skill was skipped.
222        let tool = ReadSkillTool::new(tmp.path().join("workspace"), false, None, true);
223        let result = tool.execute(json!({ "name": "setup" })).await.unwrap();
224
225        assert!(
226            result.success,
227            "script-bearing skill must be returned when allow_scripts=true; got error={:?}",
228            result.error
229        );
230        assert!(result.output.contains("# Setup"));
231    }
232}