Skip to main content

recursive/tools/
run_skill_script.rs

1//! Tool to run a script from a skill's `scripts/` directory.
2//!
3//! Finds the named script within a named skill, executes it with optional
4//! arguments, and returns the combined stdout+stderr output.
5
6use 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
20/// Tool to run a script from a skill's scripts/ directory.
21pub 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        // Find the skill (case-insensitive)
80        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        // Find the script within the skill (case-insensitive)
90        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        // Security: script path must be within the skill directory
108        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        // Build the command
131        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)]
207mod tests {
208    use super::*;
209    use crate::skills::{SkillMode, SkillScript};
210    use tempfile::TempDir;
211
212    fn make_skill_with_scripts(tmp: &TempDir, scripts: &[(&str, &str, &str)]) -> Vec<Skill> {
213        let skill_dir = tmp.path().join("test-skill");
214        std::fs::create_dir(&skill_dir).unwrap();
215        std::fs::write(
216            skill_dir.join("SKILL.md"),
217            "---\nname: test-skill\ndescription: Test\n---\n\nBody",
218        )
219        .unwrap();
220
221        let scripts_dir = skill_dir.join("scripts");
222        std::fs::create_dir(&scripts_dir).unwrap();
223
224        let mut skill_scripts = Vec::new();
225        for (name, content, desc) in scripts {
226            let path = scripts_dir.join(name);
227            std::fs::write(&path, content).unwrap();
228            // Make executable on Unix
229            #[cfg(unix)]
230            {
231                use std::os::unix::fs::PermissionsExt;
232                let perms = std::fs::metadata(&path).unwrap().permissions();
233                let mut new_perms = perms;
234                new_perms.set_mode(0o755);
235                std::fs::set_permissions(&path, new_perms).unwrap();
236            }
237            skill_scripts.push(SkillScript {
238                name: name.to_string(),
239                path,
240                description: desc.to_string(),
241            });
242        }
243
244        vec![Skill {
245            name: "test-skill".to_string(),
246            description: "Test".to_string(),
247            path: skill_dir.join("SKILL.md"),
248            refs: vec![],
249            params: vec![],
250            scripts: skill_scripts,
251            mode: SkillMode::Manual,
252            triggers: vec![],
253            hint: String::new(),
254            depends_on: vec![],
255            sections: vec![],
256        }]
257    }
258
259    #[tokio::test]
260    async fn run_skill_script_executes_and_returns_output() {
261        let tmp = TempDir::new().unwrap();
262        let skills = make_skill_with_scripts(
263            &tmp,
264            &[("hello.sh", "#!/bin/sh\necho 'Hello, World!'", "Say hello")],
265        );
266        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
267
268        let result = tool
269            .execute(json!({"skill": "test-skill", "script": "hello.sh"}))
270            .await
271            .unwrap();
272
273        assert!(result.contains("exit: 0"));
274        assert!(result.contains("Hello, World!"));
275    }
276
277    #[tokio::test]
278    async fn run_skill_script_errors_on_unknown_skill() {
279        let tmp = TempDir::new().unwrap();
280        let skills = make_skill_with_scripts(&tmp, &[]);
281        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
282
283        let result = tool
284            .execute(json!({"skill": "nonexistent", "script": "hello.sh"}))
285            .await;
286
287        assert!(result.is_err());
288        let err = result.unwrap_err().to_string();
289        assert!(err.contains("skill not found"));
290    }
291
292    #[tokio::test]
293    async fn run_skill_script_errors_on_unknown_script() {
294        let tmp = TempDir::new().unwrap();
295        let skills = make_skill_with_scripts(&tmp, &[("hello.sh", "#!/bin/sh\necho hi", "Say hi")]);
296        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
297
298        let result = tool
299            .execute(json!({"skill": "test-skill", "script": "nonexistent"}))
300            .await;
301
302        assert!(result.is_err());
303        let err = result.unwrap_err().to_string();
304        assert!(err.contains("script not found"));
305        assert!(err.contains("hello.sh"));
306    }
307
308    #[tokio::test]
309    async fn run_skill_script_passes_args() {
310        let tmp = TempDir::new().unwrap();
311        let skills = make_skill_with_scripts(
312            &tmp,
313            &[("echo.sh", "#!/bin/sh\necho \"args: $*\"", "Echo args")],
314        );
315        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
316
317        let result = tool
318            .execute(
319                json!({"skill": "test-skill", "script": "echo.sh", "args": "--verbose --debug"}),
320            )
321            .await
322            .unwrap();
323
324        assert!(result.contains("exit: 0"));
325        assert!(result.contains("args: --verbose --debug"));
326    }
327
328    #[tokio::test]
329    async fn run_skill_script_respects_timeout() {
330        let tmp = TempDir::new().unwrap();
331        let skills = make_skill_with_scripts(
332            &tmp,
333            &[("sleep.sh", "#!/bin/sh\nsleep 999", "Sleep forever")],
334        );
335        let tool =
336            RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_millis(100));
337
338        let result = tool
339            .execute(json!({"skill": "test-skill", "script": "sleep.sh"}))
340            .await;
341
342        assert!(result.is_err());
343        let err = result.unwrap_err().to_string();
344        assert!(err.contains("timed out"));
345    }
346
347    #[tokio::test]
348    async fn run_skill_script_case_insensitive() {
349        let tmp = TempDir::new().unwrap();
350        let skills =
351            make_skill_with_scripts(&tmp, &[("Hello.sh", "#!/bin/sh\necho 'hi'", "Say hi")]);
352        let tool = RunSkillScript::new(skills, tmp.path().to_path_buf(), Duration::from_secs(30));
353
354        // Different case for both skill and script
355        let result = tool
356            .execute(json!({"skill": "TEST-SKILL", "script": "HELLO.SH"}))
357            .await
358            .unwrap();
359
360        assert!(result.contains("exit: 0"));
361        assert!(result.contains("hi"));
362    }
363}