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