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
69 .iter()
70 .filter_map(|t| t.as_str())
71 .map(Into::into)
72 .collect();
73 }
74 Some(meta)
75}
76
77pub fn execute_json_skill(
82 path: &Path,
83 args: &HashMap<String, serde_json::Value>,
84) -> (String, bool) {
85 let content = match std::fs::read_to_string(path) {
86 Ok(c) => c,
87 Err(e) => return (format!("error: could not read skill file: {e}"), true),
88 };
89 let v: serde_json::Value = match serde_json::from_str(&content) {
90 Ok(v) => v,
91 Err(_) => return ("error: invalid JSON skill format".into(), true),
92 };
93 let template = match v["template"].as_str() {
94 Some(t) => t.to_string(),
95 None => return ("error: JSON skill missing 'template' field".into(), true),
96 };
97 (render_template(&template, args), false)
98}
99
100pub fn parse_python_skill(path: &Path) -> Option<SkillMetadata> {
111 let content = std::fs::read_to_string(path).ok()?;
112 let name = extract_py_meta(&content, "name")?;
113 let description = extract_py_meta(&content, "description").unwrap_or_default();
114 let mut meta = SkillMetadata::new(name, description);
115 if let Some(w) = extract_py_meta(&content, "when_to_use") {
116 meta = meta.with_when_to_use(w);
117 }
118 if let Some(t) = extract_py_meta(&content, "allowed_tools") {
120 meta.allowed_tools = t
121 .split(',')
122 .map(|s| s.trim())
123 .filter(|s| !s.is_empty())
124 .map(Into::into)
125 .collect();
126 }
127 Some(meta)
128}
129
130pub struct PythonSkillPolicy {
132 pub timeout_ms: u64,
134 pub max_output_bytes: usize,
136}
137
138impl Default for PythonSkillPolicy {
139 fn default() -> Self {
140 Self {
141 timeout_ms: 30_000,
142 max_output_bytes: 65_536,
143 }
144 }
145}
146
147pub async fn execute_python_skill(
157 path: &Path,
158 args: &HashMap<String, serde_json::Value>,
159 sandbox_base: Option<&Path>,
160 policy: &PythonSkillPolicy,
161) -> (String, bool) {
162 let script = match std::fs::read_to_string(path) {
163 Ok(s) => s,
164 Err(e) => return (format!("error: could not read skill file: {e}"), true),
165 };
166 let args_json = serde_json::to_string(args).unwrap_or_else(|_| "{}".into());
167
168 let base = sandbox_base
170 .map(|p| p.to_path_buf())
171 .unwrap_or_else(|| std::env::temp_dir().join("deepstrike-skills"));
172 let invocation_id = uuid::Uuid::new_v4().to_string();
173 let work_dir = base.join(invocation_id);
174
175 if let Err(e) = tokio::fs::create_dir_all(&work_dir).await {
176 return (format!("error: cannot create sandbox dir: {e}"), true);
177 }
178
179 let mut child = match tokio::process::Command::new("python3")
180 .arg("-c")
181 .arg(&script)
182 .env_clear()
183 .env("SKILL_ARGS", &args_json)
184 .env("HOME", work_dir.to_string_lossy().as_ref())
185 .env("TMPDIR", work_dir.to_string_lossy().as_ref())
186 .env("PATH", "/usr/local/bin:/usr/bin:/bin")
187 .current_dir(&work_dir)
188 .stdin(std::process::Stdio::null())
189 .stdout(std::process::Stdio::piped())
190 .stderr(std::process::Stdio::piped())
191 .spawn()
192 {
193 Ok(c) => c,
194 Err(e) => {
195 let _ = tokio::fs::remove_dir_all(&work_dir).await;
196 return (format!("error: failed to spawn python3: {e}"), true);
197 }
198 };
199
200 use tokio::io::AsyncReadExt;
203 let stdout_pipe = child.stdout.take().expect("stdout was piped");
204 let stderr_pipe = child.stderr.take().expect("stderr was piped");
205 let read_out = tokio::spawn(async move {
206 let mut buf = Vec::new();
207 tokio::io::BufReader::new(stdout_pipe)
208 .read_to_end(&mut buf)
209 .await
210 .ok();
211 buf
212 });
213 let read_err = tokio::spawn(async move {
214 let mut buf = Vec::new();
215 tokio::io::BufReader::new(stderr_pipe)
216 .read_to_end(&mut buf)
217 .await
218 .ok();
219 buf
220 });
221
222 let timeout_dur = tokio::time::Duration::from_millis(policy.timeout_ms);
223 let timed_out = tokio::time::timeout(timeout_dur, child.wait()).await;
224
225 let _ = tokio::fs::remove_dir_all(&work_dir).await;
227
228 let is_error = match timed_out {
229 Ok(Ok(status)) => !status.success(),
230 Ok(Err(e)) => {
231 read_out.abort();
232 read_err.abort();
233 return (format!("error: subprocess IO error: {e}"), true);
234 }
235 Err(_) => {
236 child.kill().await.ok();
237 child.wait().await.ok();
238 read_out.abort();
239 read_err.abort();
240 return (
241 format!("error: skill timed out after {}ms", policy.timeout_ms),
242 true,
243 );
244 }
245 };
246
247 let out_bytes = read_out.await.unwrap_or_default();
248 let err_bytes = read_err.await.unwrap_or_default();
249 let mut combined = [out_bytes, err_bytes].concat();
250 if combined.len() > policy.max_output_bytes {
251 combined.truncate(policy.max_output_bytes);
252 combined.extend_from_slice(b"\n[output truncated]");
253 }
254 let text = String::from_utf8_lossy(&combined).into_owned();
255 (
256 if text.is_empty() {
257 "(no output)".into()
258 } else {
259 text
260 },
261 is_error,
262 )
263}
264
265fn render_template(template: &str, args: &HashMap<String, serde_json::Value>) -> String {
268 let mut out = template.to_string();
269 for (k, v) in args {
270 let placeholder = format!("{{{{{k}}}}}");
271 let val = match v {
272 serde_json::Value::String(s) => s.clone(),
273 other => other.to_string(),
274 };
275 out = out.replace(&placeholder, &val);
276 }
277 out
278}
279
280fn extract_py_meta(content: &str, key: &str) -> Option<String> {
283 let prefix = format!("# {key}:");
284 content
285 .lines()
286 .find(|l| l.trim_start().starts_with(&prefix))
287 .map(|l| {
288 let pos = l.find(&prefix).unwrap() + prefix.len();
289 l[pos..].trim().to_string()
290 })
291}
292
293pub fn scan_skill_dir(dir: &Path) -> Vec<SkillMetadata> {
300 let Ok(entries) = std::fs::read_dir(dir) else {
301 return Vec::new();
302 };
303
304 let mut by_name: HashMap<String, (u8, SkillMetadata)> = HashMap::new();
306
307 for entry in entries.flatten() {
308 let path = entry.path();
309 let Some(kind) = SkillKind::from_path(&path) else {
310 continue;
311 };
312 let name = path
313 .file_stem()
314 .and_then(|s| s.to_str())
315 .unwrap_or("")
316 .to_string();
317 if name.is_empty() {
318 continue;
319 }
320
321 let priority = match kind {
322 SkillKind::Prompt => 0u8,
323 SkillKind::ComputeJson => 1,
324 SkillKind::PythonScript => 2,
325 };
326
327 let meta = match kind {
328 SkillKind::Prompt => {
329 if let Ok(content) = std::fs::read_to_string(&path) {
330 let mut meta = SkillMetadata::new(name.clone(), parse_md_description(&content));
331 if let Some(line) = content
333 .lines()
334 .find(|l| l.trim_start().starts_with("allowed_tools:"))
335 {
336 let raw = line.splitn(2, ':').nth(1).unwrap_or("");
337 meta.allowed_tools = raw
338 .trim()
339 .trim_matches(|c| c == '[' || c == ']')
340 .split(',')
341 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\''))
342 .filter(|s| !s.is_empty())
343 .map(Into::into)
344 .collect();
345 }
346 Some(meta)
347 } else {
348 None
349 }
350 }
351 SkillKind::ComputeJson => parse_json_skill(&path),
352 SkillKind::PythonScript => parse_python_skill(&path),
353 };
354
355 if let Some(meta) = meta {
356 let entry = by_name.entry(name).or_insert((255, meta.clone()));
357 if priority < entry.0 {
358 *entry = (priority, meta);
359 }
360 }
361 }
362
363 by_name.into_values().map(|(_, m)| m).collect()
364}
365
366fn parse_md_description(content: &str) -> String {
367 let body = content.trim_start();
368 if !body.starts_with("---") {
369 return String::new();
370 }
371 let rest = &body[3..];
372 let end = rest.find("\n---").unwrap_or(rest.len());
373 for line in rest[..end].lines() {
374 if let Some(val) = line.strip_prefix("description:") {
375 return val.trim().to_string();
376 }
377 }
378 String::new()
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn skill_kind_from_extension() {
387 assert_eq!(
388 SkillKind::from_path(Path::new("a.md")),
389 Some(SkillKind::Prompt)
390 );
391 assert_eq!(
392 SkillKind::from_path(Path::new("b.json")),
393 Some(SkillKind::ComputeJson)
394 );
395 assert_eq!(
396 SkillKind::from_path(Path::new("c.py")),
397 Some(SkillKind::PythonScript)
398 );
399 assert_eq!(SkillKind::from_path(Path::new("d.ts")), None);
400 assert_eq!(SkillKind::from_path(Path::new("noext")), None);
401 }
402
403 #[test]
404 fn template_substitutes_string_and_numeric() {
405 let mut args = HashMap::new();
406 args.insert("name".into(), serde_json::Value::String("Alice".into()));
407 args.insert("count".into(), serde_json::json!(42));
408 let result = render_template("Hello, {{name}}! You have {{count}} items.", &args);
409 assert_eq!(result, "Hello, Alice! You have 42 items.");
410 }
411
412 #[test]
413 fn template_leaves_unknown_placeholders() {
414 let args = HashMap::new();
415 let result = render_template("Hi {{name}}!", &args);
416 assert_eq!(result, "Hi {{name}}!");
417 }
418
419 #[test]
420 fn extract_py_meta_parses_comment_lines() {
421 let src = "# name: my_skill\n# description: Does stuff\nimport os\n";
422 assert_eq!(extract_py_meta(src, "name").as_deref(), Some("my_skill"));
423 assert_eq!(
424 extract_py_meta(src, "description").as_deref(),
425 Some("Does stuff")
426 );
427 assert_eq!(extract_py_meta(src, "missing"), None);
428 }
429
430 #[test]
431 fn parse_md_description_extracts_frontmatter() {
432 let md = "---\ndescription: A useful skill\nauthor: test\n---\n# Heading\n";
433 assert_eq!(parse_md_description(md), "A useful skill");
434 }
435
436 #[test]
437 fn parse_md_description_empty_without_frontmatter() {
438 assert_eq!(parse_md_description("# No frontmatter"), "");
439 }
440}