1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::{Component, Path, PathBuf};
3
4use serde_json::Value;
5
6use crate::error::MarsError;
7use crate::lock::{ItemId, ItemKind};
8use crate::types::ItemName;
9
10const RECURSIVE_SKIP_DIRS: &[&str] = &["node_modules", ".git", "dist", "build", "__pycache__"];
11const PLUGIN_MANIFESTS: &[&str] = &[
12 ".claude-plugin/plugin.json",
13 ".claude-plugin/marketplace.json",
14];
15const MAX_FALLBACK_DEPTH: usize = 5;
16const MAX_CONTAINER_ROOT_DEPTH: usize = 2;
17const MAX_HEURISTIC_FS_DEPTH: usize = MAX_FALLBACK_DEPTH + MAX_CONTAINER_ROOT_DEPTH;
18const SKILL_CONTAINER_ROOTS: &[&str] = &[
19 "skills",
20 "skills/.curated",
21 "skills/.experimental",
22 "skills/.system",
23 ".claude/skills",
24 ".codex/skills",
25];
26const AGENT_CONTAINER_ROOTS: &[&str] = &["agents", ".claude/agents", ".codex/agents"];
27const BOOTSTRAP_CONTAINER_ROOTS: &[&str] = &["bootstrap"];
28const MANIFEST_SKILL_KEYS: &[&str] = &["skills", "skill_paths", "skillPaths"];
29const MANIFEST_AGENT_KEYS: &[&str] = &["agents", "agent_paths", "agentPaths"];
30const MANIFEST_BOOTSTRAP_KEYS: &[&str] = &["bootstrapDocs", "bootstrap_docs"];
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DiscoveredItem {
35 pub id: ItemId,
36 pub source_path: PathBuf,
38}
39
40pub fn discover_source(
42 tree_path: &Path,
43 fallback_name: Option<&str>,
44) -> Result<Vec<DiscoveredItem>, MarsError> {
45 let mut items = Vec::new();
46
47 scan_agent_dir(
48 tree_path,
49 Path::new("agents"),
50 &mut items,
51 &mut HashSet::new(),
52 )?;
53 scan_skill_dir(
54 tree_path,
55 Path::new("skills"),
56 &mut items,
57 &mut HashSet::new(),
58 )?;
59 scan_bootstrap_dir(
60 tree_path,
61 Path::new("bootstrap"),
62 &mut items,
63 &mut HashSet::new(),
64 )?;
65
66 let has_agent_or_skill = items
67 .iter()
68 .any(|item| matches!(item.id.kind, ItemKind::Agent | ItemKind::Skill));
69 if !has_agent_or_skill && tree_path.join("SKILL.md").is_file() {
70 let name = fallback_name
71 .map(String::from)
72 .unwrap_or_else(|| package_basename(tree_path));
73 items.push(DiscoveredItem {
74 id: ItemId {
75 kind: ItemKind::Skill,
76 name: ItemName::from(name),
77 },
78 source_path: PathBuf::from("."),
79 });
80 }
81
82 sort_items(&mut items);
83 Ok(items)
84}
85
86pub fn discover_fallback(
88 package_root: &Path,
89 source_name: Option<&str>,
90) -> Result<Vec<DiscoveredItem>, MarsError> {
91 let source_name = source_name.unwrap_or("unknown-source");
92
93 if package_root.join("SKILL.md").is_file() {
94 let mut items = vec![DiscoveredItem {
95 id: ItemId {
96 kind: ItemKind::Skill,
97 name: ItemName::from(package_basename(package_root)),
98 },
99 source_path: PathBuf::from("."),
100 }];
101 items.extend(
102 discover_manifest_declared_items(package_root, source_name)?
103 .into_iter()
104 .filter(|item| item.id.kind == ItemKind::BootstrapDoc),
105 );
106 return finalize_items(source_name, items);
107 }
108
109 let explicit_items = discover_manifest_declared_items(package_root, source_name)?;
110 if !explicit_items.is_empty() {
111 return finalize_items(source_name, explicit_items);
112 }
113
114 let heuristic_items = discover_heuristic_layer_items(package_root)?;
115 finalize_items(source_name, heuristic_items)
116}
117
118pub fn discover_resolved_source(
120 package_root: &Path,
121 source_name: Option<&str>,
122) -> Result<Vec<DiscoveredItem>, MarsError> {
123 if package_root.join("mars.toml").is_file() {
124 discover_source(package_root, source_name)
125 } else {
126 discover_fallback(package_root, source_name)
127 }
128}
129
130fn scan_skill_dir(
131 package_root: &Path,
132 relative_root: &Path,
133 items: &mut Vec<DiscoveredItem>,
134 visited: &mut HashSet<PathBuf>,
135) -> Result<(), MarsError> {
136 let dir = package_root.join(relative_root);
137 if !dir.is_dir() {
138 return Ok(());
139 }
140
141 for path in read_dir_paths_sorted(&dir)? {
142 if !path.is_dir() {
143 continue;
144 }
145 if let Some(name) = path.file_name().and_then(|name| name.to_str())
146 && name.starts_with('.')
147 {
148 continue;
149 }
150 let rel = relative_to(package_root, &path)?;
151 register_skill_dir(package_root, &rel, items, visited)?;
152 }
153
154 Ok(())
155}
156
157fn scan_agent_dir(
158 package_root: &Path,
159 relative_root: &Path,
160 items: &mut Vec<DiscoveredItem>,
161 visited: &mut HashSet<PathBuf>,
162) -> Result<(), MarsError> {
163 let dir = package_root.join(relative_root);
164 if !dir.is_dir() {
165 return Ok(());
166 }
167
168 for path in read_dir_paths_sorted(&dir)? {
169 if !path.is_file() {
170 continue;
171 }
172 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
173 continue;
174 }
175 let rel = relative_to(package_root, &path)?;
176 register_agent_file(&rel, items, visited);
177 }
178
179 Ok(())
180}
181
182fn scan_bootstrap_dir(
183 package_root: &Path,
184 relative_root: &Path,
185 items: &mut Vec<DiscoveredItem>,
186 visited: &mut HashSet<PathBuf>,
187) -> Result<(), MarsError> {
188 let dir = package_root.join(relative_root);
189 if !dir.is_dir() {
190 return Ok(());
191 }
192
193 for path in read_dir_paths_sorted(&dir)? {
194 if !path.is_dir() {
195 continue;
196 }
197 if let Some(name) = path.file_name().and_then(|name| name.to_str())
198 && name.starts_with('.')
199 {
200 continue;
201 }
202 let rel = relative_to(package_root, &path)?;
203 register_bootstrap_doc(package_root, &rel, items, visited)?;
204 }
205
206 Ok(())
207}
208
209fn scan_manifest_declared_path(
210 package_root: &Path,
211 declared_path: &DeclaredPath,
212 items: &mut Vec<DiscoveredItem>,
213) -> Result<(), MarsError> {
214 let mut visited = HashSet::new();
215 let candidate = package_root.join(&declared_path.relative_path);
216 match declared_path.kind {
217 ItemKind::Skill => {
218 if candidate.join("SKILL.md").is_file() {
219 register_skill_dir(
220 package_root,
221 &declared_path.relative_path,
222 items,
223 &mut visited,
224 )?;
225 } else if matches_container_root(&declared_path.relative_path, SKILL_CONTAINER_ROOTS) {
226 scan_skill_dir(
227 package_root,
228 &declared_path.relative_path,
229 items,
230 &mut visited,
231 )?;
232 }
233 }
234 ItemKind::Agent => {
235 if candidate.is_file()
236 && candidate.extension().and_then(|ext| ext.to_str()) == Some("md")
237 {
238 register_agent_file(&declared_path.relative_path, items, &mut visited);
239 } else if matches_container_root(&declared_path.relative_path, AGENT_CONTAINER_ROOTS) {
240 scan_agent_dir(
241 package_root,
242 &declared_path.relative_path,
243 items,
244 &mut visited,
245 )?;
246 }
247 }
248 ItemKind::BootstrapDoc => {
249 if candidate.join("BOOTSTRAP.md").is_file() {
250 register_bootstrap_doc(
251 package_root,
252 &declared_path.relative_path,
253 items,
254 &mut visited,
255 )?;
256 } else if candidate
257 .file_name()
258 .and_then(|name| name.to_str())
259 .is_some_and(|name| name == "BOOTSTRAP.md")
260 && candidate.is_file()
261 && let Some(parent) = declared_path.relative_path.parent()
262 {
263 register_bootstrap_doc(package_root, parent, items, &mut visited)?;
264 } else if matches_container_root(
265 &declared_path.relative_path,
266 BOOTSTRAP_CONTAINER_ROOTS,
267 ) {
268 scan_bootstrap_dir(
269 package_root,
270 &declared_path.relative_path,
271 items,
272 &mut visited,
273 )?;
274 }
275 }
276 ItemKind::Hook | ItemKind::McpServer => {}
278 }
279
280 Ok(())
281}
282
283fn register_skill_dir(
284 package_root: &Path,
285 relative_path: &Path,
286 items: &mut Vec<DiscoveredItem>,
287 visited: &mut HashSet<PathBuf>,
288) -> Result<(), MarsError> {
289 let normalized = normalize_relative_path(relative_path);
290 if !visited.insert(normalized.clone()) {
291 return Ok(());
292 }
293 if !package_root.join(&normalized).join("SKILL.md").is_file() {
294 return Ok(());
295 }
296 let name = normalized
297 .file_name()
298 .and_then(|name| name.to_str())
299 .unwrap_or_default();
300 items.push(DiscoveredItem {
301 id: ItemId {
302 kind: ItemKind::Skill,
303 name: ItemName::from(name.to_string()),
304 },
305 source_path: normalized,
306 });
307 Ok(())
308}
309
310fn register_agent_file(
311 relative_path: &Path,
312 items: &mut Vec<DiscoveredItem>,
313 visited: &mut HashSet<PathBuf>,
314) {
315 let normalized = normalize_relative_path(relative_path);
316 if !visited.insert(normalized.clone()) {
317 return;
318 }
319 let name = normalized
320 .file_stem()
321 .and_then(|name| name.to_str())
322 .unwrap_or_default();
323 items.push(DiscoveredItem {
324 id: ItemId {
325 kind: ItemKind::Agent,
326 name: ItemName::from(name.to_string()),
327 },
328 source_path: normalized,
329 });
330}
331
332fn register_bootstrap_doc(
333 package_root: &Path,
334 relative_path: &Path,
335 items: &mut Vec<DiscoveredItem>,
336 visited: &mut HashSet<PathBuf>,
337) -> Result<(), MarsError> {
338 let normalized = normalize_relative_path(relative_path);
339 if !visited.insert(normalized.clone()) {
340 return Ok(());
341 }
342 if !package_root
343 .join(&normalized)
344 .join("BOOTSTRAP.md")
345 .is_file()
346 {
347 return Ok(());
348 }
349 let name = normalized
350 .file_name()
351 .and_then(|name| name.to_str())
352 .unwrap_or_default();
353 items.push(DiscoveredItem {
354 id: ItemId {
355 kind: ItemKind::BootstrapDoc,
356 name: ItemName::from(name.to_string()),
357 },
358 source_path: normalized,
359 });
360 Ok(())
361}
362
363fn discover_manifest_declared_items(
364 package_root: &Path,
365 source_name: &str,
366) -> Result<Vec<DiscoveredItem>, MarsError> {
367 let mut items = Vec::new();
368 for declared_path in collect_manifest_declared_paths(package_root, source_name)? {
369 scan_manifest_declared_path(package_root, &declared_path, &mut items)?;
370 }
371 Ok(dedupe_items_by_path(items))
372}
373
374fn discover_heuristic_layer_items(package_root: &Path) -> Result<Vec<DiscoveredItem>, MarsError> {
375 let candidates = collect_heuristic_candidates(package_root)?;
376 let Some(min_layer) = candidates.iter().map(|candidate| candidate.layer).min() else {
377 return Ok(Vec::new());
378 };
379
380 let items = candidates
381 .into_iter()
382 .filter(|candidate| candidate.layer == min_layer)
383 .map(|candidate| candidate.item)
384 .collect();
385 let items = dedupe_items_by_path(items);
386 Ok(dedupe_items_by_name_first_seen(items))
387}
388
389fn collect_heuristic_candidates(package_root: &Path) -> Result<Vec<LayeredCandidate>, MarsError> {
390 let mut candidates = Vec::new();
391 let mut queue = VecDeque::from([(package_root.to_path_buf(), 0usize)]);
392
393 while let Some((base_dir, depth)) = queue.pop_front() {
394 if depth > MAX_HEURISTIC_FS_DEPTH {
395 continue;
396 }
397
398 let base_rel = if base_dir == package_root {
399 PathBuf::new()
400 } else {
401 relative_to(package_root, &base_dir)?
402 };
403 collect_heuristic_candidates_at_base(package_root, &base_rel, &mut candidates)?;
404
405 if depth == MAX_HEURISTIC_FS_DEPTH {
406 continue;
407 }
408
409 for path in read_dir_paths_sorted(&base_dir)? {
410 if !path.is_dir() {
411 continue;
412 }
413 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
414 continue;
415 };
416 if RECURSIVE_SKIP_DIRS.contains(&name) {
417 continue;
418 }
419 queue.push_back((path, depth + 1));
420 }
421 }
422
423 Ok(candidates)
424}
425
426fn collect_heuristic_candidates_at_base(
427 package_root: &Path,
428 base_rel: &Path,
429 candidates: &mut Vec<LayeredCandidate>,
430) -> Result<(), MarsError> {
431 collect_direct_skill_children(package_root, base_rel, candidates)?;
432 for root in SKILL_CONTAINER_ROOTS {
433 collect_skill_container_candidates(
434 package_root,
435 &join_relative(base_rel, Path::new(root)),
436 candidates,
437 )?;
438 }
439 for root in AGENT_CONTAINER_ROOTS {
440 collect_agent_container_candidates(
441 package_root,
442 &join_relative(base_rel, Path::new(root)),
443 candidates,
444 )?;
445 }
446 for root in BOOTSTRAP_CONTAINER_ROOTS {
447 collect_bootstrap_container_candidates(
448 package_root,
449 &join_relative(base_rel, Path::new(root)),
450 candidates,
451 )?;
452 }
453 Ok(())
454}
455
456fn collect_direct_skill_children(
457 package_root: &Path,
458 base_rel: &Path,
459 candidates: &mut Vec<LayeredCandidate>,
460) -> Result<(), MarsError> {
461 let base_dir = package_root.join(base_rel);
462 if !base_dir.is_dir() {
463 return Ok(());
464 }
465
466 for path in read_dir_paths_sorted(&base_dir)? {
467 if !path.is_dir() {
468 continue;
469 }
470 if let Some(name) = path.file_name().and_then(|name| name.to_str())
471 && name.starts_with('.')
472 {
473 continue;
474 }
475 let rel = relative_to(package_root, &path)?;
476 if !path.join("SKILL.md").is_file() {
477 continue;
478 }
479 candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
480 }
481
482 Ok(())
483}
484
485fn collect_skill_container_candidates(
486 package_root: &Path,
487 container_rel: &Path,
488 candidates: &mut Vec<LayeredCandidate>,
489) -> Result<(), MarsError> {
490 let container_dir = package_root.join(container_rel);
491 if !container_dir.is_dir() {
492 return Ok(());
493 }
494
495 for path in read_dir_paths_sorted(&container_dir)? {
496 if !path.is_dir() {
497 continue;
498 }
499 if let Some(name) = path.file_name().and_then(|name| name.to_str())
500 && name.starts_with('.')
501 {
502 continue;
503 }
504 if !path.join("SKILL.md").is_file() {
505 continue;
506 }
507 let rel = relative_to(package_root, &path)?;
508 candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
509 }
510
511 Ok(())
512}
513
514fn collect_agent_container_candidates(
515 package_root: &Path,
516 container_rel: &Path,
517 candidates: &mut Vec<LayeredCandidate>,
518) -> Result<(), MarsError> {
519 let container_dir = package_root.join(container_rel);
520 if !container_dir.is_dir() {
521 return Ok(());
522 }
523
524 for path in read_dir_paths_sorted(&container_dir)? {
525 if !path.is_file() {
526 continue;
527 }
528 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
529 continue;
530 }
531 let rel = relative_to(package_root, &path)?;
532 candidates.push(LayeredCandidate::new(ItemKind::Agent, rel)?);
533 }
534
535 Ok(())
536}
537
538fn collect_bootstrap_container_candidates(
539 package_root: &Path,
540 container_rel: &Path,
541 candidates: &mut Vec<LayeredCandidate>,
542) -> Result<(), MarsError> {
543 let container_dir = package_root.join(container_rel);
544 if !container_dir.is_dir() {
545 return Ok(());
546 }
547
548 for path in read_dir_paths_sorted(&container_dir)? {
549 if !path.is_dir() {
550 continue;
551 }
552 if let Some(name) = path.file_name().and_then(|name| name.to_str())
553 && name.starts_with('.')
554 {
555 continue;
556 }
557 if !path.join("BOOTSTRAP.md").is_file() {
558 continue;
559 }
560 let rel = relative_to(package_root, &path)?;
561 candidates.push(LayeredCandidate::new(ItemKind::BootstrapDoc, rel)?);
562 }
563
564 Ok(())
565}
566
567fn finalize_items(
568 source_name: &str,
569 mut items: Vec<DiscoveredItem>,
570) -> Result<Vec<DiscoveredItem>, MarsError> {
571 ensure_unique_names(source_name, &items)?;
572 sort_items(&mut items);
573 Ok(items)
574}
575
576fn dedupe_items_by_path(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
577 let mut seen = HashSet::new();
578 let mut deduped = Vec::with_capacity(items.len());
579 for item in items {
580 if seen.insert(item.source_path.clone()) {
581 deduped.push(item);
582 }
583 }
584 deduped
585}
586
587fn dedupe_items_by_name_first_seen(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
588 let mut seen = HashSet::new();
589 let mut deduped = Vec::with_capacity(items.len());
590 for item in items {
591 let key = (item.id.kind, item.id.name.to_string());
592 if seen.insert(key) {
593 deduped.push(item);
594 }
595 }
596 deduped
597}
598
599fn collect_manifest_declared_paths(
600 package_root: &Path,
601 source_name: &str,
602) -> Result<Vec<DeclaredPath>, MarsError> {
603 let mut declared = Vec::new();
604 for manifest in PLUGIN_MANIFESTS {
605 let path = package_root.join(manifest);
606 if !path.is_file() {
607 continue;
608 }
609 let content = std::fs::read_to_string(&path)?;
610 let json: Value = serde_json::from_str(&content).map_err(|e| MarsError::Source {
611 source_name: source_name.to_string(),
612 message: format!("failed to parse plugin manifest `{}`: {e}", path.display()),
613 })?;
614 declared.extend(parse_declared_paths(&json));
615 }
616
617 let mut resolved = Vec::new();
618 let mut seen = HashSet::new();
619 for raw in declared {
620 if !raw.raw_path.starts_with("./") {
621 continue;
622 }
623 let normalized = normalize_manifest_declared_path(&raw.raw_path).ok_or_else(|| {
624 MarsError::ManifestDeclaredPathEscape {
625 source_name: source_name.to_string(),
626 manifest_path: raw.raw_path.display().to_string(),
627 package_root: package_root.to_path_buf(),
628 }
629 })?;
630 let candidate = package_root.join(&normalized);
631 if !candidate.exists() {
632 return Err(MarsError::ManifestDeclaredPathMissing {
633 source_name: source_name.to_string(),
634 manifest_path: raw.raw_path.display().to_string(),
635 package_root: package_root.to_path_buf(),
636 });
637 }
638 let canonical = dunce::canonicalize(&candidate).map_err(|_| {
639 MarsError::ManifestDeclaredPathMissing {
640 source_name: source_name.to_string(),
641 manifest_path: raw.raw_path.display().to_string(),
642 package_root: package_root.to_path_buf(),
643 }
644 })?;
645 let canonical_root = dunce::canonicalize(package_root).map_err(|e| MarsError::Source {
646 source_name: source_name.to_string(),
647 message: format!(
648 "failed to canonicalize package root `{}`: {e}",
649 package_root.display()
650 ),
651 })?;
652 if !canonical.starts_with(&canonical_root) {
653 return Err(MarsError::ManifestDeclaredPathEscape {
654 source_name: source_name.to_string(),
655 manifest_path: raw.raw_path.display().to_string(),
656 package_root: package_root.to_path_buf(),
657 });
658 }
659 let rel = relative_to(package_root, &candidate)?;
660 if seen.insert((raw.kind, rel.clone())) {
661 resolved.push(DeclaredPath {
662 kind: raw.kind,
663 relative_path: rel,
664 });
665 }
666 }
667 Ok(resolved)
668}
669
670fn ensure_unique_names(source_name: &str, items: &[DiscoveredItem]) -> Result<(), MarsError> {
671 let mut seen: HashMap<(ItemKind, String), PathBuf> = HashMap::new();
672 for item in items {
673 let key = (item.id.kind, item.id.name.to_string());
674 if let Some(existing) = seen.insert(key.clone(), item.source_path.clone()) {
675 return Err(MarsError::DiscoveryCollision {
676 source_name: source_name.to_string(),
677 kind: item.id.kind.to_string(),
678 item_name: item.id.name.to_string(),
679 path_a: existing,
680 path_b: item.source_path.clone(),
681 });
682 }
683 }
684 Ok(())
685}
686
687fn relative_to(base: &Path, child: &Path) -> Result<PathBuf, MarsError> {
688 child
689 .strip_prefix(base)
690 .map(|path| path.to_path_buf())
691 .map_err(|_| MarsError::Source {
692 source_name: "discover".to_string(),
693 message: format!(
694 "path `{}` is not under package root `{}`",
695 child.display(),
696 base.display()
697 ),
698 })
699}
700
701fn normalize_relative_path(path: &Path) -> PathBuf {
702 let mut normalized = PathBuf::new();
703 for component in path.components() {
704 normalized.push(component.as_os_str());
705 }
706 normalized
707}
708
709fn normalize_manifest_declared_path(path: &Path) -> Option<PathBuf> {
710 let mut normalized = PathBuf::new();
711 for component in path.components() {
712 match component {
713 Component::CurDir => {}
714 Component::Normal(seg) => normalized.push(seg),
715 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
716 }
717 }
718 if normalized.as_os_str().is_empty() {
719 None
720 } else {
721 Some(normalized)
722 }
723}
724
725fn package_basename(path: &Path) -> String {
726 path.file_name()
727 .and_then(|name| name.to_str())
728 .filter(|name| !name.is_empty())
729 .unwrap_or("unknown-skill")
730 .to_string()
731}
732
733fn read_dir_paths_sorted(dir: &Path) -> Result<Vec<PathBuf>, MarsError> {
734 let mut paths = Vec::new();
735 for entry in std::fs::read_dir(dir)? {
736 paths.push(entry?.path());
737 }
738 paths.sort();
739 Ok(paths)
740}
741
742fn join_relative(base: &Path, suffix: &Path) -> PathBuf {
743 if base.as_os_str().is_empty() {
744 suffix.to_path_buf()
745 } else {
746 base.join(suffix)
747 }
748}
749
750fn matches_container_root(path: &Path, roots: &[&str]) -> bool {
751 roots.iter().any(|root| path == Path::new(root))
752}
753
754fn parse_declared_paths(json: &Value) -> Vec<RawDeclaredPath> {
755 let Some(map) = json.as_object() else {
756 return Vec::new();
757 };
758
759 let mut declared = Vec::new();
760 for key in MANIFEST_SKILL_KEYS {
761 if let Some(value) = map.get(*key) {
762 collect_declared_paths_from_value(ItemKind::Skill, value, &mut declared);
763 }
764 }
765 for key in MANIFEST_AGENT_KEYS {
766 if let Some(value) = map.get(*key) {
767 collect_declared_paths_from_value(ItemKind::Agent, value, &mut declared);
768 }
769 }
770 for key in MANIFEST_BOOTSTRAP_KEYS {
771 if let Some(value) = map.get(*key) {
772 collect_declared_paths_from_value(ItemKind::BootstrapDoc, value, &mut declared);
773 }
774 }
775 declared
776}
777
778fn collect_declared_paths_from_value(
779 kind: ItemKind,
780 value: &Value,
781 declared: &mut Vec<RawDeclaredPath>,
782) {
783 match value {
784 Value::String(path) => declared.push(RawDeclaredPath {
785 kind,
786 raw_path: PathBuf::from(path),
787 }),
788 Value::Array(values) => {
789 for child in values {
790 collect_declared_paths_from_value(kind, child, declared);
791 }
792 }
793 Value::Object(map) => {
794 if let Some(path) = map.get("path").and_then(|value| value.as_str()) {
795 declared.push(RawDeclaredPath {
796 kind,
797 raw_path: PathBuf::from(path),
798 });
799 }
800 }
801 _ => {}
802 }
803}
804
805fn split_segments(path: &Path) -> Vec<String> {
806 path.components()
807 .filter_map(|component| match component {
808 Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
809 _ => None,
810 })
811 .collect()
812}
813
814fn logical_layer(kind: ItemKind, relative_path: &Path) -> Result<usize, MarsError> {
815 let segments = split_segments(relative_path);
816 let default_layer = match kind {
817 ItemKind::Skill => segments.len(),
818 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer | ItemKind::BootstrapDoc => {
819 usize::MAX
820 }
821 };
822 let container_roots = match kind {
823 ItemKind::Skill => SKILL_CONTAINER_ROOTS,
824 ItemKind::BootstrapDoc => BOOTSTRAP_CONTAINER_ROOTS,
825 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => AGENT_CONTAINER_ROOTS,
826 };
827
828 let mut layer = default_layer;
829 for root in container_roots {
830 let root_segments: Vec<&str> = root.split('/').collect();
831 if segments.len() < root_segments.len() + 1 {
832 continue;
833 }
834 let start = segments.len() - 1 - root_segments.len();
835 if segments[start..start + root_segments.len()]
836 .iter()
837 .map(String::as_str)
838 .eq(root_segments.iter().copied())
839 {
840 layer = layer.min(start + 1);
841 }
842 }
843
844 if layer == usize::MAX || layer == 0 || layer > MAX_FALLBACK_DEPTH {
845 return Err(MarsError::Source {
846 source_name: "discover".to_string(),
847 message: format!(
848 "invalid logical discovery layer for `{}`",
849 relative_path.display()
850 ),
851 });
852 }
853
854 Ok(layer)
855}
856
857#[derive(Debug, Clone)]
858struct RawDeclaredPath {
859 kind: ItemKind,
860 raw_path: PathBuf,
861}
862
863#[derive(Debug, Clone)]
864struct DeclaredPath {
865 kind: ItemKind,
866 relative_path: PathBuf,
867}
868
869#[derive(Debug, Clone)]
870struct LayeredCandidate {
871 item: DiscoveredItem,
872 layer: usize,
873}
874
875impl LayeredCandidate {
876 fn new(kind: ItemKind, source_path: PathBuf) -> Result<Self, MarsError> {
877 let item = match kind {
878 ItemKind::Skill => DiscoveredItem {
879 id: ItemId {
880 kind,
881 name: ItemName::from(
882 source_path
883 .file_name()
884 .and_then(|name| name.to_str())
885 .unwrap_or_default()
886 .to_string(),
887 ),
888 },
889 source_path: normalize_relative_path(&source_path),
890 },
891 ItemKind::Agent | ItemKind::Hook | ItemKind::McpServer => DiscoveredItem {
892 id: ItemId {
893 kind,
894 name: ItemName::from(
895 source_path
896 .file_stem()
897 .and_then(|name| name.to_str())
898 .unwrap_or_default()
899 .to_string(),
900 ),
901 },
902 source_path: normalize_relative_path(&source_path),
903 },
904 ItemKind::BootstrapDoc => DiscoveredItem {
905 id: ItemId {
906 kind,
907 name: ItemName::from(
908 source_path
909 .file_name()
910 .and_then(|name| name.to_str())
911 .unwrap_or_default()
912 .to_string(),
913 ),
914 },
915 source_path: normalize_relative_path(&source_path),
916 },
917 };
918
919 Ok(Self {
920 layer: logical_layer(kind, &item.source_path)?,
921 item,
922 })
923 }
924}
925
926fn sort_items(items: &mut [DiscoveredItem]) {
927 items.sort_by(|a, b| {
928 a.id.cmp(&b.id)
929 .then_with(|| a.source_path.cmp(&b.source_path))
930 });
931}
932
933#[derive(Debug, Clone)]
935pub struct InstalledItem {
936 pub id: ItemId,
937 pub path: PathBuf,
939 pub frontmatter_name: Option<String>,
941 pub description: Option<String>,
943 pub skill_refs: Vec<String>,
945}
946
947#[derive(Debug, Clone)]
949pub struct InstalledState {
950 pub agents: Vec<InstalledItem>,
951 pub skills: Vec<InstalledItem>,
952}
953
954pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
956 let mut agents = Vec::new();
957 let mut skills = Vec::new();
958
959 let mut scratch = Vec::new();
960 let mut visited = HashSet::new();
961 scan_agent_dir(root, Path::new("agents"), &mut scratch, &mut visited)?;
962 for item in scratch.drain(..) {
963 let path = root.join(&item.source_path);
964 let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
965 agents.push(InstalledItem {
966 id: item.id,
967 path,
968 frontmatter_name,
969 description,
970 skill_refs,
971 });
972 }
973
974 scan_skill_dir(root, Path::new("skills"), &mut scratch, &mut HashSet::new())?;
975 for item in scratch.drain(..) {
976 let path = root.join(&item.source_path);
977 let skill_md = if item.source_path == Path::new(".") {
978 root.join("SKILL.md")
979 } else {
980 path.join("SKILL.md")
981 };
982 let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
983 skills.push(InstalledItem {
984 id: item.id,
985 path,
986 frontmatter_name,
987 description,
988 skill_refs: Vec::new(),
989 });
990 }
991
992 sort_installed(&mut agents);
993 sort_installed(&mut skills);
994 Ok(InstalledState { agents, skills })
995}
996
997fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
998 let content = match std::fs::read_to_string(path) {
999 Ok(c) => c,
1000 Err(_) => return (None, None, Vec::new()),
1001 };
1002 match crate::frontmatter::parse(&content) {
1003 Ok(fm) => {
1004 let name = fm.name().map(str::to_owned);
1005 let description = fm
1006 .get("description")
1007 .and_then(|value| value.as_str())
1008 .map(str::to_owned);
1009 (name, description, fm.skills())
1010 }
1011 Err(_) => (None, None, Vec::new()),
1012 }
1013}
1014
1015fn sort_installed(items: &mut [InstalledItem]) {
1016 items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.path.cmp(&b.path)));
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use std::fs;
1023 use tempfile::TempDir;
1024
1025 #[test]
1026 fn conventional_discovery_finds_agents_and_skills() {
1027 let dir = TempDir::new().unwrap();
1028 fs::create_dir_all(dir.path().join("agents")).unwrap();
1029 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1030 fs::write(dir.path().join("agents/coder.md"), "# coder").unwrap();
1031 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
1032
1033 let items = discover_source(dir.path(), None).unwrap();
1034 assert_eq!(items.len(), 2);
1035 assert!(
1036 items
1037 .iter()
1038 .any(|item| item.source_path == Path::new("agents/coder.md"))
1039 );
1040 assert!(
1041 items
1042 .iter()
1043 .any(|item| item.source_path == Path::new("skills/planning"))
1044 );
1045 }
1046
1047 #[test]
1048 fn conventional_discovery_finds_package_bootstrap_docs() {
1049 let dir = TempDir::new().unwrap();
1050 fs::create_dir_all(dir.path().join("bootstrap/global-auth")).unwrap();
1051 fs::create_dir_all(dir.path().join("bootstrap/.hidden")).unwrap();
1052 fs::write(
1053 dir.path().join("bootstrap/global-auth/BOOTSTRAP.md"),
1054 "# auth",
1055 )
1056 .unwrap();
1057 fs::write(dir.path().join("bootstrap/.hidden/BOOTSTRAP.md"), "# hide").unwrap();
1058
1059 let items = discover_source(dir.path(), None).unwrap();
1060 assert_eq!(items.len(), 1);
1061 assert_eq!(items[0].id.kind, ItemKind::BootstrapDoc);
1062 assert_eq!(items[0].id.name.as_str(), "global-auth");
1063 assert_eq!(items[0].source_path, PathBuf::from("bootstrap/global-auth"));
1064 }
1065
1066 #[test]
1067 fn conventional_bootstrap_discovery_ignores_missing_bootstrap_file() {
1068 let dir = TempDir::new().unwrap();
1069 fs::create_dir_all(dir.path().join("bootstrap/incomplete")).unwrap();
1070 fs::write(
1071 dir.path().join("bootstrap/incomplete/README.md"),
1072 "# readme",
1073 )
1074 .unwrap();
1075
1076 let items = discover_source(dir.path(), None).unwrap();
1077 assert!(items.is_empty());
1078 }
1079
1080 #[test]
1081 fn dispatcher_prefers_conventional_when_manifest_exists() {
1082 let dir = TempDir::new().unwrap();
1083 fs::write(
1084 dir.path().join("mars.toml"),
1085 "[package]\nname='demo'\nversion='0.1.0'\n",
1086 )
1087 .unwrap();
1088 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1089 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
1090 fs::create_dir_all(dir.path().join("nested")).unwrap();
1091 fs::write(dir.path().join("nested/SKILL.md"), "# nested").unwrap();
1092
1093 let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
1094 assert_eq!(items.len(), 1);
1095 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1096 }
1097
1098 #[test]
1099 fn fallback_short_circuits_root_skill() {
1100 let dir = TempDir::new().unwrap();
1101 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
1102 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1103 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
1104
1105 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1106 assert_eq!(items.len(), 1);
1107 assert_eq!(
1108 items[0].id.name.as_str(),
1109 dir.path().file_name().unwrap().to_string_lossy().as_ref()
1110 );
1111 assert_eq!(items[0].source_path, PathBuf::from("."));
1112 }
1113
1114 #[test]
1115 fn fallback_root_skill_includes_manifest_bootstrap_docs() {
1116 let dir = TempDir::new().unwrap();
1117 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
1118 fs::create_dir_all(dir.path().join("docs/global-auth")).unwrap();
1119 fs::write(dir.path().join("docs/global-auth/BOOTSTRAP.md"), "# auth").unwrap();
1120 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1121 fs::write(
1122 dir.path().join(".claude-plugin/plugin.json"),
1123 r#"{"bootstrapDocs":[{"path":"./docs/global-auth"}]}"#,
1124 )
1125 .unwrap();
1126
1127 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1128
1129 assert_eq!(items.len(), 2);
1130 assert!(
1131 items
1132 .iter()
1133 .any(|item| item.id.kind == ItemKind::Skill && item.source_path == Path::new("."))
1134 );
1135 assert!(items.iter().any(|item| {
1136 item.id.kind == ItemKind::BootstrapDoc
1137 && item.source_path == Path::new("docs/global-auth")
1138 }));
1139 }
1140
1141 #[test]
1142 fn fallback_priority_scan_finds_skill_dirs_and_agents() {
1143 let dir = TempDir::new().unwrap();
1144 fs::create_dir_all(dir.path().join("skills/.experimental/find-skills")).unwrap();
1145 fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
1146 fs::write(
1147 dir.path().join("skills/.experimental/find-skills/SKILL.md"),
1148 "# skill",
1149 )
1150 .unwrap();
1151 fs::write(dir.path().join(".claude/agents/reviewer.md"), "# agent").unwrap();
1152
1153 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1154 assert_eq!(items.len(), 2);
1155 assert!(
1156 items
1157 .iter()
1158 .any(|item| item.source_path == Path::new("skills/.experimental/find-skills"))
1159 );
1160 assert!(
1161 items
1162 .iter()
1163 .any(|item| item.source_path == Path::new(".claude/agents/reviewer.md"))
1164 );
1165 }
1166
1167 #[test]
1168 fn conventional_root_skill_does_not_override_conventional_items() {
1169 let dir = TempDir::new().unwrap();
1170 fs::write(
1171 dir.path().join("mars.toml"),
1172 "[package]\nname='demo'\nversion='0.1.0'\n",
1173 )
1174 .unwrap();
1175 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
1176 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1177 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
1178
1179 let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
1180 assert_eq!(items.len(), 1);
1181 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1182 }
1183
1184 #[test]
1185 fn conventional_root_skill_survives_bootstrap_only_discovery() {
1186 let dir = TempDir::new().unwrap();
1187 fs::write(
1188 dir.path().join("mars.toml"),
1189 "[package]\nname='demo'\nversion='0.1.0'\n",
1190 )
1191 .unwrap();
1192 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
1193 fs::create_dir_all(dir.path().join("bootstrap/global-auth")).unwrap();
1194 fs::write(
1195 dir.path().join("bootstrap/global-auth/BOOTSTRAP.md"),
1196 "# auth",
1197 )
1198 .unwrap();
1199
1200 let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
1201
1202 assert_eq!(items.len(), 2);
1203 assert!(items.iter().any(|item| {
1204 item.id.kind == ItemKind::Skill
1205 && item.id.name.as_str() == "demo"
1206 && item.source_path == Path::new(".")
1207 }));
1208 assert!(items.iter().any(|item| {
1209 item.id.kind == ItemKind::BootstrapDoc
1210 && item.id.name.as_str() == "global-auth"
1211 && item.source_path == Path::new("bootstrap/global-auth")
1212 }));
1213 }
1214
1215 #[test]
1216 fn fallback_manifest_paths_precede_heuristic_layers() {
1217 let dir = TempDir::new().unwrap();
1218 fs::create_dir_all(dir.path().join("top-level")).unwrap();
1219 fs::create_dir_all(dir.path().join("plugins/deep-skill")).unwrap();
1220 fs::write(dir.path().join("top-level/SKILL.md"), "# top").unwrap();
1221 fs::write(dir.path().join("plugins/deep-skill/SKILL.md"), "# deep").unwrap();
1222 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1223 fs::write(
1224 dir.path().join(".claude-plugin/plugin.json"),
1225 r#"{"skills":[{"path":"./plugins/deep-skill"}]}"#,
1226 )
1227 .unwrap();
1228
1229 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1230 assert_eq!(items.len(), 1);
1231 assert_eq!(items[0].source_path, PathBuf::from("plugins/deep-skill"));
1232 }
1233
1234 #[test]
1235 fn fallback_dedupes_overlapping_manifest_and_container_paths() {
1236 let dir = TempDir::new().unwrap();
1237 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1238 fs::write(dir.path().join("skills/planning/SKILL.md"), "# skill").unwrap();
1239 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1240 fs::write(
1241 dir.path().join(".claude-plugin/plugin.json"),
1242 r#"{"skills":[{"path":"./skills/planning"}]}"#,
1243 )
1244 .unwrap();
1245
1246 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1247 assert_eq!(items.len(), 1);
1248 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1249 }
1250
1251 #[test]
1252 fn fallback_manifest_declares_agent_paths_without_heuristic_json_walk() {
1253 let dir = TempDir::new().unwrap();
1254 fs::create_dir_all(dir.path().join("agents")).unwrap();
1255 fs::write(dir.path().join("agents/reviewer.md"), "# reviewer").unwrap();
1256 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1257 fs::write(
1258 dir.path().join(".claude-plugin/plugin.json"),
1259 r#"{"agents":[{"path":"./agents/reviewer.md"}],"metadata":{"agents":[{"path":"./ignore.md"}]}}"#,
1260 )
1261 .unwrap();
1262
1263 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1264 assert_eq!(items.len(), 1);
1265 assert_eq!(items[0].source_path, PathBuf::from("agents/reviewer.md"));
1266 }
1267
1268 #[test]
1269 fn fallback_manifest_declares_bootstrap_docs() {
1270 let dir = TempDir::new().unwrap();
1271 fs::create_dir_all(dir.path().join("docs/global-auth")).unwrap();
1272 fs::write(dir.path().join("docs/global-auth/BOOTSTRAP.md"), "# auth").unwrap();
1273 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1274 fs::write(
1275 dir.path().join(".claude-plugin/plugin.json"),
1276 r#"{"bootstrapDocs":[{"path":"./docs/global-auth"}]}"#,
1277 )
1278 .unwrap();
1279
1280 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1281 assert_eq!(items.len(), 1);
1282 assert_eq!(items[0].id.kind, ItemKind::BootstrapDoc);
1283 assert_eq!(items[0].id.name.as_str(), "global-auth");
1284 assert_eq!(items[0].source_path, PathBuf::from("docs/global-auth"));
1285 }
1286
1287 #[test]
1288 fn fallback_manifest_declares_bootstrap_container() {
1289 let dir = TempDir::new().unwrap();
1290 fs::create_dir_all(dir.path().join("bootstrap/setup")).unwrap();
1291 fs::write(dir.path().join("bootstrap/setup/BOOTSTRAP.md"), "# setup").unwrap();
1292 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1293 fs::write(
1294 dir.path().join(".claude-plugin/plugin.json"),
1295 r#"{"bootstrap_docs":["./bootstrap"]}"#,
1296 )
1297 .unwrap();
1298
1299 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1300 assert_eq!(items.len(), 1);
1301 assert_eq!(items[0].id.kind, ItemKind::BootstrapDoc);
1302 assert_eq!(items[0].id.name.as_str(), "setup");
1303 assert_eq!(items[0].source_path, PathBuf::from("bootstrap/setup"));
1304 }
1305
1306 #[test]
1307 fn fallback_prefers_first_match_after_visit_dedupe() {
1308 let dir = TempDir::new().unwrap();
1309 fs::create_dir_all(dir.path().join("skills/plan")).unwrap();
1310 fs::create_dir_all(dir.path().join("plan")).unwrap();
1311 fs::write(dir.path().join("skills/plan/SKILL.md"), "# skill a").unwrap();
1312 fs::write(dir.path().join("plan/SKILL.md"), "# skill b").unwrap();
1313
1314 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1315 assert_eq!(items.len(), 1);
1316 assert_eq!(items[0].source_path, PathBuf::from("plan"));
1317 }
1318
1319 #[test]
1320 fn fallback_prefers_first_mirrored_skill_match() {
1321 let dir = TempDir::new().unwrap();
1322 fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1323 fs::create_dir_all(dir.path().join("caveman")).unwrap();
1324 fs::write(dir.path().join("skills/caveman/SKILL.md"), "# same").unwrap();
1325 fs::write(dir.path().join("caveman/SKILL.md"), "# same").unwrap();
1326
1327 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1328 assert_eq!(items.len(), 1);
1329 assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1330 }
1331
1332 #[test]
1333 fn fallback_heuristic_finds_bootstrap_container_docs() {
1334 let dir = TempDir::new().unwrap();
1335 fs::create_dir_all(dir.path().join("nested/bootstrap/setup")).unwrap();
1336 fs::create_dir_all(dir.path().join("nested/bootstrap/.hidden")).unwrap();
1337 fs::write(
1338 dir.path().join("nested/bootstrap/setup/BOOTSTRAP.md"),
1339 "# setup",
1340 )
1341 .unwrap();
1342 fs::write(
1343 dir.path().join("nested/bootstrap/.hidden/BOOTSTRAP.md"),
1344 "# hidden",
1345 )
1346 .unwrap();
1347
1348 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1349 assert_eq!(items.len(), 1);
1350 assert_eq!(items[0].id.kind, ItemKind::BootstrapDoc);
1351 assert_eq!(items[0].id.name.as_str(), "setup");
1352 assert_eq!(
1353 items[0].source_path,
1354 PathBuf::from("nested/bootstrap/setup")
1355 );
1356 }
1357
1358 #[test]
1359 fn fallback_manifest_declared_escape_is_rejected() {
1360 let dir = TempDir::new().unwrap();
1361 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1362 fs::write(
1363 dir.path().join(".claude-plugin/plugin.json"),
1364 r#"{"skills":[{"path":"./../escape"}]}"#,
1365 )
1366 .unwrap();
1367
1368 let err = discover_fallback(dir.path(), Some("demo")).unwrap_err();
1369 assert!(matches!(err, MarsError::ManifestDeclaredPathEscape { .. }));
1370 }
1371
1372 #[test]
1373 fn fallback_selects_first_non_empty_logical_layer() {
1374 let dir = TempDir::new().unwrap();
1375 fs::create_dir_all(dir.path().join("top")).unwrap();
1376 fs::create_dir_all(dir.path().join("nested/deeper/skill")).unwrap();
1377 fs::write(dir.path().join("top/SKILL.md"), "# top").unwrap();
1378 fs::write(dir.path().join("nested/deeper/skill/SKILL.md"), "# skill").unwrap();
1379
1380 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1381 assert_eq!(items.len(), 1);
1382 assert_eq!(items[0].source_path, PathBuf::from("top"));
1383 }
1384
1385 #[test]
1386 fn fallback_groups_layout_variants_into_same_logical_layer() {
1387 let dir = TempDir::new().unwrap();
1388 fs::create_dir_all(dir.path().join("caveman")).unwrap();
1389 fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1390 fs::write(dir.path().join("caveman/SKILL.md"), "# direct").unwrap();
1391 fs::write(dir.path().join("skills/caveman/SKILL.md"), "# container").unwrap();
1392
1393 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1394 assert_eq!(items.len(), 1);
1395 assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1396 }
1397
1398 #[test]
1399 fn discover_installed_reads_frontmatter() {
1400 let dir = TempDir::new().unwrap();
1401 fs::create_dir_all(dir.path().join("agents")).unwrap();
1402 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1403 fs::write(
1404 dir.path().join("agents/coder.md"),
1405 "---\nname: coder\ndescription: test\nskills: [planning]\n---\n# Coder\n",
1406 )
1407 .unwrap();
1408 fs::write(
1409 dir.path().join("skills/planning/SKILL.md"),
1410 "---\nname: planning\ndescription: test\n---\n# Planning\n",
1411 )
1412 .unwrap();
1413
1414 let state = discover_installed(dir.path()).unwrap();
1415 assert_eq!(state.agents.len(), 1);
1416 assert_eq!(state.skills.len(), 1);
1417 assert_eq!(state.agents[0].skill_refs, vec!["planning"]);
1418 assert_eq!(
1419 state.skills[0].frontmatter_name.as_deref(),
1420 Some("planning")
1421 );
1422 }
1423}