Skip to main content

mars_agents/discover/
mod.rs

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/// An item discovered in a source tree by filesystem convention.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct DiscoveredItem {
35    pub id: ItemId,
36    /// Path within source tree (relative), e.g. "agents/coder.md" or "skills/planning".
37    pub source_path: PathBuf,
38}
39
40/// Discover items by conventional mars package layout.
41pub 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
86/// Discover items using the Vercel-compatible fallback walk.
87pub 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
118/// Shared dispatcher for rooted-source discovery.
119pub 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        // New config kinds not yet handled by source discovery.
277        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/// An installed item with parsed frontmatter metadata.
934#[derive(Debug, Clone)]
935pub struct InstalledItem {
936    pub id: ItemId,
937    /// Disk path (absolute) to the installed file/dir.
938    pub path: PathBuf,
939    /// Parsed frontmatter name (may differ from filename).
940    pub frontmatter_name: Option<String>,
941    /// Parsed frontmatter description.
942    pub description: Option<String>,
943    /// Skills referenced in frontmatter (agents only).
944    pub skill_refs: Vec<String>,
945}
946
947/// Result of scanning an installed managed root.
948#[derive(Debug, Clone)]
949pub struct InstalledState {
950    pub agents: Vec<InstalledItem>,
951    pub skills: Vec<InstalledItem>,
952}
953
954/// Discover all installed agents and skills in a managed root.
955pub 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}