1use std::collections::HashMap;
2use std::path::Path;
3
4use deepstrike_core::types::skill::SkillMetadata;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum SkillKind {
9 Prompt,
11 ComputeJson,
13 PythonScript,
15}
16
17impl SkillKind {
18 pub fn from_path(path: &Path) -> Option<Self> {
19 match path.extension().and_then(|e| e.to_str()) {
20 Some("md") => Some(Self::Prompt),
21 Some("json") => Some(Self::ComputeJson),
22 Some("py") => Some(Self::PythonScript),
23 _ => None,
24 }
25 }
26}
27
28pub fn resolve_skill_path(skill_dir: &Path, name: &str) -> Option<(std::path::PathBuf, SkillKind)> {
31 for (ext, kind) in &[
32 ("md", SkillKind::Prompt),
33 ("json", SkillKind::ComputeJson),
34 ("py", SkillKind::PythonScript),
35 ] {
36 let path = skill_dir.join(format!("{name}.{ext}"));
37 if path.exists() {
38 return Some((path, *kind));
39 }
40 }
41 None
42}
43
44pub fn parse_json_skill(path: &Path) -> Option<SkillMetadata> {
58 let content = std::fs::read_to_string(path).ok()?;
59 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
60 let name = v["name"].as_str()?.to_string();
61 let description = v["description"].as_str().unwrap_or("").to_string();
62 let mut meta = SkillMetadata::new(name, description);
63 if let Some(w) = v["when_to_use"].as_str() {
64 meta = meta.with_when_to_use(w);
65 }
66 if let Some(tools) = v["allowed_tools"].as_array() {
68 meta.allowed_tools = tools.iter().filter_map(|t| t.as_str()).map(Into::into).collect();
69 }
70 Some(meta)
71}
72
73pub fn execute_json_skill(path: &Path, args: &HashMap<String, serde_json::Value>) -> (String, bool) {
78 let content = match std::fs::read_to_string(path) {
79 Ok(c) => c,
80 Err(e) => return (format!("error: could not read skill file: {e}"), true),
81 };
82 let v: serde_json::Value = match serde_json::from_str(&content) {
83 Ok(v) => v,
84 Err(_) => return ("error: invalid JSON skill format".into(), true),
85 };
86 let template = match v["template"].as_str() {
87 Some(t) => t.to_string(),
88 None => return ("error: JSON skill missing 'template' field".into(), true),
89 };
90 (render_template(&template, args), false)
91}
92
93pub fn parse_python_skill(path: &Path) -> Option<SkillMetadata> {
104 let content = std::fs::read_to_string(path).ok()?;
105 let name = extract_py_meta(&content, "name")?;
106 let description = extract_py_meta(&content, "description").unwrap_or_default();
107 let mut meta = SkillMetadata::new(name, description);
108 if let Some(w) = extract_py_meta(&content, "when_to_use") {
109 meta = meta.with_when_to_use(w);
110 }
111 if let Some(t) = extract_py_meta(&content, "allowed_tools") {
113 meta.allowed_tools = t.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).map(Into::into).collect();
114 }
115 Some(meta)
116}
117
118pub struct PythonSkillPolicy {
120 pub timeout_ms: u64,
122 pub max_output_bytes: usize,
124}
125
126impl Default for PythonSkillPolicy {
127 fn default() -> Self {
128 Self {
129 timeout_ms: 30_000,
130 max_output_bytes: 65_536,
131 }
132 }
133}
134
135pub async fn execute_python_skill(
145 path: &Path,
146 args: &HashMap<String, serde_json::Value>,
147 sandbox_base: Option<&Path>,
148 policy: &PythonSkillPolicy,
149) -> (String, bool) {
150 let script = match std::fs::read_to_string(path) {
151 Ok(s) => s,
152 Err(e) => return (format!("error: could not read skill file: {e}"), true),
153 };
154 let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".into());
155
156 let base = sandbox_base
158 .map(|p| p.to_path_buf())
159 .unwrap_or_else(|| std::env::temp_dir().join("deepstrike-skills"));
160 let invocation_id = uuid::Uuid::new_v4().to_string();
161 let work_dir = base.join(invocation_id);
162
163 if let Err(e) = tokio::fs::create_dir_all(&work_dir).await {
164 return (format!("error: cannot create sandbox dir: {e}"), true);
165 }
166
167 let mut child = match tokio::process::Command::new("python3")
168 .arg("-c")
169 .arg(&script)
170 .env_clear()
171 .env("SKILL_ARGS", &args_json)
172 .env("HOME", work_dir.to_string_lossy().as_ref())
173 .env("TMPDIR", work_dir.to_string_lossy().as_ref())
174 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
175 .current_dir(&work_dir)
176 .stdin(std::process::Stdio::null())
177 .stdout(std::process::Stdio::piped())
178 .stderr(std::process::Stdio::piped())
179 .spawn()
180 {
181 Ok(c) => c,
182 Err(e) => {
183 let _ = tokio::fs::remove_dir_all(&work_dir).await;
184 return (format!("error: failed to spawn python3: {e}"), true);
185 }
186 };
187
188 use tokio::io::AsyncReadExt;
191 let stdout_pipe = child.stdout.take().expect("stdout was piped");
192 let stderr_pipe = child.stderr.take().expect("stderr was piped");
193 let read_out = tokio::spawn(async move {
194 let mut buf = Vec::new();
195 tokio::io::BufReader::new(stdout_pipe).read_to_end(&mut buf).await.ok();
196 buf
197 });
198 let read_err = tokio::spawn(async move {
199 let mut buf = Vec::new();
200 tokio::io::BufReader::new(stderr_pipe).read_to_end(&mut buf).await.ok();
201 buf
202 });
203
204 let timeout_dur = tokio::time::Duration::from_millis(policy.timeout_ms);
205 let timed_out = tokio::time::timeout(timeout_dur, child.wait()).await;
206
207 let _ = tokio::fs::remove_dir_all(&work_dir).await;
209
210 let is_error = match timed_out {
211 Ok(Ok(status)) => !status.success(),
212 Ok(Err(e)) => {
213 read_out.abort();
214 read_err.abort();
215 return (format!("error: subprocess IO error: {e}"), true);
216 }
217 Err(_) => {
218 child.kill().await.ok();
219 child.wait().await.ok();
220 read_out.abort();
221 read_err.abort();
222 return (
223 format!("error: skill timed out after {}ms", policy.timeout_ms),
224 true,
225 );
226 }
227 };
228
229 let out_bytes = read_out.await.unwrap_or_default();
230 let err_bytes = read_err.await.unwrap_or_default();
231 let mut combined = [out_bytes, err_bytes].concat();
232 if combined.len() > policy.max_output_bytes {
233 combined.truncate(policy.max_output_bytes);
234 combined.extend_from_slice(b"\n[output truncated]");
235 }
236 let text = String::from_utf8_lossy(&combined).into_owned();
237 (if text.is_empty() { "(no output)".into() } else { text }, is_error)
238}
239
240fn render_template(template: &str, args: &HashMap<String, serde_json::Value>) -> String {
243 let mut out = template.to_string();
244 for (k, v) in args {
245 let placeholder = format!("{{{{{k}}}}}");
246 let val = match v {
247 serde_json::Value::String(s) => s.clone(),
248 other => other.to_string(),
249 };
250 out = out.replace(&placeholder, &val);
251 }
252 out
253}
254
255fn extract_py_meta(content: &str, key: &str) -> Option<String> {
258 let prefix = format!("# {key}:");
259 content
260 .lines()
261 .find(|l| l.trim_start().starts_with(&prefix))
262 .map(|l| {
263 let pos = l.find(&prefix).unwrap() + prefix.len();
264 l[pos..].trim().to_string()
265 })
266}
267
268pub fn scan_skill_dir(dir: &Path) -> Vec<SkillMetadata> {
275 let Ok(entries) = std::fs::read_dir(dir) else {
276 return Vec::new();
277 };
278
279 let mut by_name: HashMap<String, (u8, SkillMetadata)> = HashMap::new();
281
282 for entry in entries.flatten() {
283 let path = entry.path();
284 let Some(kind) = SkillKind::from_path(&path) else {
285 continue;
286 };
287 let name = path
288 .file_stem()
289 .and_then(|s| s.to_str())
290 .unwrap_or("")
291 .to_string();
292 if name.is_empty() {
293 continue;
294 }
295
296 let priority = match kind {
297 SkillKind::Prompt => 0u8,
298 SkillKind::ComputeJson => 1,
299 SkillKind::PythonScript => 2,
300 };
301
302 let meta = match kind {
303 SkillKind::Prompt => {
304 if let Ok(content) = std::fs::read_to_string(&path) {
305 let mut meta = SkillMetadata::new(name.clone(), parse_md_description(&content));
306 if let Some(line) =
308 content.lines().find(|l| l.trim_start().starts_with("allowed_tools:"))
309 {
310 let raw = line.splitn(2, ':').nth(1).unwrap_or("");
311 meta.allowed_tools = raw
312 .trim()
313 .trim_matches(|c| c == '[' || c == ']')
314 .split(',')
315 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\''))
316 .filter(|s| !s.is_empty())
317 .map(Into::into)
318 .collect();
319 }
320 Some(meta)
321 } else {
322 None
323 }
324 }
325 SkillKind::ComputeJson => parse_json_skill(&path),
326 SkillKind::PythonScript => parse_python_skill(&path),
327 };
328
329 if let Some(meta) = meta {
330 let entry = by_name.entry(name).or_insert((255, meta.clone()));
331 if priority < entry.0 {
332 *entry = (priority, meta);
333 }
334 }
335 }
336
337 by_name.into_values().map(|(_, m)| m).collect()
338}
339
340fn parse_md_description(content: &str) -> String {
341 let body = content.trim_start();
342 if !body.starts_with("---") {
343 return String::new();
344 }
345 let rest = &body[3..];
346 let end = rest.find("\n---").unwrap_or(rest.len());
347 for line in rest[..end].lines() {
348 if let Some(val) = line.strip_prefix("description:") {
349 return val.trim().to_string();
350 }
351 }
352 String::new()
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358
359 #[test]
360 fn skill_kind_from_extension() {
361 assert_eq!(SkillKind::from_path(Path::new("a.md")), Some(SkillKind::Prompt));
362 assert_eq!(SkillKind::from_path(Path::new("b.json")), Some(SkillKind::ComputeJson));
363 assert_eq!(SkillKind::from_path(Path::new("c.py")), Some(SkillKind::PythonScript));
364 assert_eq!(SkillKind::from_path(Path::new("d.ts")), None);
365 assert_eq!(SkillKind::from_path(Path::new("noext")), None);
366 }
367
368 #[test]
369 fn template_substitutes_string_and_numeric() {
370 let mut args = HashMap::new();
371 args.insert("name".into(), serde_json::Value::String("Alice".into()));
372 args.insert("count".into(), serde_json::json!(42));
373 let result = render_template("Hello, {{name}}! You have {{count}} items.", &args);
374 assert_eq!(result, "Hello, Alice! You have 42 items.");
375 }
376
377 #[test]
378 fn template_leaves_unknown_placeholders() {
379 let args = HashMap::new();
380 let result = render_template("Hi {{name}}!", &args);
381 assert_eq!(result, "Hi {{name}}!");
382 }
383
384 #[test]
385 fn extract_py_meta_parses_comment_lines() {
386 let src = "# name: my_skill\n# description: Does stuff\nimport os\n";
387 assert_eq!(extract_py_meta(src, "name").as_deref(), Some("my_skill"));
388 assert_eq!(extract_py_meta(src, "description").as_deref(), Some("Does stuff"));
389 assert_eq!(extract_py_meta(src, "missing"), None);
390 }
391
392 #[test]
393 fn parse_md_description_extracts_frontmatter() {
394 let md = "---\ndescription: A useful skill\nauthor: test\n---\n# Heading\n";
395 assert_eq!(parse_md_description(md), "A useful skill");
396 }
397
398 #[test]
399 fn parse_md_description_empty_without_frontmatter() {
400 assert_eq!(parse_md_description("# No frontmatter"), "");
401 }
402}