1use std::path::{Path, PathBuf};
20
21use async_trait::async_trait;
22
23#[derive(Debug, thiserror::Error)]
24pub enum SkillError {
25 #[error("skill source error: {0}")]
26 Source(String),
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct SkillMetadata {
34 pub name: String,
35 pub description: String,
36 pub path: String,
37}
38
39#[async_trait]
44pub trait SkillSource: Send + Sync {
45 async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError>;
47
48 async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError>;
51}
52
53pub struct LocalSkillSource {
56 root: PathBuf,
57}
58
59impl LocalSkillSource {
60 pub fn new(root: impl Into<PathBuf>) -> Self {
63 Self { root: root.into() }
64 }
65}
66
67#[async_trait]
68impl SkillSource for LocalSkillSource {
69 async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError> {
70 let mut entries = match tokio::fs::read_dir(&self.root).await {
71 Ok(e) => e,
72 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]),
74 Err(e) => return Err(SkillError::Source(e.to_string())),
75 };
76 let mut dirs = Vec::new();
77 while let Some(entry) = entries
78 .next_entry()
79 .await
80 .map_err(|e| SkillError::Source(e.to_string()))?
81 {
82 if entry
83 .file_type()
84 .await
85 .map(|t| t.is_dir())
86 .unwrap_or(false)
87 {
88 dirs.push(entry.path().to_string_lossy().into_owned());
89 }
90 }
91 dirs.sort();
92 Ok(dirs)
93 }
94
95 async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError> {
96 let path = Path::new(dir).join("SKILL.md");
97 match tokio::fs::read_to_string(&path).await {
98 Ok(s) => Ok(Some(s)),
99 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
100 Err(e) => Err(SkillError::Source(e.to_string())),
101 }
102 }
103}
104
105pub fn parse_skill_frontmatter(content: &str) -> (Option<String>, Option<String>) {
109 let Some(after_open) = content.strip_prefix("---") else {
110 return (None, None);
111 };
112 let Some(after_open) = after_open.trim_start_matches(' ').strip_prefix('\n') else {
113 return (None, None);
114 };
115 let Some(close_pos) = after_open.find("\n---") else {
116 return (None, None);
117 };
118 let front_matter = &after_open[..close_pos];
119
120 let mut name = None;
121 let mut description = None;
122 for line in front_matter.lines() {
123 if let Some(rest) = line.strip_prefix("name:") {
124 name = unquote_nonempty(rest);
125 } else if let Some(rest) = line.strip_prefix("description:") {
126 description = unquote_nonempty(rest);
127 }
128 }
129 (name, description)
130}
131
132fn unquote_nonempty(raw: &str) -> Option<String> {
133 let raw = raw.trim();
134 let v = raw
135 .strip_prefix('"')
136 .and_then(|s| s.strip_suffix('"'))
137 .or_else(|| raw.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
138 .unwrap_or(raw)
139 .trim();
140 (!v.is_empty()).then(|| v.to_string())
141}
142
143fn dir_basename(dir: &str) -> String {
144 dir.trim_end_matches('/')
145 .rsplit('/')
146 .next()
147 .unwrap_or(dir)
148 .to_string()
149}
150
151#[derive(Default)]
155pub struct SkillLoader;
156
157impl SkillLoader {
158 pub fn new() -> Self {
159 Self
160 }
161
162 pub async fn load(&self, src: &dyn SkillSource) -> Result<Vec<SkillMetadata>, SkillError> {
163 let mut out = Vec::new();
164 for dir in src.list_skill_dirs().await? {
165 let Some(content) = src.read_skill_md(&dir).await? else {
166 continue;
167 };
168 let (name, description) = parse_skill_frontmatter(&content);
169 out.push(SkillMetadata {
170 name: name.unwrap_or_else(|| dir_basename(&dir)),
171 description: description.unwrap_or_default(),
172 path: dir,
173 });
174 }
175 Ok(out)
176 }
177}
178
179#[derive(Default)]
181pub struct SkillPromptRenderer;
182
183impl SkillPromptRenderer {
184 pub fn new() -> Self {
185 Self
186 }
187
188 pub fn render(&self, skills: &[SkillMetadata]) -> Option<String> {
190 if skills.is_empty() {
191 return None;
192 }
193 let mut s = String::from(
194 "You have access to the following skills. When a task matches one, read its \
195 SKILL.md at the given path for the full instructions, then follow them (run any \
196 bundled scripts with bash). Available skills:",
197 );
198 for skill in skills {
199 s.push_str("\n- ");
200 s.push_str(&skill.name);
201 if !skill.description.is_empty() {
202 s.push_str(": ");
203 s.push_str(&skill.description);
204 }
205 s.push_str(" (path: ");
206 s.push_str(&skill.path);
207 s.push_str("/SKILL.md)");
208 }
209 Some(s)
210 }
211}
212
213#[derive(Default)]
217pub struct SkillsManager {
218 loader: SkillLoader,
219 renderer: SkillPromptRenderer,
220}
221
222impl SkillsManager {
223 pub fn new() -> Self {
224 Self::default()
225 }
226
227 pub async fn load_and_render(
228 &self,
229 src: &dyn SkillSource,
230 ) -> Result<Option<String>, SkillError> {
231 let skills = self.loader.load(src).await?;
232 Ok(self.renderer.render(&skills))
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239
240 #[test]
241 fn frontmatter_parses_name_and_description() {
242 let md = "---\nname: pdf-tools\ndescription: \"Work with PDFs\"\n---\n# body\n";
243 let (name, desc) = parse_skill_frontmatter(md);
244 assert_eq!(name.as_deref(), Some("pdf-tools"));
245 assert_eq!(desc.as_deref(), Some("Work with PDFs"));
246 }
247
248 #[test]
249 fn frontmatter_missing_fields_are_none() {
250 assert_eq!(parse_skill_frontmatter("no frontmatter"), (None, None));
251 let (name, desc) = parse_skill_frontmatter("---\nname: x\n---\n");
252 assert_eq!(name.as_deref(), Some("x"));
253 assert_eq!(desc, None);
254 }
255
256 #[test]
257 fn renderer_emits_name_desc_path_and_no_body() {
258 let skills = vec![SkillMetadata {
259 name: "pdf-tools".into(),
260 description: "Work with PDFs".into(),
261 path: "/cwd/.harness/skills/pdf-tools".into(),
262 }];
263 let out = SkillPromptRenderer::new().render(&skills).unwrap();
264 assert!(out.contains("pdf-tools"));
265 assert!(out.contains("Work with PDFs"));
266 assert!(out.contains("/cwd/.harness/skills/pdf-tools/SKILL.md"));
267 assert!(!out.contains("# body"));
269 }
270
271 #[test]
272 fn renderer_empty_is_none() {
273 assert!(SkillPromptRenderer::new().render(&[]).is_none());
274 }
275
276 struct FakeSource(Vec<(String, Option<String>)>);
278
279 #[async_trait]
280 impl SkillSource for FakeSource {
281 async fn list_skill_dirs(&self) -> Result<Vec<String>, SkillError> {
282 Ok(self.0.iter().map(|(d, _)| d.clone()).collect())
283 }
284 async fn read_skill_md(&self, dir: &str) -> Result<Option<String>, SkillError> {
285 Ok(self
286 .0
287 .iter()
288 .find(|(d, _)| d == dir)
289 .and_then(|(_, md)| md.clone()))
290 }
291 }
292
293 #[tokio::test]
294 async fn loader_skips_dirs_without_skill_md_and_falls_back_to_basename() {
295 let src = FakeSource(vec![
296 (
297 "/s/alpha".into(),
298 Some("---\nname: alpha-skill\ndescription: A\n---\nbody".into()),
299 ),
300 ("/s/no-md".into(), None),
301 ("/s/beta".into(), Some("just text, no frontmatter".into())),
303 ]);
304 let skills = SkillLoader::new().load(&src).await.unwrap();
305 assert_eq!(skills.len(), 2);
306 assert_eq!(skills[0].name, "alpha-skill");
307 assert_eq!(skills[0].description, "A");
308 assert_eq!(skills[1].name, "beta");
309 assert_eq!(skills[1].description, "");
310 }
311
312 #[tokio::test]
313 async fn manager_load_and_render_end_to_end() {
314 let src = FakeSource(vec![(
315 "/s/alpha".into(),
316 Some("---\nname: alpha\ndescription: Do alpha\n---\nbody".into()),
317 )]);
318 let fragment = SkillsManager::new().load_and_render(&src).await.unwrap();
319 let fragment = fragment.expect("one skill ⇒ Some");
320 assert!(fragment.contains("alpha"));
321 assert!(fragment.contains("Do alpha"));
322 assert!(fragment.contains("/s/alpha/SKILL.md"));
323 }
324
325 #[tokio::test]
326 async fn local_source_lists_and_reads() {
327 let base = std::env::temp_dir().join(format!("harness_skills_test_{}", std::process::id()));
328 let skill_dir = base.join("demo");
329 tokio::fs::create_dir_all(&skill_dir).await.unwrap();
330 tokio::fs::write(skill_dir.join("SKILL.md"), "---\nname: demo\n---\nx")
331 .await
332 .unwrap();
333
334 let src = LocalSkillSource::new(&base);
335 let dirs = src.list_skill_dirs().await.unwrap();
336 assert_eq!(dirs.len(), 1);
337 assert!(src.read_skill_md(&dirs[0]).await.unwrap().is_some());
338
339 let _ = tokio::fs::remove_dir_all(&base).await;
340 }
341}