1use std::path::{Path, PathBuf};
15
16pub const MAX_SKILLS: usize = 64;
18const MAX_DESCRIPTION_CHARS: usize = 200;
20const MAX_INDEX_BYTES: usize = 8 * 1024;
22const MAX_SKILL_FILE_BYTES: usize = 8 * 1024;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum SkillSource {
29 Project,
31 User,
33 Plugin,
35}
36
37impl SkillSource {
38 fn label(self) -> &'static str {
41 match self {
42 SkillSource::Project => "project",
43 SkillSource::User => "user",
44 SkillSource::Plugin => "plugin",
45 }
46 }
47}
48
49#[derive(Debug, Clone)]
52pub struct SkillEntry {
53 pub name: String,
55 pub description: String,
57 pub path: PathBuf,
59 pub source: SkillSource,
61}
62
63#[derive(Debug, Clone)]
66pub struct LoadedSkills {
67 pub entries: Vec<SkillEntry>,
69 pub index: String,
71}
72
73pub fn load(cwd: &Path) -> Option<LoadedSkills> {
77 let project_root = crate::app::memory::find_git_root(cwd).unwrap_or_else(|| cwd.to_path_buf());
78 let project = discover_dir(
79 &project_root.join(".mermaid").join("skills"),
80 SkillSource::Project,
81 );
82 let user = match crate::app::get_config_dir() {
83 Ok(dir) => discover_dir(&dir.join("skills"), SkillSource::User),
84 Err(_) => Vec::new(),
85 };
86 let plugin = plugin_entries();
87 let entries = merge_by_precedence(vec![project, user, plugin]);
88 if entries.is_empty() {
89 return None;
90 }
91 let index = render_index(&entries);
92 Some(LoadedSkills { entries, index })
93}
94
95fn discover_dir(root: &Path, source: SkillSource) -> Vec<SkillEntry> {
98 let mut entries = Vec::new();
99 let Ok(read) = std::fs::read_dir(root) else {
100 return entries;
101 };
102 for dir in read.flatten() {
103 let path = dir.path();
104 if !dir.file_type().map(|t| t.is_dir()).unwrap_or(false) {
105 continue;
106 }
107 if let Some(entry) = read_skill_entry(&path.join("SKILL.md"), source) {
108 entries.push(entry);
109 }
110 }
111 entries.sort_by(|a, b| a.name.cmp(&b.name));
112 entries
113}
114
115fn read_skill_entry(path: &Path, source: SkillSource) -> Option<SkillEntry> {
118 let raw = match crate::utils::read_file_capped(path, MAX_SKILL_FILE_BYTES) {
119 Ok((bytes, _truncated)) => String::from_utf8_lossy(&bytes).into_owned(),
120 Err(e) => {
121 tracing::warn!(path = %path.display(), error = %e, "skills: skipping unreadable SKILL.md");
122 return None;
123 },
124 };
125 let (name, description) = parse_skill_frontmatter(&raw);
126 let name = name.or_else(|| {
128 path.parent()
129 .and_then(|p| p.file_name())
130 .and_then(|n| n.to_str())
131 .map(str::to_string)
132 })?;
133 let mut description = description.unwrap_or_default();
134 if description.chars().count() > MAX_DESCRIPTION_CHARS {
135 description = description.chars().take(MAX_DESCRIPTION_CHARS).collect();
136 description.push_str("...");
137 }
138 Some(SkillEntry {
139 name,
140 description,
141 path: path.to_path_buf(),
142 source,
143 })
144}
145
146fn parse_skill_frontmatter(raw: &str) -> (Option<String>, Option<String>) {
151 let (name, description, _) = parse_frontmatter_with_body(raw);
152 (name, description)
153}
154
155pub(crate) fn parse_frontmatter_with_body(raw: &str) -> (Option<String>, Option<String>, String) {
158 let raw = raw.strip_prefix('\u{feff}').unwrap_or(raw);
159 let mut name = None;
160 let mut description = None;
161 let mut body_lines: Vec<&str> = Vec::new();
162 let mut lines = raw.lines();
163 if lines.next().map(str::trim) == Some("---") {
164 let mut in_fm = true;
165 for line in lines {
166 if in_fm {
167 if line.trim() == "---" {
168 in_fm = false;
169 continue;
170 }
171 if let Some((key, value)) = line.split_once(':') {
172 let value = value.trim().trim_matches('"').to_string();
173 match key.trim() {
174 "name" if !value.is_empty() => name = Some(value),
175 "description" if !value.is_empty() => description = Some(value),
176 _ => {},
177 }
178 }
179 } else {
180 body_lines.push(line);
181 }
182 }
183 if in_fm {
184 name = None;
186 description = None;
187 body_lines = raw.lines().collect();
188 }
189 } else {
190 body_lines = raw.lines().collect();
191 }
192 let first_body_line = body_lines
193 .iter()
194 .map(|l| l.trim())
195 .find(|l| !l.is_empty())
196 .map(str::to_string);
197 (name, description.or(first_body_line), body_lines.join("\n"))
198}
199
200fn plugin_skill_paths(canonical_root: &Path, declared: &[String]) -> Vec<PathBuf> {
205 let mut out = Vec::new();
206 for entry in declared {
207 let Ok(mut resolved) = std::fs::canonicalize(canonical_root.join(entry)) else {
208 continue; };
210 if resolved.is_dir() {
211 let Ok(inner) = std::fs::canonicalize(resolved.join("SKILL.md")) else {
212 continue;
213 };
214 resolved = inner;
215 }
216 if !resolved.starts_with(canonical_root) {
217 tracing::warn!(entry = %entry, "plugin skill escapes plugin root; skipping");
218 continue;
219 }
220 out.push(resolved);
221 }
222 out
223}
224
225fn plugin_entries() -> Vec<SkillEntry> {
228 let Ok(store) = crate::runtime::RuntimeStore::open_default() else {
229 return Vec::new();
230 };
231 let Ok(plugins) = store.plugins().list() else {
232 return Vec::new();
233 };
234 let mut entries = Vec::new();
235 for plugin in plugins {
236 if !plugin.enabled {
239 continue;
240 }
241 let Ok(manifest) =
242 serde_json::from_str::<crate::runtime::PluginManifest>(&plugin.manifest_json)
243 else {
244 continue;
245 };
246 if manifest.skills.is_empty() {
247 continue;
248 }
249 let Ok(root) = std::fs::canonicalize(&plugin.source) else {
250 continue;
251 };
252 for path in plugin_skill_paths(&root, &manifest.skills) {
253 if let Some(entry) = read_skill_entry(&path, SkillSource::Plugin) {
254 entries.push(entry);
255 }
256 }
257 }
258 entries.sort_by(|a, b| a.name.cmp(&b.name));
259 entries
260}
261
262pub fn merge_by_precedence(groups: Vec<Vec<SkillEntry>>) -> Vec<SkillEntry> {
266 let mut seen = std::collections::HashSet::new();
267 let mut merged = Vec::new();
268 for group in groups {
269 for entry in group {
270 if seen.insert(entry.name.clone()) {
271 merged.push(entry);
272 }
273 }
274 }
275 merged
276}
277
278pub fn render_index(entries: &[SkillEntry]) -> String {
284 let mut out = String::from(
285 "# Skills\n\nTask-specific playbooks available on this machine. When a skill's \
286 description matches the task at hand, read its SKILL.md with `read_file` \
287 before proceeding.\n\n",
288 );
289 const OVERFLOW_RESERVE: usize = 32;
291 let mut listed = 0;
292 for entry in entries {
293 if listed == MAX_SKILLS {
294 break;
295 }
296 let line = format!(
297 "- [{}] {} — {} ({})\n",
298 entry.name,
299 entry.description,
300 entry.path.display(),
301 entry.source.label()
302 );
303 if out.len() + line.len() > MAX_INDEX_BYTES.saturating_sub(OVERFLOW_RESERVE) {
304 break;
305 }
306 out.push_str(&line);
307 listed += 1;
308 }
309 let skipped = entries.len() - listed;
310 if skipped > 0 {
311 out.push_str(&format!("(+{skipped} more not listed)\n"));
312 }
313 out
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use std::fs;
320
321 fn temp_dir(tag: &str) -> PathBuf {
324 let dir = std::env::temp_dir().join(format!(
325 "mermaid-skills-{tag}-{}-{:?}",
326 std::process::id(),
327 std::thread::current().id()
328 ));
329 let _ = fs::remove_dir_all(&dir);
330 fs::create_dir_all(&dir).unwrap();
331 dir
332 }
333
334 fn entry(name: &str, source: SkillSource) -> SkillEntry {
335 SkillEntry {
336 name: name.to_string(),
337 description: format!("{name} description"),
338 path: PathBuf::from(format!("/skills/{name}/SKILL.md")),
339 source,
340 }
341 }
342
343 #[test]
344 fn frontmatter_parses_name_and_description() {
345 let (name, desc) =
346 parse_skill_frontmatter("---\nname: deploy\ndescription: \"Ship it\"\n---\n\nBody.\n");
347 assert_eq!(name.as_deref(), Some("deploy"));
348 assert_eq!(desc.as_deref(), Some("Ship it"));
349 }
350
351 #[test]
352 fn frontmatter_missing_description_falls_back_to_first_body_line() {
353 let (name, desc) =
354 parse_skill_frontmatter("---\nname: deploy\n---\n\nFirst body line.\nSecond.\n");
355 assert_eq!(name.as_deref(), Some("deploy"));
356 assert_eq!(desc.as_deref(), Some("First body line."));
357 }
358
359 #[test]
360 fn frontmatter_absent_treats_whole_file_as_body() {
361 let (name, desc) = parse_skill_frontmatter("Just a body.\n");
362 assert_eq!(name, None);
363 assert_eq!(desc.as_deref(), Some("Just a body."));
364 }
365
366 #[test]
367 fn frontmatter_unclosed_fence_is_body() {
368 let (name, desc) = parse_skill_frontmatter("---\nname: broken\nno closing fence\n");
369 assert_eq!(name, None);
370 assert_eq!(desc.as_deref(), Some("---"));
372 }
373
374 #[test]
375 fn merge_dedupes_by_name_project_wins() {
376 let merged = merge_by_precedence(vec![
377 vec![entry("deploy", SkillSource::Project)],
378 vec![
379 entry("deploy", SkillSource::User),
380 entry("review", SkillSource::User),
381 ],
382 vec![entry("review", SkillSource::Plugin)],
383 ]);
384 assert_eq!(merged.len(), 2);
385 assert_eq!(merged[0].name, "deploy");
386 assert_eq!(merged[0].source, SkillSource::Project);
387 assert_eq!(merged[1].name, "review");
388 assert_eq!(merged[1].source, SkillSource::User);
389 }
390
391 #[test]
392 fn render_index_lists_entries_with_source_labels() {
393 let index = render_index(&[
394 entry("deploy", SkillSource::Project),
395 entry("review", SkillSource::User),
396 ]);
397 assert!(index.starts_with("# Skills\n"));
398 assert!(index.contains("read its SKILL.md with `read_file`"));
399 assert!(
400 index.contains("- [deploy] deploy description — /skills/deploy/SKILL.md (project)")
401 );
402 assert!(index.contains("- [review] review description — /skills/review/SKILL.md (user)"));
403 assert!(!index.contains("more not listed"));
404 }
405
406 #[test]
407 fn render_index_caps_entry_count_with_overflow_line() {
408 let entries: Vec<SkillEntry> = (0..MAX_SKILLS + 5)
409 .map(|i| entry(&format!("skill-{i:03}"), SkillSource::User))
410 .collect();
411 let index = render_index(&entries);
412 assert!(index.contains("skill-000"));
413 assert!(index.contains(&format!("skill-{:03}", MAX_SKILLS - 1)));
414 assert!(!index.contains(&format!("skill-{:03}", MAX_SKILLS)));
415 assert!(index.contains("(+5 more not listed)"));
416 }
417
418 #[test]
419 fn render_index_caps_bytes_with_overflow_line() {
420 let entries: Vec<SkillEntry> = (0..MAX_SKILLS)
421 .map(|i| SkillEntry {
422 name: format!("skill-{i:03}"),
423 description: "d".repeat(MAX_DESCRIPTION_CHARS),
424 path: PathBuf::from(format!("/skills/skill-{i:03}/SKILL.md")),
425 source: SkillSource::User,
426 })
427 .collect();
428 let index = render_index(&entries);
429 assert!(index.len() <= MAX_INDEX_BYTES);
430 assert!(index.contains("more not listed"));
431 }
432
433 #[test]
434 fn discover_reads_skill_dirs_sorted_and_tolerates_junk() {
435 let dir = temp_dir("discover");
436 let root = dir.join("skills");
437 fs::create_dir_all(root.join("zeta")).unwrap();
438 fs::write(
439 root.join("zeta").join("SKILL.md"),
440 "---\nname: zeta\ndescription: Last alphabetically\n---\nBody",
441 )
442 .unwrap();
443 fs::create_dir_all(root.join("alpha")).unwrap();
444 fs::write(root.join("alpha").join("SKILL.md"), "No frontmatter body.").unwrap();
445 fs::create_dir_all(root.join("empty")).unwrap();
447 fs::write(root.join("README.md"), "not a skill").unwrap();
448 let entries = discover_dir(&root, SkillSource::Project);
449 assert_eq!(entries.len(), 2);
450 assert_eq!(entries[0].name, "alpha"); assert_eq!(entries[0].description, "No frontmatter body.");
452 assert_eq!(entries[1].name, "zeta");
453 assert!(entries[1].path.ends_with("zeta/SKILL.md"));
454 let _ = fs::remove_dir_all(&dir);
455 }
456
457 #[test]
458 fn discover_missing_root_is_empty() {
459 let dir = temp_dir("missing-root");
460 assert!(discover_dir(&dir.join("nope"), SkillSource::User).is_empty());
461 let _ = fs::remove_dir_all(&dir);
462 }
463
464 #[test]
465 fn load_discovers_project_skills_from_git_root() {
466 let dir = temp_dir("load-project");
467 fs::create_dir(dir.join(".git")).unwrap();
468 let skill_dir = dir.join(".mermaid").join("skills").join("demo");
469 fs::create_dir_all(&skill_dir).unwrap();
470 fs::write(
471 skill_dir.join("SKILL.md"),
472 "---\nname: demo\ndescription: Demo skill\n---\nSteps.",
473 )
474 .unwrap();
475 let sub = dir.join("src");
477 fs::create_dir_all(&sub).unwrap();
478 let loaded = load(&sub).expect("project skill should be discovered");
479 assert!(loaded.entries.iter().any(|e| e.name == "demo"));
480 assert!(loaded.index.contains("[demo] Demo skill"));
481 let _ = fs::remove_dir_all(&dir);
482 }
483
484 #[cfg(unix)]
485 #[test]
486 fn plugin_skill_paths_enforce_containment() {
487 let dir = temp_dir("plugin-containment");
488 let root = dir.join("plugin");
489 let outside = dir.join("outside");
490 fs::create_dir_all(&root).unwrap();
491 fs::create_dir_all(&outside).unwrap();
492 fs::write(outside.join("SKILL.md"), "escaped").unwrap();
493 let inside = root.join("skills").join("ok");
495 fs::create_dir_all(&inside).unwrap();
496 fs::write(inside.join("SKILL.md"), "---\nname: ok\n---\nBody").unwrap();
497 std::os::unix::fs::symlink(&outside, root.join("evil")).unwrap();
499 let canonical_root = fs::canonicalize(&root).unwrap();
500 let paths = plugin_skill_paths(
501 &canonical_root,
502 &["skills/ok".to_string(), "evil".to_string()],
503 );
504 assert_eq!(paths.len(), 1);
505 assert!(paths[0].ends_with("ok/SKILL.md"));
506 let _ = fs::remove_dir_all(&dir);
507 }
508}