diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 7e01766a..d795e5d6 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -44,6 +44,11 @@ pub struct Skill { pub name: String, pub description: String, pub body: String, + /// On-disk path to the `SKILL.md` this was loaded from. The directory + /// name can differ from the frontmatter `name` for community installs + /// or manually-placed skills, so callers must use this rather than + /// reconstructing `//SKILL.md`. + pub path: PathBuf, } /// Collection of discovered skills. @@ -70,7 +75,10 @@ impl SkillRegistry { let skill_path = entry.path().join("SKILL.md"); match fs::read_to_string(&skill_path) { Ok(content) => match Self::parse_skill(&skill_path, &content) { - Ok(skill) => registry.skills.push(skill), + Ok(mut skill) => { + skill.path = skill_path.clone(); + registry.skills.push(skill); + } Err(reason) => registry.push_warning(format!( "Failed to parse {}: {reason}", skill_path.display() @@ -137,6 +145,9 @@ impl SkillRegistry { name, description, body, + // Filled in by `discover` after parse succeeds; default to an + // empty path so direct constructors (e.g. tests) compile. + path: PathBuf::new(), }) } @@ -196,16 +207,19 @@ instructions when using a specific skill.\n\n", let mut omitted = 0usize; for skill in skills { - let path = skills_dir.join(&skill.name).join("SKILL.md"); + // Use the real on-disk path captured at discovery — the directory + // name can differ from the frontmatter `name` for community + // installs, in which case `//SKILL.md` would not exist + // and the model would fail to open it. let description = truncate_for_prompt(&skill.description, MAX_SKILL_DESCRIPTION_CHARS); let line = if description.is_empty() { - format!("- {}: (file: {})\n", skill.name, path.display()) + format!("- {}: (file: {})\n", skill.name, skill.path.display()) } else { format!( "- {}: {} (file: {})\n", skill.name, description, - path.display() + skill.path.display() ) }; @@ -336,6 +350,50 @@ mod tests { assert!(rendered.contains("### How to use skills")); } + #[test] + fn render_available_skills_context_uses_real_dir_name_not_frontmatter_name() { + // Regression: when a community-installed or manually-placed skill + // lives in a directory whose name differs from its frontmatter + // `name`, the rendered prompt must point to the real on-disk file + // path, not //SKILL.md (which does + // not exist). + let tmpdir = TempDir::new().unwrap(); + create_skill_dir( + &tmpdir, + "weird-dir-name", + "---\nname: friendly-name\ndescription: drift case\n---\nbody", + ); + + let rendered = crate::skills::render_available_skills_context( + &tmpdir.path().join("skills"), + ) + .expect("skill context"); + + let real_path = tmpdir + .path() + .join("skills") + .join("weird-dir-name") + .join("SKILL.md") + .display() + .to_string(); + let stale_path = tmpdir + .path() + .join("skills") + .join("friendly-name") + .join("SKILL.md") + .display() + .to_string(); + + assert!( + rendered.contains(&real_path), + "expected real on-disk path {real_path:?} in rendered output, got:\n{rendered}" + ); + assert!( + !rendered.contains(&stale_path), + "rendered output must not invent a path under the frontmatter name:\n{rendered}" + ); + } + #[test] fn render_available_skills_context_returns_none_when_empty() { let tmpdir = TempDir::new().unwrap();