1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, clap::ValueEnum)]
15#[serde(rename_all = "kebab-case")]
16#[value(rename_all = "kebab-case")]
17pub enum Harness {
18 #[default]
19 ClaudeCode,
20 Codex,
21 #[serde(rename = "opencode")]
22 #[value(name = "opencode")]
23 OpenCode,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct RunContext {
31 pub skill_dir: PathBuf,
32 pub skill_name: String,
33 pub skill_subdir: PathBuf,
34 pub sibling_skill_names: Vec<String>,
35 pub stage_siblings: bool,
36 pub workspace_root: PathBuf,
37 pub stage_root: PathBuf,
38 pub bootstrap_path: Option<PathBuf>,
39 pub harness: Harness,
40}
41
42#[derive(Debug, Clone, Default)]
46pub struct DetectInput {
47 pub skill_dir: Option<String>,
48 pub skill: Option<String>,
49 pub bootstrap: Option<String>,
50 pub workspace_dir: Option<String>,
51 pub harness: Option<Harness>,
52 pub cwd: Option<PathBuf>,
53}
54
55#[derive(Debug, thiserror::Error)]
59pub enum ContextError {
60 #[error(
61 "missing skill. Run from a skill directory containing SKILL.md, pass --skill <path-or-name>, or pass --skill-dir <dir> --skill <name>"
62 )]
63 MissingSkill,
64 #[error("--skill-dir contains multiple skills; pass --skill <name>. Candidates: {0}")]
65 AmbiguousSkillSelection(String),
66 #[error("no skills found under --skill-dir: {0}")]
67 NoSkillsInSkillDir(String),
68 #[error("--skill-dir is not a directory: {0}")]
69 SkillDirNotDirectory(String),
70 #[error("skill not found: {0}")]
71 SkillNotFound(String),
72 #[error("--bootstrap file not found: {0}")]
73 BootstrapNotFound(String),
74 #[error("io error: {0}")]
75 Io(#[from] std::io::Error),
76}
77
78fn absolutize(cwd: &Path, p: &str) -> Result<PathBuf, ContextError> {
82 let path = Path::new(p);
83 let joined = if path.is_absolute() {
84 path.to_path_buf()
85 } else {
86 cwd.join(path)
87 };
88 Ok(std::path::absolute(joined)?)
89}
90
91fn skill_name_from_dir(skill_subdir: &Path) -> Result<String, ContextError> {
92 skill_subdir
93 .file_name()
94 .map(|name| name.to_string_lossy().into_owned())
95 .filter(|name| !name.is_empty())
96 .ok_or(ContextError::MissingSkill)
97}
98
99fn parent_dir(skill_subdir: &Path) -> PathBuf {
100 skill_subdir
101 .parent()
102 .map(Path::to_path_buf)
103 .unwrap_or_else(|| skill_subdir.to_path_buf())
104}
105
106fn enumerate_skill_children(skill_dir: &Path) -> Result<Vec<String>, ContextError> {
107 let mut out = Vec::new();
108 for entry in std::fs::read_dir(skill_dir)? {
109 let entry = entry?;
110 let sub = entry.path();
111 if !sub.is_dir() || !sub.join("SKILL.md").exists() {
112 continue;
113 }
114 out.push(entry.file_name().to_string_lossy().into_owned());
115 }
116 out.sort();
117 Ok(out)
118}
119
120fn enumerate_siblings(skill_dir: &Path, skill_name: &str) -> Result<Vec<String>, ContextError> {
123 Ok(enumerate_skill_children(skill_dir)?
124 .into_iter()
125 .filter(|name| name != skill_name)
126 .collect())
127}
128
129fn infer_only_skill_name(skill_dir: &Path) -> Result<String, ContextError> {
130 let skills = enumerate_skill_children(skill_dir)?;
131 match skills.as_slice() {
132 [only] => Ok(only.clone()),
133 [] => Err(ContextError::NoSkillsInSkillDir(
134 skill_dir.display().to_string(),
135 )),
136 _ => Err(ContextError::AmbiguousSkillSelection(skills.join(", "))),
137 }
138}
139
140pub fn detect_run_context(input: DetectInput) -> Result<RunContext, ContextError> {
146 let cwd = input.cwd.unwrap_or(std::env::current_dir()?);
147 let cwd = std::path::absolute(cwd)?;
148 let (skill_dir, skill_name, skill_subdir, sibling_skill_names, stage_siblings) =
149 match input.skill_dir {
150 Some(skill_dir_raw) => {
151 let skill_dir = absolutize(&cwd, &skill_dir_raw)?;
152 if !skill_dir.is_dir() {
153 return Err(ContextError::SkillDirNotDirectory(
154 skill_dir.display().to_string(),
155 ));
156 }
157 let skill_name = match input.skill {
158 Some(skill) => skill,
159 None => infer_only_skill_name(&skill_dir)?,
160 };
161 let skill_subdir = skill_dir.join(&skill_name);
162 let sibling_skill_names = enumerate_siblings(&skill_dir, &skill_name)?;
163 (
164 skill_dir,
165 skill_name,
166 skill_subdir,
167 sibling_skill_names,
168 true,
169 )
170 }
171 None => {
172 let skill_subdir = match input.skill {
173 Some(skill_raw) => absolutize(&cwd, &skill_raw)?,
174 None if cwd.join("SKILL.md").exists() => cwd.clone(),
175 None => return Err(ContextError::MissingSkill),
176 };
177 let skill_name = skill_name_from_dir(&skill_subdir)?;
178 let skill_dir = parent_dir(&skill_subdir);
179 (skill_dir, skill_name, skill_subdir, Vec::new(), false)
180 }
181 };
182 let skill_md = skill_subdir.join("SKILL.md");
183 if !skill_md.exists() {
184 return Err(ContextError::SkillNotFound(skill_md.display().to_string()));
185 }
186
187 let bootstrap_path = match input.bootstrap {
188 Some(raw) => {
189 let resolved = absolutize(&cwd, &raw)?;
190 if !resolved.exists() {
191 return Err(ContextError::BootstrapNotFound(
192 resolved.display().to_string(),
193 ));
194 }
195 Some(resolved)
196 }
197 None => None,
198 };
199
200 let workspace_root = match input.workspace_dir {
201 Some(raw) => absolutize(&cwd, &raw)?,
202 None => cwd.join("skills-workspace"),
203 };
204 let stage_root = cwd;
205
206 let harness = input.harness.unwrap_or_default();
207
208 Ok(RunContext {
209 skill_dir,
210 skill_name,
211 skill_subdir,
212 sibling_skill_names,
213 stage_siblings,
214 workspace_root,
215 stage_root,
216 bootstrap_path,
217 harness,
218 })
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::fs;
225 use std::path::{Path, PathBuf};
226 use tempfile::TempDir;
227
228 fn make_skill_dir(root: &Path, skills: &[&str]) -> PathBuf {
231 let dir = root.join("skill-dir");
232 fs::create_dir_all(&dir).unwrap();
233 for name in skills {
234 let sub = dir.join(name);
235 fs::create_dir_all(&sub).unwrap();
236 fs::write(
237 sub.join("SKILL.md"),
238 format!("---\nname: {name}\ndescription: {name} skill\n---\n\nbody\n"),
239 )
240 .unwrap();
241 }
242 dir
243 }
244
245 fn input(skill_dir: &Path, skill: &str) -> DetectInput {
246 DetectInput {
247 skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
248 skill: Some(skill.to_string()),
249 ..Default::default()
250 }
251 }
252
253 fn input_from(cwd: &Path) -> DetectInput {
254 DetectInput {
255 cwd: Some(cwd.to_path_buf()),
256 ..Default::default()
257 }
258 }
259
260 #[test]
261 fn cwd_skill_dir_is_the_default_single_skill() {
262 let tmp = TempDir::new().unwrap();
263 let skill_subdir = tmp.path().join("mr-review");
264 fs::create_dir_all(&skill_subdir).unwrap();
265 fs::write(
266 skill_subdir.join("SKILL.md"),
267 "---\nname: mr-review\n---\n\nbody\n",
268 )
269 .unwrap();
270
271 let ctx = detect_run_context(input_from(&skill_subdir)).unwrap();
272
273 assert_eq!(ctx.skill_name, "mr-review");
274 assert_eq!(
275 ctx.skill_subdir,
276 std::path::absolute(&skill_subdir).unwrap()
277 );
278 assert!(ctx.sibling_skill_names.is_empty());
279 assert!(!ctx.stage_siblings);
280 }
281
282 #[test]
283 fn skill_path_selects_one_skill_without_siblings() {
284 let tmp = TempDir::new().unwrap();
285 let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta"]);
286
287 let ctx = detect_run_context(DetectInput {
288 skill: Some(skill_dir.join("beta").to_string_lossy().into_owned()),
289 cwd: Some(tmp.path().to_path_buf()),
290 ..Default::default()
291 })
292 .unwrap();
293
294 assert_eq!(ctx.skill_name, "beta");
295 assert_eq!(
296 ctx.skill_subdir,
297 std::path::absolute(skill_dir.join("beta")).unwrap()
298 );
299 assert!(ctx.sibling_skill_names.is_empty());
300 assert!(!ctx.stage_siblings);
301 }
302
303 #[test]
304 fn skill_dir_with_one_skill_infers_the_skill_name_and_stages_siblings_mode() {
305 let tmp = TempDir::new().unwrap();
306 let skill_dir = make_skill_dir(tmp.path(), &["only-skill"]);
307
308 let ctx = detect_run_context(DetectInput {
309 skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
310 cwd: Some(tmp.path().to_path_buf()),
311 ..Default::default()
312 })
313 .unwrap();
314
315 assert_eq!(ctx.skill_name, "only-skill");
316 assert!(ctx.sibling_skill_names.is_empty());
317 assert!(ctx.stage_siblings);
318 }
319
320 #[test]
321 fn skill_dir_with_multiple_skills_requires_a_skill_name() {
322 let tmp = TempDir::new().unwrap();
323 let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta"]);
324
325 let err = detect_run_context(DetectInput {
326 skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
327 cwd: Some(tmp.path().to_path_buf()),
328 ..Default::default()
329 })
330 .unwrap_err();
331
332 assert!(matches!(err, ContextError::AmbiguousSkillSelection(_)));
333 assert!(err.to_string().contains("alpha"));
334 assert!(err.to_string().contains("beta"));
335 }
336
337 #[test]
338 fn missing_skill_errors_when_cwd_is_not_a_skill() {
339 let tmp = TempDir::new().unwrap();
340 let err = detect_run_context(input_from(tmp.path())).unwrap_err();
341 assert!(matches!(err, ContextError::MissingSkill));
342 assert!(err.to_string().contains("--skill"));
343 }
344
345 #[test]
346 fn empty_skill_dir_errors_when_skill_is_not_named() {
347 let tmp = TempDir::new().unwrap();
348 let skill_dir = tmp.path().join("skill-dir");
349 fs::create_dir_all(&skill_dir).unwrap();
350 let err = detect_run_context(DetectInput {
351 skill_dir: Some(skill_dir.to_string_lossy().into_owned()),
352 ..Default::default()
353 })
354 .unwrap_err();
355 assert!(matches!(err, ContextError::NoSkillsInSkillDir(_)));
356 assert!(err.to_string().contains("no skills found"));
357 }
358
359 #[test]
360 fn skill_dir_not_directory_errors() {
361 let err = detect_run_context(DetectInput {
362 skill_dir: Some("/nonexistent/does-not-exist-12345".into()),
363 skill: Some("foo".into()),
364 ..Default::default()
365 })
366 .unwrap_err();
367 assert!(matches!(err, ContextError::SkillDirNotDirectory(_)));
368 assert!(err.to_string().contains("--skill-dir"));
369 }
370
371 #[test]
372 fn skill_subdir_missing_errors() {
373 let tmp = TempDir::new().unwrap();
374 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
375 let err = detect_run_context(input(&skill_dir, "bar")).unwrap_err();
376 assert!(matches!(err, ContextError::SkillNotFound(_)));
377 assert!(err.to_string().contains("skill not found"));
378 }
379
380 #[test]
381 fn bad_bootstrap_errors() {
382 let tmp = TempDir::new().unwrap();
383 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
384 let err = detect_run_context(DetectInput {
385 bootstrap: Some("/nonexistent/no-bootstrap-12345.md".into()),
386 ..input(&skill_dir, "foo")
387 })
388 .unwrap_err();
389 assert!(matches!(err, ContextError::BootstrapNotFound(_)));
390 assert!(err.to_string().contains("--bootstrap"));
391 }
392
393 #[test]
394 fn happy_path_absolute_paths() {
395 let tmp = TempDir::new().unwrap();
396 let skill_dir = make_skill_dir(tmp.path(), &["mr-review"]);
397 let ctx = detect_run_context(input(&skill_dir, "mr-review")).unwrap();
398 assert_eq!(ctx.skill_dir, std::path::absolute(&skill_dir).unwrap());
399 assert_eq!(ctx.skill_name, "mr-review");
400 assert_eq!(
401 ctx.skill_subdir,
402 std::path::absolute(skill_dir.join("mr-review")).unwrap()
403 );
404 assert!(ctx.sibling_skill_names.is_empty());
405 assert!(ctx.bootstrap_path.is_none());
406 assert_eq!(ctx.harness, Harness::ClaudeCode);
407 }
408
409 #[test]
410 fn enumerates_siblings_excluding_sut() {
411 let tmp = TempDir::new().unwrap();
412 let skill_dir = make_skill_dir(tmp.path(), &["alpha", "beta", "gamma"]);
413 let ctx = detect_run_context(input(&skill_dir, "beta")).unwrap();
414 assert_eq!(
415 ctx.sibling_skill_names,
416 vec!["alpha".to_string(), "gamma".to_string()]
417 );
418 }
419
420 #[test]
421 fn ignores_non_skill_md_entries() {
422 let tmp = TempDir::new().unwrap();
423 let skill_dir = make_skill_dir(tmp.path(), &["real"]);
424 fs::create_dir_all(skill_dir.join("node_modules")).unwrap();
425 fs::create_dir_all(skill_dir.join("no-skill-md-here")).unwrap();
426 fs::write(skill_dir.join("loose-file.txt"), "hello").unwrap();
427 let ctx = detect_run_context(input(&skill_dir, "real")).unwrap();
428 assert!(ctx.sibling_skill_names.is_empty());
429 }
430
431 #[test]
432 fn workspace_default() {
433 let tmp = TempDir::new().unwrap();
434 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
435 let ctx = detect_run_context(input(&skill_dir, "foo")).unwrap();
436 let expected = std::env::current_dir().unwrap().join("skills-workspace");
437 assert_eq!(ctx.workspace_root, expected);
438 }
439
440 #[test]
441 fn workspace_override_absolute() {
442 let tmp = TempDir::new().unwrap();
443 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
444 let custom = tmp.path().join("custom-ws");
445 fs::create_dir_all(&custom).unwrap();
446 let ctx = detect_run_context(DetectInput {
447 workspace_dir: Some(custom.to_string_lossy().into_owned()),
448 ..input(&skill_dir, "foo")
449 })
450 .unwrap();
451 assert_eq!(ctx.workspace_root, std::path::absolute(&custom).unwrap());
452 }
453
454 #[test]
455 fn stage_root_default() {
456 let tmp = TempDir::new().unwrap();
457 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
458 let ctx = detect_run_context(input(&skill_dir, "foo")).unwrap();
459 assert_eq!(ctx.stage_root, std::env::current_dir().unwrap());
460 }
461
462 #[test]
463 fn bootstrap_resolved_absolute() {
464 let tmp = TempDir::new().unwrap();
465 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
466 let bootstrap = tmp.path().join("my-bootstrap.md");
467 fs::write(&bootstrap, "BOOT").unwrap();
468 let ctx = detect_run_context(DetectInput {
469 bootstrap: Some(bootstrap.to_string_lossy().into_owned()),
470 ..input(&skill_dir, "foo")
471 })
472 .unwrap();
473 assert_eq!(
474 ctx.bootstrap_path,
475 Some(std::path::absolute(&bootstrap).unwrap())
476 );
477 }
478
479 #[test]
480 fn harness_codex_accepted() {
481 let tmp = TempDir::new().unwrap();
482 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
483 let ctx = detect_run_context(DetectInput {
484 harness: Some(Harness::Codex),
485 ..input(&skill_dir, "foo")
486 })
487 .unwrap();
488 assert_eq!(ctx.harness, Harness::Codex);
489 }
490
491 #[test]
492 fn harness_opencode_accepted() {
493 let tmp = TempDir::new().unwrap();
494 let skill_dir = make_skill_dir(tmp.path(), &["foo"]);
495 let ctx = detect_run_context(DetectInput {
496 harness: Some(Harness::OpenCode),
497 ..input(&skill_dir, "foo")
498 })
499 .unwrap();
500 assert_eq!(ctx.harness, Harness::OpenCode);
501 }
502}