Skip to main content

mars_agents/discover/
mod.rs

1//! Filesystem discovery for package-provided agents, skills, and bootstrap docs.
2//!
3//! Discovery is intentionally convention-based: a bounded walk finds directories
4//! named `agents`, `skills`, and `bootstrap` instead of carrying harness-specific
5//! blocklists. Hidden dot-directories are skipped during that walk so generated
6//! harness surfaces like `.claude/` and tool caches like `.git/` are not imported
7//! unless a dependency explicitly roots discovery there with `subpath`.
8
9use std::collections::{HashMap, HashSet, VecDeque};
10use std::path::{Component, Path, PathBuf};
11
12use serde_json::Value;
13
14use crate::error::MarsError;
15use crate::lock::{ItemId, ItemKind};
16use crate::skill_source_name::flat_root_skill_source_name;
17use crate::types::ItemName;
18
19// These high-volume generated directories are skipped in addition to the dot-dir
20// rule to avoid slow walks and false-positive imports from dependency/build
21// outputs that sometimes contain docs shaped like agents or skills.
22const RECURSIVE_SKIP_DIRS: &[&str] = &["node_modules", ".git", "dist", "build", "__pycache__"];
23const PLUGIN_MANIFESTS: &[&str] = &[
24    ".claude-plugin/plugin.json",
25    ".claude-plugin/marketplace.json",
26];
27// Covers real package layouts like `vendor/pkg/.claude/skills/foo` and
28// `packages/group/tooling/agents/foo.md` while intentionally skipping
29// over-depth convention dirs silently so arbitrary repo trees do not become
30// unbounded discovery surfaces.
31const MAX_DISCOVERY_WALK_DEPTH: usize = 5;
32const AGENTS_DIR_NAME: &str = "agents";
33const SKILLS_DIR_NAME: &str = "skills";
34const BOOTSTRAP_DIR_NAME: &str = "bootstrap";
35const MANIFEST_SKILL_KEYS: &[&str] = &["skills", "skill_paths", "skillPaths"];
36const MANIFEST_AGENT_KEYS: &[&str] = &["agents", "agent_paths", "agentPaths"];
37const MANIFEST_BOOTSTRAP_KEYS: &[&str] = &["bootstrapDocs", "bootstrap_docs"];
38
39/// An item discovered in a source tree by filesystem convention.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DiscoveredItem {
42    pub id: ItemId,
43    /// Path within source tree (relative), e.g. "agents/coder.md" or "skills/planning".
44    pub source_path: PathBuf,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48struct LayeredItem {
49    item: DiscoveredItem,
50    // Convention grounding depends on the container directory that registered an
51    // item, not on the item's leaf path. Nested package layouts can contain
52    // repeated `skills`/`agents`/`bootstrap` segments, so deriving this later
53    // from `source_path` can anchor to the wrong container.
54    layer: usize,
55}
56
57/// Discover items by conventional mars package layout.
58pub fn discover_source(
59    tree_path: &Path,
60    fallback_name: Option<&str>,
61) -> Result<Vec<DiscoveredItem>, MarsError> {
62    let items = discover_convention_items(tree_path, fallback_name)?;
63    finalize_items(fallback_name.unwrap_or("unknown-source"), items)
64}
65
66/// Discover items from a source without a mars.toml manifest.
67pub fn discover_manifestless_source(
68    package_root: &Path,
69    source_name: Option<&str>,
70) -> Result<Vec<DiscoveredItem>, MarsError> {
71    let label = source_name.unwrap_or("unknown-source");
72    let convention_items = discover_convention_items(package_root, source_name)?;
73
74    let mut items = convention_items;
75    items.append(&mut discover_manifest_declared_items(package_root, label)?);
76    finalize_items(label, items)
77}
78
79/// Shared dispatcher for rooted-source discovery.
80pub fn discover_resolved_source(
81    package_root: &Path,
82    source_name: Option<&str>,
83) -> Result<Vec<DiscoveredItem>, MarsError> {
84    if package_root.join("mars.toml").is_file() {
85        discover_source(package_root, source_name)
86    } else {
87        discover_manifestless_source(package_root, source_name)
88    }
89}
90
91fn discover_convention_items(
92    package_root: &Path,
93    source_name: Option<&str>,
94) -> Result<Vec<DiscoveredItem>, MarsError> {
95    if !package_root.is_dir() {
96        return Ok(Vec::new());
97    }
98
99    let mut items = Vec::new();
100    let mut scratch = Vec::new();
101    let mut visited_agents = HashSet::new();
102    let mut visited_skills = HashSet::new();
103    let mut visited_bootstrap = HashSet::new();
104    let mut queue = VecDeque::from([(package_root.to_path_buf(), 0usize)]);
105
106    while let Some((base_dir, depth)) = queue.pop_front() {
107        let base_rel = if base_dir == package_root {
108            PathBuf::new()
109        } else {
110            relative_to(package_root, &base_dir)?
111        };
112
113        match base_dir.file_name().and_then(|name| name.to_str()) {
114            Some(AGENTS_DIR_NAME) => {
115                scan_agent_dir(package_root, &base_rel, &mut scratch, &mut visited_agents)?;
116                push_layered_items(&mut items, &mut scratch, convention_layer(&base_rel));
117            }
118            Some(SKILLS_DIR_NAME) => {
119                scan_skill_dir(package_root, &base_rel, &mut scratch, &mut visited_skills)?;
120                push_layered_items(&mut items, &mut scratch, convention_layer(&base_rel));
121            }
122            Some(BOOTSTRAP_DIR_NAME) => {
123                scan_bootstrap_dir(
124                    package_root,
125                    &base_rel,
126                    &mut scratch,
127                    &mut visited_bootstrap,
128                )?;
129                push_layered_items(&mut items, &mut scratch, convention_layer(&base_rel));
130            }
131            _ => {}
132        }
133
134        if depth == MAX_DISCOVERY_WALK_DEPTH {
135            continue;
136        }
137
138        for path in read_dir_paths_sorted(&base_dir)? {
139            if !path.is_dir() {
140                continue;
141            }
142            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
143                continue;
144            };
145            // Hidden directories are generated/cache/control surfaces by convention.
146            // Consumers can still import a hidden foreign layout explicitly by rooting
147            // the package at that directory with `subpath = ".claude"`.
148            if name.starts_with('.') || RECURSIVE_SKIP_DIRS.contains(&name) {
149                continue;
150            }
151            queue.push_back((path, depth + 1));
152        }
153    }
154
155    let found_agent_or_skill_before_grounding = items
156        .iter()
157        .any(|item| matches!(item.item.id.kind, ItemKind::Agent | ItemKind::Skill));
158
159    items = ground_items_to_shallowest_layer(items);
160
161    if !found_agent_or_skill_before_grounding && package_root.join("SKILL.md").is_file() {
162        let name = flat_root_skill_source_name(package_root, source_name);
163        items.push(LayeredItem {
164            item: DiscoveredItem {
165                id: ItemId {
166                    kind: ItemKind::Skill,
167                    name: ItemName::from(name),
168                },
169                source_path: PathBuf::from("."),
170            },
171            layer: 0,
172        });
173    }
174
175    Ok(items.into_iter().map(|layered| layered.item).collect())
176}
177
178fn push_layered_items(
179    items: &mut Vec<LayeredItem>,
180    scratch: &mut Vec<DiscoveredItem>,
181    layer: usize,
182) {
183    items.extend(scratch.drain(..).map(|item| LayeredItem { item, layer }));
184}
185
186fn convention_layer(relative_root: &Path) -> usize {
187    relative_root.components().count()
188}
189
190fn scan_skill_dir(
191    package_root: &Path,
192    relative_root: &Path,
193    items: &mut Vec<DiscoveredItem>,
194    visited: &mut HashSet<PathBuf>,
195) -> Result<(), MarsError> {
196    let dir = package_root.join(relative_root);
197    if !dir.is_dir() {
198        return Ok(());
199    }
200
201    for path in read_dir_paths_sorted(&dir)? {
202        if !path.is_dir() {
203            continue;
204        }
205        if let Some(name) = path.file_name().and_then(|name| name.to_str())
206            && name.starts_with('.')
207        {
208            continue;
209        }
210        let rel = relative_to(package_root, &path)?;
211        register_skill_dir(package_root, &rel, items, visited)?;
212    }
213
214    Ok(())
215}
216
217fn scan_agent_dir(
218    package_root: &Path,
219    relative_root: &Path,
220    items: &mut Vec<DiscoveredItem>,
221    visited: &mut HashSet<PathBuf>,
222) -> Result<(), MarsError> {
223    let dir = package_root.join(relative_root);
224    if !dir.is_dir() {
225        return Ok(());
226    }
227
228    for path in read_dir_paths_sorted(&dir)? {
229        if !path.is_file() {
230            continue;
231        }
232        if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
233            continue;
234        }
235        let rel = relative_to(package_root, &path)?;
236        register_agent_file(&rel, items, visited);
237    }
238
239    Ok(())
240}
241
242fn scan_bootstrap_dir(
243    package_root: &Path,
244    relative_root: &Path,
245    items: &mut Vec<DiscoveredItem>,
246    visited: &mut HashSet<PathBuf>,
247) -> Result<(), MarsError> {
248    let dir = package_root.join(relative_root);
249    if !dir.is_dir() {
250        return Ok(());
251    }
252
253    for path in read_dir_paths_sorted(&dir)? {
254        if !path.is_dir() {
255            continue;
256        }
257        if let Some(name) = path.file_name().and_then(|name| name.to_str())
258            && name.starts_with('.')
259        {
260            continue;
261        }
262        let rel = relative_to(package_root, &path)?;
263        register_bootstrap_doc(package_root, &rel, items, visited)?;
264    }
265
266    Ok(())
267}
268
269fn scan_manifest_declared_path(
270    package_root: &Path,
271    declared_path: &DeclaredPath,
272    items: &mut Vec<DiscoveredItem>,
273) -> Result<(), MarsError> {
274    let mut visited = HashSet::new();
275    let candidate = package_root.join(&declared_path.relative_path);
276    match declared_path.kind {
277        ItemKind::Skill => {
278            if candidate.join("SKILL.md").is_file() {
279                register_skill_dir(
280                    package_root,
281                    &declared_path.relative_path,
282                    items,
283                    &mut visited,
284                )?;
285            } else if candidate.is_dir() {
286                scan_skill_dir(
287                    package_root,
288                    &declared_path.relative_path,
289                    items,
290                    &mut visited,
291                )?;
292            }
293        }
294        ItemKind::Agent => {
295            if candidate.is_file()
296                && candidate.extension().and_then(|ext| ext.to_str()) == Some("md")
297            {
298                register_agent_file(&declared_path.relative_path, items, &mut visited);
299            } else if candidate.is_dir() {
300                scan_agent_dir(
301                    package_root,
302                    &declared_path.relative_path,
303                    items,
304                    &mut visited,
305                )?;
306            }
307        }
308        ItemKind::BootstrapDoc => {
309            if candidate.join("BOOTSTRAP.md").is_file() {
310                register_bootstrap_doc(
311                    package_root,
312                    &declared_path.relative_path,
313                    items,
314                    &mut visited,
315                )?;
316            } else if candidate
317                .file_name()
318                .and_then(|name| name.to_str())
319                .is_some_and(|name| name == "BOOTSTRAP.md")
320                && candidate.is_file()
321                && let Some(parent) = declared_path.relative_path.parent()
322            {
323                register_bootstrap_doc(package_root, parent, items, &mut visited)?;
324            } else if candidate.is_dir() {
325                scan_bootstrap_dir(
326                    package_root,
327                    &declared_path.relative_path,
328                    items,
329                    &mut visited,
330                )?;
331            }
332        }
333        // New config kinds not yet handled by source discovery.
334        ItemKind::Hook | ItemKind::McpServer => {}
335    }
336
337    Ok(())
338}
339
340fn register_skill_dir(
341    package_root: &Path,
342    relative_path: &Path,
343    items: &mut Vec<DiscoveredItem>,
344    visited: &mut HashSet<PathBuf>,
345) -> Result<(), MarsError> {
346    let normalized = normalize_relative_path(relative_path);
347    if !visited.insert(normalized.clone()) {
348        return Ok(());
349    }
350    if !package_root.join(&normalized).join("SKILL.md").is_file() {
351        return Ok(());
352    }
353    let name = normalized
354        .file_name()
355        .and_then(|name| name.to_str())
356        .unwrap_or_default();
357    items.push(DiscoveredItem {
358        id: ItemId {
359            kind: ItemKind::Skill,
360            name: ItemName::from(name.to_string()),
361        },
362        source_path: normalized,
363    });
364    Ok(())
365}
366
367fn register_agent_file(
368    relative_path: &Path,
369    items: &mut Vec<DiscoveredItem>,
370    visited: &mut HashSet<PathBuf>,
371) {
372    let normalized = normalize_relative_path(relative_path);
373    if !visited.insert(normalized.clone()) {
374        return;
375    }
376    let name = normalized
377        .file_stem()
378        .and_then(|name| name.to_str())
379        .unwrap_or_default();
380    items.push(DiscoveredItem {
381        id: ItemId {
382            kind: ItemKind::Agent,
383            name: ItemName::from(name.to_string()),
384        },
385        source_path: normalized,
386    });
387}
388
389fn register_bootstrap_doc(
390    package_root: &Path,
391    relative_path: &Path,
392    items: &mut Vec<DiscoveredItem>,
393    visited: &mut HashSet<PathBuf>,
394) -> Result<(), MarsError> {
395    let normalized = normalize_relative_path(relative_path);
396    if !visited.insert(normalized.clone()) {
397        return Ok(());
398    }
399    if !package_root
400        .join(&normalized)
401        .join("BOOTSTRAP.md")
402        .is_file()
403    {
404        return Ok(());
405    }
406    let name = normalized
407        .file_name()
408        .and_then(|name| name.to_str())
409        .unwrap_or_default();
410    items.push(DiscoveredItem {
411        id: ItemId {
412            kind: ItemKind::BootstrapDoc,
413            name: ItemName::from(name.to_string()),
414        },
415        source_path: normalized,
416    });
417    Ok(())
418}
419
420fn discover_manifest_declared_items(
421    package_root: &Path,
422    source_name: &str,
423) -> Result<Vec<DiscoveredItem>, MarsError> {
424    let mut items = Vec::new();
425    for declared_path in collect_manifest_declared_paths(package_root, source_name)? {
426        scan_manifest_declared_path(package_root, &declared_path, &mut items)?;
427    }
428    Ok(dedupe_items_by_path(items))
429}
430
431fn ground_items_to_shallowest_layer(items: Vec<LayeredItem>) -> Vec<LayeredItem> {
432    let Some(min_layer) = items.iter().map(|item| item.layer).min() else {
433        return items;
434    };
435
436    // Grounding: agents/skills/bootstrap docs live at one logical layer; find
437    // the shallowest layer that has them and ignore deeper containers. This
438    // prevents importing nested fixture, example, or vendored package layouts
439    // when a package also exposes its own top-level convention directories.
440    items
441        .into_iter()
442        .filter(|item| item.layer == min_layer)
443        .collect()
444}
445
446fn finalize_items(
447    source_name: &str,
448    mut items: Vec<DiscoveredItem>,
449) -> Result<Vec<DiscoveredItem>, MarsError> {
450    items = dedupe_items_by_path(items);
451    ensure_unique_names(source_name, &items)?;
452    sort_items(&mut items);
453    Ok(items)
454}
455
456fn dedupe_items_by_path(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
457    let mut seen = HashSet::new();
458    let mut deduped = Vec::with_capacity(items.len());
459    for item in items {
460        if seen.insert(item.source_path.clone()) {
461            deduped.push(item);
462        }
463    }
464    deduped
465}
466
467fn collect_manifest_declared_paths(
468    package_root: &Path,
469    source_name: &str,
470) -> Result<Vec<DeclaredPath>, MarsError> {
471    let mut declared = Vec::new();
472    for manifest in PLUGIN_MANIFESTS {
473        let path = package_root.join(manifest);
474        if !path.is_file() {
475            continue;
476        }
477        let content = std::fs::read_to_string(&path)?;
478        let json: Value = serde_json::from_str(&content).map_err(|e| MarsError::Source {
479            source_name: source_name.to_string(),
480            message: format!("failed to parse plugin manifest `{}`: {e}", path.display()),
481        })?;
482        declared.extend(parse_declared_paths(&json));
483    }
484
485    let mut resolved = Vec::new();
486    let mut seen = HashSet::new();
487    for raw in declared {
488        if !raw.raw_path.starts_with("./") {
489            continue;
490        }
491        let normalized = normalize_manifest_declared_path(&raw.raw_path).ok_or_else(|| {
492            MarsError::ManifestDeclaredPathEscape {
493                source_name: source_name.to_string(),
494                manifest_path: raw.raw_path.display().to_string(),
495                package_root: package_root.to_path_buf(),
496            }
497        })?;
498        let candidate = package_root.join(&normalized);
499        if !candidate.exists() {
500            return Err(MarsError::ManifestDeclaredPathMissing {
501                source_name: source_name.to_string(),
502                manifest_path: raw.raw_path.display().to_string(),
503                package_root: package_root.to_path_buf(),
504            });
505        }
506        let canonical = dunce::canonicalize(&candidate).map_err(|_| {
507            MarsError::ManifestDeclaredPathMissing {
508                source_name: source_name.to_string(),
509                manifest_path: raw.raw_path.display().to_string(),
510                package_root: package_root.to_path_buf(),
511            }
512        })?;
513        let canonical_root = dunce::canonicalize(package_root).map_err(|e| MarsError::Source {
514            source_name: source_name.to_string(),
515            message: format!(
516                "failed to canonicalize package root `{}`: {e}",
517                package_root.display()
518            ),
519        })?;
520        if !canonical.starts_with(&canonical_root) {
521            return Err(MarsError::ManifestDeclaredPathEscape {
522                source_name: source_name.to_string(),
523                manifest_path: raw.raw_path.display().to_string(),
524                package_root: package_root.to_path_buf(),
525            });
526        }
527        let rel = relative_to(package_root, &candidate)?;
528        if seen.insert((raw.kind, rel.clone())) {
529            resolved.push(DeclaredPath {
530                kind: raw.kind,
531                relative_path: rel,
532            });
533        }
534    }
535    Ok(resolved)
536}
537
538fn ensure_unique_names(source_name: &str, items: &[DiscoveredItem]) -> Result<(), MarsError> {
539    let mut seen: HashMap<(ItemKind, String), PathBuf> = HashMap::new();
540    for item in items {
541        let key = (item.id.kind, item.id.name.to_string());
542        if let Some(existing) = seen.insert(key.clone(), item.source_path.clone()) {
543            return Err(MarsError::DiscoveryCollision {
544                source_name: source_name.to_string(),
545                kind: item.id.kind.to_string(),
546                item_name: item.id.name.to_string(),
547                path_a: existing,
548                path_b: item.source_path.clone(),
549            });
550        }
551    }
552    Ok(())
553}
554
555fn relative_to(base: &Path, child: &Path) -> Result<PathBuf, MarsError> {
556    child
557        .strip_prefix(base)
558        .map(|path| path.to_path_buf())
559        .map_err(|_| MarsError::Source {
560            source_name: "discover".to_string(),
561            message: format!(
562                "path `{}` is not under package root `{}`",
563                child.display(),
564                base.display()
565            ),
566        })
567}
568
569fn normalize_relative_path(path: &Path) -> PathBuf {
570    let mut normalized = PathBuf::new();
571    for component in path.components() {
572        normalized.push(component.as_os_str());
573    }
574    normalized
575}
576
577fn normalize_manifest_declared_path(path: &Path) -> Option<PathBuf> {
578    let mut normalized = PathBuf::new();
579    for component in path.components() {
580        match component {
581            Component::CurDir => {}
582            Component::Normal(seg) => normalized.push(seg),
583            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
584        }
585    }
586    if normalized.as_os_str().is_empty() {
587        None
588    } else {
589        Some(normalized)
590    }
591}
592
593fn read_dir_paths_sorted(dir: &Path) -> Result<Vec<PathBuf>, MarsError> {
594    let mut paths = Vec::new();
595    for entry in std::fs::read_dir(dir)? {
596        paths.push(entry?.path());
597    }
598    paths.sort();
599    Ok(paths)
600}
601
602fn parse_declared_paths(json: &Value) -> Vec<RawDeclaredPath> {
603    let Some(map) = json.as_object() else {
604        return Vec::new();
605    };
606
607    let mut declared = Vec::new();
608    for key in MANIFEST_SKILL_KEYS {
609        if let Some(value) = map.get(*key) {
610            collect_declared_paths_from_value(ItemKind::Skill, value, &mut declared);
611        }
612    }
613    for key in MANIFEST_AGENT_KEYS {
614        if let Some(value) = map.get(*key) {
615            collect_declared_paths_from_value(ItemKind::Agent, value, &mut declared);
616        }
617    }
618    for key in MANIFEST_BOOTSTRAP_KEYS {
619        if let Some(value) = map.get(*key) {
620            collect_declared_paths_from_value(ItemKind::BootstrapDoc, value, &mut declared);
621        }
622    }
623    declared
624}
625
626fn collect_declared_paths_from_value(
627    kind: ItemKind,
628    value: &Value,
629    declared: &mut Vec<RawDeclaredPath>,
630) {
631    match value {
632        Value::String(path) => declared.push(RawDeclaredPath {
633            kind,
634            raw_path: PathBuf::from(path),
635        }),
636        Value::Array(values) => {
637            for child in values {
638                collect_declared_paths_from_value(kind, child, declared);
639            }
640        }
641        Value::Object(map) => {
642            if let Some(path) = map.get("path").and_then(|value| value.as_str()) {
643                declared.push(RawDeclaredPath {
644                    kind,
645                    raw_path: PathBuf::from(path),
646                });
647            }
648        }
649        _ => {}
650    }
651}
652
653#[derive(Debug, Clone)]
654struct RawDeclaredPath {
655    kind: ItemKind,
656    raw_path: PathBuf,
657}
658
659#[derive(Debug, Clone)]
660struct DeclaredPath {
661    kind: ItemKind,
662    relative_path: PathBuf,
663}
664
665fn sort_items(items: &mut [DiscoveredItem]) {
666    items.sort_by(|a, b| {
667        a.id.cmp(&b.id)
668            .then_with(|| a.source_path.cmp(&b.source_path))
669    });
670}
671
672/// An installed item with parsed frontmatter metadata.
673#[derive(Debug, Clone)]
674pub struct InstalledItem {
675    pub id: ItemId,
676    /// Disk path (absolute) to the installed file/dir.
677    pub path: PathBuf,
678    /// Parsed frontmatter name (may differ from filename).
679    pub frontmatter_name: Option<String>,
680    /// Parsed frontmatter description.
681    pub description: Option<String>,
682    /// Skills referenced in frontmatter (agents only).
683    pub skill_refs: Vec<String>,
684}
685
686/// Result of scanning an installed managed root.
687#[derive(Debug, Clone)]
688pub struct InstalledState {
689    pub agents: Vec<InstalledItem>,
690    pub skills: Vec<InstalledItem>,
691}
692
693/// Discover all installed agents and skills in a managed root.
694pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
695    let mut agents = Vec::new();
696    let mut skills = Vec::new();
697
698    let mut scratch = Vec::new();
699    let mut visited = HashSet::new();
700    scan_agent_dir(root, Path::new("agents"), &mut scratch, &mut visited)?;
701    for item in scratch.drain(..) {
702        let path = root.join(&item.source_path);
703        let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
704        agents.push(InstalledItem {
705            id: item.id,
706            path,
707            frontmatter_name,
708            description,
709            skill_refs,
710        });
711    }
712
713    scan_skill_dir(root, Path::new("skills"), &mut scratch, &mut HashSet::new())?;
714    for item in scratch.drain(..) {
715        let path = root.join(&item.source_path);
716        let skill_md = if item.source_path == Path::new(".") {
717            root.join("SKILL.md")
718        } else {
719            path.join("SKILL.md")
720        };
721        let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
722        skills.push(InstalledItem {
723            id: item.id,
724            path,
725            frontmatter_name,
726            description,
727            skill_refs: Vec::new(),
728        });
729    }
730
731    sort_installed(&mut agents);
732    sort_installed(&mut skills);
733    Ok(InstalledState { agents, skills })
734}
735
736fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
737    let content = match std::fs::read_to_string(path) {
738        Ok(c) => c,
739        Err(_) => return (None, None, Vec::new()),
740    };
741    match crate::frontmatter::parse(&content) {
742        Ok(fm) => {
743            let name = fm.name().map(str::to_owned);
744            let description = fm
745                .get("description")
746                .and_then(|value| value.as_str())
747                .map(str::to_owned);
748            (name, description, fm.skills())
749        }
750        Err(_) => (None, None, Vec::new()),
751    }
752}
753
754fn sort_installed(items: &mut [InstalledItem]) {
755    items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.path.cmp(&b.path)));
756}
757
758#[cfg(test)]
759mod tests;