1use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6
7use locode_host::{SkillsExtraEntry, find_root_from_markers, locode_home};
8
9use crate::frontmatter;
10
11pub const SKILL_FILE: &str = "SKILL.md";
13pub const PROJECT_SKILLS_DIR: [&str; 2] = [".agents", "skills"];
24const MAX_NAME_LEN: usize = 64;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
34pub enum SkillScope {
35 Project,
37 User,
39 Extra,
41}
42
43impl SkillScope {
44 #[must_use]
46 pub fn as_str(self) -> &'static str {
47 match self {
48 SkillScope::Project => "project",
49 SkillScope::User => "user",
50 SkillScope::Extra => "extra",
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Skill {
58 pub name: String,
60 pub scope: SkillScope,
62 pub description: String,
64 pub when_to_use: Option<String>,
66 pub path: PathBuf,
69 pub disable_model_invocation: bool,
71 pub user_invocable: bool,
73}
74
75impl Skill {
76 #[must_use]
78 pub fn display_name(&self, ambiguous: bool) -> String {
79 if ambiguous {
80 format!("{}:{}", self.scope.as_str(), self.name)
81 } else {
82 self.name.clone()
83 }
84 }
85}
86
87#[derive(Debug, Clone, Default)]
90pub struct SkillsConfig {
91 pub enabled: bool,
93 pub root_markers: Vec<String>,
95 pub extends_dirs: Vec<PathBuf>,
97 pub extra: Vec<SkillsExtraEntry>,
99}
100
101impl SkillsConfig {
102 #[must_use]
104 pub fn enabled() -> Self {
105 Self {
106 enabled: true,
107 root_markers: vec![".git".to_string()],
108 extends_dirs: Vec::new(),
109 extra: Vec::new(),
110 }
111 }
112}
113
114#[derive(Debug, Clone, Default)]
116pub struct DiscoveredSkills {
117 pub skills: Vec<Skill>,
119 pub warnings: Vec<String>,
121}
122
123#[must_use]
130pub fn discover(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
131 let user_root = cfg
136 .enabled
137 .then(|| locode_home().ok().map(|home| home.join("skills")))
138 .flatten();
139 discover_impl(cwd, cfg, user_root.as_deref())
140}
141
142fn discover_impl(cwd: &Path, cfg: &SkillsConfig, user_root: Option<&Path>) -> DiscoveredSkills {
143 if !cfg.enabled {
144 return DiscoveredSkills::default();
145 }
146 let mut warnings = Vec::new();
147 let mut found: Vec<Skill> = Vec::new();
148
149 let root = find_root_from_markers(cwd, &cfg.root_markers, None);
150 collect_root(
151 &root.join(PROJECT_SKILLS_DIR[0]).join(PROJECT_SKILLS_DIR[1]),
152 SkillScope::Project,
153 &mut found,
154 &mut warnings,
155 );
156 if let Some(user_root) = user_root {
157 collect_root(user_root, SkillScope::User, &mut found, &mut warnings);
158 }
159 for dir in &cfg.extends_dirs {
160 collect_root(
161 &dir.join("skills"),
162 SkillScope::User,
163 &mut found,
164 &mut warnings,
165 );
166 }
167 for entry in &cfg.extra {
168 match entry {
169 SkillsExtraEntry::Skill(dir) => {
170 collect_skill_dir(dir, SkillScope::Extra, &mut found, &mut warnings);
171 }
172 SkillsExtraEntry::Folder(dir) => {
173 collect_root(dir, SkillScope::Extra, &mut found, &mut warnings);
174 }
175 }
176 }
177
178 let mut seen: Vec<(SkillScope, String)> = Vec::new();
180 found.retain(|s| {
181 let key = (s.scope, s.name.clone());
182 if seen.contains(&key) {
183 false
184 } else {
185 seen.push(key);
186 true
187 }
188 });
189 found.retain(|s| !s.disable_model_invocation);
191
192 DiscoveredSkills {
193 skills: found,
194 warnings,
195 }
196}
197
198#[must_use]
200pub fn ambiguous_names(skills: &[Skill]) -> Vec<String> {
201 let mut counts: HashMap<&str, usize> = HashMap::new();
202 for s in skills {
203 *counts.entry(s.name.as_str()).or_default() += 1;
204 }
205 let mut out: Vec<String> = counts
206 .into_iter()
207 .filter(|&(_, n)| n > 1)
208 .map(|(n, _)| n.to_string())
209 .collect();
210 out.sort();
211 out
212}
213
214fn collect_root(root: &Path, scope: SkillScope, out: &mut Vec<Skill>, warnings: &mut Vec<String>) {
216 let Ok(read) = std::fs::read_dir(root) else {
217 return; };
219 let mut dirs: Vec<PathBuf> = read
220 .flatten()
221 .map(|e| e.path())
222 .filter(|p| p.is_dir())
223 .collect();
224 dirs.sort(); for dir in dirs {
226 collect_skill_dir(&dir, scope, out, warnings);
227 }
228}
229
230fn collect_skill_dir(
232 dir: &Path,
233 scope: SkillScope,
234 out: &mut Vec<Skill>,
235 warnings: &mut Vec<String>,
236) {
237 let path = dir.join(SKILL_FILE);
238 if !path.is_file() {
239 return;
240 }
241 let Ok(source) = std::fs::read_to_string(&path) else {
242 warnings.push(format!("skills: cannot read {}; skipped", path.display()));
243 return;
244 };
245 let Some((fm, _body)) = frontmatter::parse(&source) else {
246 warnings.push(format!(
247 "skills: {} has no `---` frontmatter block; skipped",
248 path.display()
249 ));
250 return;
251 };
252
253 let raw_name = fm.name.as_deref().unwrap_or_default();
254 let name = if raw_name.trim().is_empty() {
255 dir.file_name().map(|n| n.to_string_lossy().to_string())
256 } else {
257 Some(raw_name.to_string())
258 }
259 .map(|n| normalize_name(&n))
260 .filter(|n| !n.is_empty());
261 let Some(name) = name else {
262 warnings.push(format!(
263 "skills: {} has no usable name (frontmatter `name` and directory name both \
264 normalize to empty); skipped",
265 path.display()
266 ));
267 return;
268 };
269
270 let description = fm
271 .description
272 .as_deref()
273 .map(|d| d.trim().to_string())
274 .unwrap_or_default();
275 if description.is_empty() {
276 warnings.push(format!(
277 "skills: {} has no `description`; skipped (the description is what the \
278 model matches a task against)",
279 path.display()
280 ));
281 return;
282 }
283
284 out.push(Skill {
285 name,
286 scope,
287 description,
288 when_to_use: fm
289 .when_to_use
290 .as_deref()
291 .map(|w| w.trim().to_string())
292 .filter(|w| !w.is_empty()),
293 path,
294 disable_model_invocation: fm.disable_model_invocation(),
295 user_invocable: fm.user_invocable(),
296 });
297}
298
299#[must_use]
304pub fn normalize_name(name: &str) -> String {
305 let mut out = String::with_capacity(name.len());
306 for c in name.trim().chars() {
307 let c = c.to_ascii_lowercase();
308 let c = if c.is_ascii_lowercase() || c.is_ascii_digit() {
309 c
310 } else {
311 '-'
312 };
313 if c == '-' && out.ends_with('-') {
314 continue;
315 }
316 out.push(c);
317 }
318 let out = out.trim_matches('-');
319 out.chars().take(MAX_NAME_LEN).collect::<String>()
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use std::fs;
326 use tempfile::TempDir;
327
328 fn tmp() -> (TempDir, PathBuf) {
329 let d = TempDir::new().unwrap();
330 let p = std::fs::canonicalize(d.path()).unwrap();
331 (d, p)
332 }
333
334 fn skill(root: &Path, name: &str, frontmatter: &str) {
336 let dir = root.join(name);
337 fs::create_dir_all(&dir).unwrap();
338 fs::write(
339 dir.join(SKILL_FILE),
340 format!("---\n{frontmatter}\n---\n# {name}\n"),
341 )
342 .unwrap();
343 }
344
345 fn cfg() -> SkillsConfig {
346 SkillsConfig::enabled()
347 }
348
349 fn discover_no_home(cwd: &Path, cfg: &SkillsConfig) -> DiscoveredSkills {
352 discover_impl(cwd, cfg, None)
353 }
354
355 fn names(d: &DiscoveredSkills) -> Vec<&str> {
356 d.skills.iter().map(|s| s.name.as_str()).collect()
357 }
358
359 #[test]
360 fn finds_project_skills_sorted_and_reads_the_five_keys() {
361 let (_g, repo) = tmp();
362 fs::create_dir(repo.join(".git")).unwrap();
363 let root = repo.join(".agents/skills");
364 skill(&root, "zebra", "name: zebra\ndescription: Z");
365 skill(
366 &root,
367 "commit",
368 "name: commit\ndescription: Make a commit\nwhen-to-use: on push\nuser-invocable: false",
369 );
370
371 let got = discover_no_home(&repo, &cfg());
372 assert_eq!(names(&got), vec!["commit", "zebra"], "sorted, stable");
373 let c = &got.skills[0];
374 assert_eq!(c.scope, SkillScope::Project);
375 assert_eq!(c.description, "Make a commit");
376 assert_eq!(c.when_to_use.as_deref(), Some("on push"));
377 assert!(!c.user_invocable, "parsed, even though it is inert in v1");
378 assert!(c.path.ends_with("commit/SKILL.md"));
379 }
380
381 #[test]
382 fn name_falls_back_to_the_directory_and_is_slugified() {
383 let (_g, repo) = tmp();
384 fs::create_dir(repo.join(".git")).unwrap();
385 let root = repo.join(".agents/skills");
386 skill(&root, "My_Skill", "description: D"); skill(&root, "other", "name: Review PR\ndescription: D");
388
389 let got = discover_no_home(&repo, &cfg());
390 let mut n = names(&got);
391 n.sort_unstable();
392 assert_eq!(n, vec!["my-skill", "review-pr"]);
393 }
394
395 #[test]
396 fn disable_model_invocation_hides_the_skill_entirely() {
397 let (_g, repo) = tmp();
398 fs::create_dir(repo.join(".git")).unwrap();
399 let root = repo.join(".agents/skills");
400 skill(&root, "shown", "description: D");
401 skill(
402 &root,
403 "hidden",
404 "description: D\ndisable-model-invocation: true",
405 );
406
407 let got = discover_no_home(&repo, &cfg());
408 assert_eq!(names(&got), vec!["shown"]);
409 }
410
411 #[test]
412 fn a_broken_skill_is_skipped_with_a_warning_and_does_not_stop_the_others() {
413 let (_g, repo) = tmp();
414 fs::create_dir(repo.join(".git")).unwrap();
415 let root = repo.join(".agents/skills");
416 skill(&root, "good", "description: D");
417 let bad = root.join("bad");
419 fs::create_dir_all(&bad).unwrap();
420 fs::write(bad.join(SKILL_FILE), "# just markdown\n").unwrap();
421 skill(&root, "nodesc", "name: nodesc");
423
424 let got = discover_no_home(&repo, &cfg());
425 assert_eq!(names(&got), vec!["good"]);
426 let w = got.warnings.join(" | ");
427 assert!(w.contains("frontmatter"), "{w}");
428 assert!(w.contains("description"), "{w}");
429 }
430
431 #[test]
434 fn precedence_within_a_scope_drops_the_loser_across_scopes_keeps_both() {
435 let (_g, repo) = tmp();
436 fs::create_dir(repo.join(".git")).unwrap();
437 skill(
438 &repo.join(".agents/skills"),
439 "commit",
440 "description: project",
441 );
442 let (_e, team) = tmp();
443 skill(&team.join("skills"), "commit", "description: team");
444 let (_x, extra) = tmp();
445 skill(&extra, "commit", "description: extra");
446
447 let mut c = cfg();
448 c.extends_dirs = vec![team];
449 c.extra = vec![SkillsExtraEntry::Folder(extra)];
450 let got = discover_no_home(&repo, &c);
451
452 let by_scope: Vec<_> = got
453 .skills
454 .iter()
455 .map(|s| (s.scope, &*s.description))
456 .collect();
457 assert_eq!(
458 by_scope,
459 vec![
460 (SkillScope::Project, "project"),
461 (SkillScope::User, "team"),
462 (SkillScope::Extra, "extra"),
463 ],
464 "one per scope survives"
465 );
466 assert_eq!(ambiguous_names(&got.skills), vec!["commit"]);
467 assert_eq!(got.skills[1].display_name(true), "user:commit");
468 }
469
470 #[test]
471 fn a_single_skill_extra_entry_points_straight_at_the_skill_dir() {
472 let (_g, repo) = tmp();
473 fs::create_dir(repo.join(".git")).unwrap();
474 let (_x, dir) = tmp();
475 let one = dir.join("oneoff");
476 fs::create_dir_all(&one).unwrap();
477 fs::write(one.join(SKILL_FILE), "---\ndescription: D\n---\n").unwrap();
478
479 let mut c = cfg();
480 c.extra = vec![SkillsExtraEntry::Skill(one)];
481 let got = discover_no_home(&repo, &c);
482 assert_eq!(names(&got), vec!["oneoff"]);
483 assert_eq!(got.skills[0].scope, SkillScope::Extra);
484 }
485
486 #[test]
487 fn disabled_config_touches_nothing() {
488 let (_g, repo) = tmp();
489 fs::create_dir(repo.join(".git")).unwrap();
490 skill(&repo.join(".agents/skills"), "commit", "description: D");
491 let got = discover_no_home(&repo, &SkillsConfig::default());
492 assert!(got.skills.is_empty() && got.warnings.is_empty());
493 }
494
495 #[test]
498 fn user_root_and_extends_share_the_user_scope_with_the_home_root_winning() {
499 let (_g, repo) = tmp();
500 fs::create_dir(repo.join(".git")).unwrap();
501 let (_h, home) = tmp();
502 skill(&home.join("skills"), "commit", "description: home");
503 let (_e, team) = tmp();
504 skill(&team.join("skills"), "commit", "description: team");
505
506 let mut c = cfg();
507 c.extends_dirs = vec![team];
508 let got = discover_impl(&repo, &c, Some(&home.join("skills")));
509 assert_eq!(
510 got.skills
511 .iter()
512 .map(|s| &*s.description)
513 .collect::<Vec<_>>(),
514 vec!["home"],
515 "same scope ⇒ the home root shadows the extended dotfolder"
516 );
517 }
518
519 #[test]
522 fn project_skills_come_from_dot_agents_not_dot_locode() {
523 let (_g, repo) = tmp();
524 fs::create_dir(repo.join(".git")).unwrap();
525 skill(&repo.join(".agents/skills"), "shared", "description: D");
526 skill(&repo.join(".locode/skills"), "ours", "description: D");
527
528 let got = discover_no_home(&repo, &cfg());
529 assert_eq!(
530 names(&got),
531 vec!["shared"],
532 "`.locode/skills` is not a project skills root"
533 );
534 }
535
536 #[test]
537 fn slug_rules() {
538 assert_eq!(normalize_name("Review PR"), "review-pr");
539 assert_eq!(normalize_name("tool-v1.2"), "tool-v1-2");
540 assert_eq!(normalize_name("__weird__"), "weird");
541 assert_eq!(normalize_name("---"), "");
542 assert_eq!(normalize_name(&"a".repeat(100)).len(), 64);
543 }
544}