zeroclaw_runtime/tools/
read_skill.rs1use async_trait::async_trait;
2use serde_json::json;
3use std::path::PathBuf;
4use zeroclaw_api::tool::{Tool, ToolResult};
5
6pub 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 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 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}