1use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use std::sync::Arc;
11use std::time::Duration;
12use tokio::io::AsyncReadExt;
13use tokio::process::Command;
14
15use crate::error::{Error, Result};
16use crate::llm::ToolSpec;
17use crate::skills::Skill;
18use crate::tools::Tool;
19
20pub struct RunSkillScript {
22 skills: Arc<Vec<Skill>>,
23 workspace: PathBuf,
24 timeout: Duration,
25}
26
27impl RunSkillScript {
28 pub fn new(skills: Vec<Skill>, workspace: PathBuf, timeout: Duration) -> Self {
29 Self {
30 skills: Arc::new(skills),
31 workspace,
32 timeout,
33 }
34 }
35}
36
37#[async_trait]
38impl Tool for RunSkillScript {
39 fn spec(&self) -> ToolSpec {
40 ToolSpec {
41 name: "run_skill_script".into(),
42 description: "Run a script from a skill's scripts/ directory by skill and script name. The script executes in the workspace root.".into(),
43 parameters: json!({
44 "type": "object",
45 "properties": {
46 "skill": {
47 "type": "string",
48 "description": "Name of the skill (case-insensitive)"
49 },
50 "script": {
51 "type": "string",
52 "description": "Name of the script within the skill (case-insensitive)"
53 },
54 "args": {
55 "type": "string",
56 "description": "Optional arguments to pass to the script"
57 }
58 },
59 "required": ["skill", "script"]
60 }),
61 }
62 }
63
64 async fn execute(&self, arguments: Value) -> Result<String> {
65 let skill_name = arguments["skill"]
66 .as_str()
67 .ok_or_else(|| Error::BadToolArgs {
68 name: "run_skill_script".into(),
69 message: "missing required parameter: skill".to_string(),
70 })?;
71
72 let script_name = arguments["script"]
73 .as_str()
74 .ok_or_else(|| Error::BadToolArgs {
75 name: "run_skill_script".into(),
76 message: "missing required parameter: script".to_string(),
77 })?;
78
79 let skill = self
81 .skills
82 .iter()
83 .find(|s| s.name.to_lowercase() == skill_name.to_lowercase())
84 .ok_or_else(|| Error::Tool {
85 name: "run_skill_script".into(),
86 message: format!("skill not found: {skill_name}"),
87 })?;
88
89 let script = skill
91 .scripts
92 .iter()
93 .find(|s| s.name.to_lowercase() == script_name.to_lowercase())
94 .ok_or_else(|| {
95 let available: Vec<&str> = skill.scripts.iter().map(|s| s.name.as_str()).collect();
96 let available_list = if available.is_empty() {
97 "no scripts available for this skill".to_string()
98 } else {
99 format!("available scripts: {}", available.join(", "))
100 };
101 Error::Tool {
102 name: "run_skill_script".into(),
103 message: format!("script not found: '{script_name}'. {available_list}"),
104 }
105 })?;
106
107 let skill_dir = skill.path.parent().ok_or_else(|| Error::Tool {
109 name: "run_skill_script".into(),
110 message: "cannot determine skill directory".to_string(),
111 })?;
112
113 let canonical_script = std::fs::canonicalize(&script.path).map_err(|e| Error::Tool {
114 name: "run_skill_script".into(),
115 message: format!("cannot resolve script path: {e}"),
116 })?;
117
118 let canonical_skill_dir = std::fs::canonicalize(skill_dir).map_err(|e| Error::Tool {
119 name: "run_skill_script".into(),
120 message: format!("cannot resolve skill directory: {e}"),
121 })?;
122
123 if !canonical_script.starts_with(&canonical_skill_dir) {
124 return Err(Error::Tool {
125 name: "run_skill_script".into(),
126 message: "script path escapes skill directory".to_string(),
127 });
128 }
129
130 let args_str = arguments["args"].as_str().unwrap_or("");
132 let shell_command = if args_str.is_empty() {
133 script.path.to_string_lossy().to_string()
134 } else {
135 format!("{} {}", script.path.display(), args_str)
136 };
137
138 let mut cmd = Command::new("/bin/sh");
139 cmd.arg("-c")
140 .arg(&shell_command)
141 .current_dir(&self.workspace)
142 .stdout(Stdio::piped())
143 .stderr(Stdio::piped());
144
145 let mut child = cmd.spawn().map_err(|e| Error::Tool {
146 name: "run_skill_script".into(),
147 message: format!("spawn failed: {e}"),
148 })?;
149
150 let mut stdout = child.stdout.take().expect("stdout piped");
151 let mut stderr = child.stderr.take().expect("stderr piped");
152
153 let max_output: usize = 10000;
154 let stdout_task = tokio::spawn(async move { read_capped(&mut stdout, max_output).await });
155 let stderr_task = tokio::spawn(async move { read_capped(&mut stderr, max_output).await });
156
157 let wait = child.wait();
158 let status = match tokio::time::timeout(self.timeout, wait).await {
159 Ok(s) => s.map_err(|e| Error::Tool {
160 name: "run_skill_script".into(),
161 message: format!("wait failed: {e}"),
162 })?,
163 Err(_) => {
164 return Err(Error::Tool {
165 name: "run_skill_script".into(),
166 message: format!("script timed out after {:?}", self.timeout),
167 });
168 }
169 };
170
171 let out = stdout_task.await.unwrap_or_default();
172 let err = stderr_task.await.unwrap_or_default();
173 let code = status
174 .code()
175 .map(|c| c.to_string())
176 .unwrap_or_else(|| "signal".into());
177
178 Ok(format!(
179 "exit: {code}\n--- stdout ---\n{out}\n--- stderr ---\n{err}"
180 ))
181 }
182}
183
184async fn read_capped<R: AsyncReadExt + Unpin>(reader: &mut R, max: usize) -> String {
185 let mut buf = Vec::with_capacity(8 * 1024);
186 let mut tmp = [0u8; 8 * 1024];
187 loop {
188 match reader.read(&mut tmp).await {
189 Ok(0) => break,
190 Ok(n) => {
191 if buf.len() + n > max {
192 let take = max.saturating_sub(buf.len());
193 buf.extend_from_slice(&tmp[..take]);
194 buf.extend_from_slice(b"\n... [output truncated]");
195 let _ = tokio::io::copy(reader, &mut tokio::io::sink()).await;
196 break;
197 }
198 buf.extend_from_slice(&tmp[..n]);
199 }
200 Err(_) => break,
201 }
202 }
203 String::from_utf8_lossy(&buf).into_owned()
204}
205
206#[cfg(test)]
207#[cfg(not(target_os = "windows"))]
208mod tests {
209 use super::*;
210 use crate::skills::{SkillMode, SkillScript};
211 use tempfile::TempDir;
212
213 fn make_skill_with_scripts(tmp: &TempDir, scripts: &[(&str, &str, &str)]) -> Vec<Skill> {
214 let skill_dir = tmp.path().join("test-skill");
215 std::fs::create_dir(&skill_dir).unwrap();
216 std::fs::write(
217 skill_dir.join("SKILL.md"),
218 "---\nname: test-skill\ndescription: Test\n---\n\nBody",
219 )
220 .unwrap();
221
222 let scripts_dir = skill_dir.join("scripts");
223 std::fs::create_dir(&scripts_dir).unwrap();
224
225 let mut skill_scripts = Vec::new();
226 for (name, content, desc) in scripts {
227 let path = scripts_dir.join(name);
228 std::fs::write(&path, content).unwrap();
229 #[cfg(unix)]
231 {
232 use std::os::unix::fs::PermissionsExt;
233 let perms = std::fs::metadata(&path).unwrap().permissions();
234 let mut new_perms = perms;
235 new_perms.set_mode(0o755);
236 std::fs::set_permissions(&path, new_perms).unwrap();
237 }
238 skill_scripts.push(SkillScript {
239 name: name.to_string(),
240 path,
241 description: desc.to_string(),
242 });
243 }
244
245 vec![Skill {
246 name: "test-skill".to_string(),
247 description: "Test".to_string(),
248 path: skill_dir.join("SKILL.md"),
249 refs: vec![],
250 params: vec![],
251 scripts: skill_scripts,
252 mode: SkillMode::Manual,
253 triggers: vec![],
254 hint: String::new(),
255 depends_on: vec![],
256 sections: vec![],
257 }]
258 }
259
260 #[tokio::test]
261 async fn run_skill_script_executes_and_returns_output() {
262 let tmp = TempDir::new().unwrap();
263 let skills = make_skill_with_scripts(
264 &tmp,
265 &[("hello.sh", "#!/bin/sh\necho 'Hello, World!'", "Say hello")],
266 );
267 let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
268
269 let result = tool
270 .execute(json!({"skill": "test-skill", "script": "hello.sh"}))
271 .await
272 .unwrap();
273
274 assert!(result.contains("exit: 0"));
275 assert!(result.contains("Hello, World!"));
276 }
277
278 #[tokio::test]
279 async fn run_skill_script_errors_on_unknown_skill() {
280 let tmp = TempDir::new().unwrap();
281 let skills = make_skill_with_scripts(&tmp, &[]);
282 let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
283
284 let result = tool
285 .execute(json!({"skill": "nonexistent", "script": "hello.sh"}))
286 .await;
287
288 assert!(result.is_err());
289 let err = result.unwrap_err().to_string();
290 assert!(err.contains("skill not found"));
291 }
292
293 #[tokio::test]
294 async fn run_skill_script_errors_on_unknown_script() {
295 let tmp = TempDir::new().unwrap();
296 let skills = make_skill_with_scripts(&tmp, &[("hello.sh", "#!/bin/sh\necho hi", "Say hi")]);
297 let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
298
299 let result = tool
300 .execute(json!({"skill": "test-skill", "script": "nonexistent"}))
301 .await;
302
303 assert!(result.is_err());
304 let err = result.unwrap_err().to_string();
305 assert!(err.contains("script not found"));
306 assert!(err.contains("hello.sh"));
307 }
308
309 #[tokio::test]
310 async fn run_skill_script_passes_args() {
311 let tmp = TempDir::new().unwrap();
312 let skills = make_skill_with_scripts(
313 &tmp,
314 &[("echo.sh", "#!/bin/sh\necho \"args: $*\"", "Echo args")],
315 );
316 let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
317
318 let result = tool
319 .execute(
320 json!({"skill": "test-skill", "script": "echo.sh", "args": "--verbose --debug"}),
321 )
322 .await
323 .unwrap();
324
325 assert!(result.contains("exit: 0"));
326 assert!(result.contains("args: --verbose --debug"));
327 }
328
329 #[tokio::test]
330 async fn run_skill_script_respects_timeout() {
331 let tmp = TempDir::new().unwrap();
332 let skills = make_skill_with_scripts(
333 &tmp,
334 &[("sleep.sh", "#!/bin/sh\nsleep 999", "Sleep forever")],
335 );
336 let tool =
337 RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_millis(100));
338
339 let result = tool
340 .execute(json!({"skill": "test-skill", "script": "sleep.sh"}))
341 .await;
342
343 assert!(result.is_err());
344 let err = result.unwrap_err().to_string();
345 assert!(err.contains("timed out"));
346 }
347
348 #[tokio::test]
349 async fn run_skill_script_case_insensitive() {
350 let tmp = TempDir::new().unwrap();
351 let skills =
352 make_skill_with_scripts(&tmp, &[("Hello.sh", "#!/bin/sh\necho 'hi'", "Say hi")]);
353 let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
354
355 let result = tool
357 .execute(json!({"skill": "TEST-SKILL", "script": "HELLO.SH"}))
358 .await
359 .unwrap();
360
361 assert!(result.contains("exit: 0"));
362 assert!(result.contains("hi"));
363 }
364}