Skip to main content

mars_agents/discover/
mod.rs

1use std::path::{Path, PathBuf};
2
3use crate::error::MarsError;
4use crate::lock::{ItemId, ItemKind};
5use crate::types::ItemName;
6
7/// An item discovered in a source tree by filesystem convention.
8///
9/// Discovery scans for `agents/*.md` and `skills/*/SKILL.md`.
10/// The manifest is not consulted for what a package provides.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct DiscoveredItem {
13    pub id: ItemId,
14    /// Path within source tree (relative), e.g. "agents/coder.md" or "skills/planning".
15    pub source_path: PathBuf,
16}
17
18/// Discover all installable items in a source tree by filesystem convention.
19///
20/// Convention:
21/// - `agents/*.md` files become `ItemKind::Agent` items
22/// - `skills/*/SKILL.md` directories become `ItemKind::Skill` items
23/// - If neither is found, a root-level `SKILL.md` is treated as one flat skill
24/// - Everything else is ignored
25///
26/// Sources without a `mars.toml` work identically — discovery doesn't
27/// depend on the manifest.
28pub fn discover_source(
29    tree_path: &Path,
30    fallback_name: Option<&str>,
31) -> Result<Vec<DiscoveredItem>, MarsError> {
32    let mut items = Vec::new();
33
34    // Discover agents: agents/*.md (non-recursive)
35    let agents_dir = tree_path.join("agents");
36    if agents_dir.is_dir() {
37        for entry in std::fs::read_dir(&agents_dir)? {
38            let entry = entry?;
39            let file_name = entry.file_name();
40            let name_str = file_name.to_string_lossy();
41
42            // Skip hidden files
43            if name_str.starts_with('.') {
44                continue;
45            }
46
47            let path = entry.path();
48            if path.is_file()
49                && let (Some(ext), Some(stem)) = (path.extension(), path.file_stem())
50                && ext == "md"
51            {
52                items.push(DiscoveredItem {
53                    id: ItemId {
54                        kind: ItemKind::Agent,
55                        name: ItemName::from(stem.to_string_lossy().into_owned()),
56                    },
57                    source_path: PathBuf::from("agents").join(&file_name),
58                });
59            }
60        }
61    }
62
63    // Discover skills: skills/*/SKILL.md (non-recursive)
64    let skills_dir = tree_path.join("skills");
65    if skills_dir.is_dir() {
66        for entry in std::fs::read_dir(&skills_dir)? {
67            let entry = entry?;
68            let dir_name = entry.file_name();
69            let name_str = dir_name.to_string_lossy();
70
71            // Skip hidden directories
72            if name_str.starts_with('.') {
73                continue;
74            }
75
76            let path = entry.path();
77            if path.is_dir() && path.join("SKILL.md").is_file() {
78                items.push(DiscoveredItem {
79                    id: ItemId {
80                        kind: ItemKind::Skill,
81                        name: ItemName::from(name_str.into_owned()),
82                    },
83                    source_path: PathBuf::from("skills").join(&dir_name),
84                });
85            }
86        }
87    }
88
89    // Flat skill fallback: root SKILL.md means the whole repo is one skill.
90    // Only used when no conventional agents/skills were discovered.
91    if items.is_empty() && tree_path.join("SKILL.md").is_file() {
92        let name = fallback_name.map(String::from).unwrap_or_else(|| {
93            tree_path
94                .file_name()
95                .map(|n| n.to_string_lossy().into_owned())
96                .unwrap_or_else(|| "unknown-skill".to_string())
97        });
98        items.push(DiscoveredItem {
99            id: ItemId {
100                kind: ItemKind::Skill,
101                name: ItemName::from(name),
102            },
103            source_path: PathBuf::from("."),
104        });
105    }
106
107    // Sort by (kind, name) for deterministic ordering.
108    // ItemId derives Ord with Agent < Skill, then lexicographic by name.
109    items.sort_by(|a, b| a.id.cmp(&b.id));
110
111    Ok(items)
112}
113
114/// An installed item with parsed frontmatter metadata.
115#[derive(Debug, Clone)]
116pub struct InstalledItem {
117    pub id: ItemId,
118    /// Disk path (absolute) to the installed file/dir.
119    pub path: PathBuf,
120    /// Parsed frontmatter name (may differ from filename).
121    pub frontmatter_name: Option<String>,
122    /// Parsed frontmatter description.
123    pub description: Option<String>,
124    /// Skills referenced in frontmatter (agents only).
125    pub skill_refs: Vec<String>,
126    /// Whether this is a symlink (skipped from validation).
127    pub is_symlink: bool,
128}
129
130/// Result of scanning an installed managed root.
131#[derive(Debug, Clone)]
132pub struct InstalledState {
133    pub agents: Vec<InstalledItem>,
134    pub skills: Vec<InstalledItem>,
135}
136
137/// Discover all installed agents and skills in a managed root.
138///
139/// Scans `agents/*.md` and `skills/*/SKILL.md`, parses frontmatter,
140/// and collects metadata. Includes symlinks (marked as such) so
141/// callers can decide whether to skip or warn.
142pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
143    let mut agents = Vec::new();
144    let mut skills = Vec::new();
145
146    // Scan agents/*.md
147    let agents_dir = root.join("agents");
148    if agents_dir.is_dir() {
149        for entry in std::fs::read_dir(&agents_dir)? {
150            let entry = entry?;
151            let path = entry.path();
152            let file_name = entry.file_name();
153            let name_str = file_name.to_string_lossy();
154
155            // Skip hidden files
156            if name_str.starts_with('.') {
157                continue;
158            }
159
160            let is_symlink = path
161                .symlink_metadata()
162                .map(|m| m.file_type().is_symlink())
163                .unwrap_or(false);
164
165            // Must be a .md file (following symlinks for the check)
166            if !path.is_file() {
167                continue;
168            }
169            let ext = path.extension().and_then(|e| e.to_str());
170            if ext != Some("md") {
171                continue;
172            }
173
174            let stem = path
175                .file_stem()
176                .map(|s| s.to_string_lossy().into_owned())
177                .unwrap_or_default();
178
179            let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
180
181            agents.push(InstalledItem {
182                id: ItemId {
183                    kind: ItemKind::Agent,
184                    name: ItemName::from(stem),
185                },
186                path,
187                frontmatter_name,
188                description,
189                skill_refs,
190                is_symlink,
191            });
192        }
193    }
194
195    // Scan skills/*/SKILL.md
196    let skills_dir = root.join("skills");
197    if skills_dir.is_dir() {
198        for entry in std::fs::read_dir(&skills_dir)? {
199            let entry = entry?;
200            let path = entry.path();
201            let dir_name = entry.file_name();
202            let name_str = dir_name.to_string_lossy();
203
204            // Skip hidden directories
205            if name_str.starts_with('.') {
206                continue;
207            }
208
209            let is_symlink = path
210                .symlink_metadata()
211                .map(|m| m.file_type().is_symlink())
212                .unwrap_or(false);
213
214            if !path.is_dir() {
215                continue;
216            }
217
218            let skill_md = path.join("SKILL.md");
219            if !skill_md.is_file() {
220                continue;
221            }
222
223            let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
224
225            skills.push(InstalledItem {
226                id: ItemId {
227                    kind: ItemKind::Skill,
228                    name: ItemName::from(name_str.into_owned()),
229                },
230                path,
231                frontmatter_name,
232                description,
233                skill_refs: Vec::new(),
234                is_symlink,
235            });
236        }
237    }
238
239    // Sort for deterministic order
240    agents.sort_by(|a, b| a.id.cmp(&b.id));
241    skills.sort_by(|a, b| a.id.cmp(&b.id));
242
243    Ok(InstalledState { agents, skills })
244}
245
246/// Parse frontmatter from an installed file, returning (name, description, skill_refs).
247/// Returns None/empty on parse failure — the item is still discovered.
248fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
249    let content = match std::fs::read_to_string(path) {
250        Ok(c) => c,
251        Err(_) => return (None, None, Vec::new()),
252    };
253    match crate::frontmatter::parse(&content) {
254        Ok(fm) => {
255            let name = fm.name().map(str::to_owned);
256            let description = fm
257                .get("description")
258                .and_then(|v| v.as_str())
259                .map(str::to_owned);
260            let skill_refs = fm.skills();
261            (name, description, skill_refs)
262        }
263        Err(_) => (None, None, Vec::new()),
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::fs;
271    use tempfile::TempDir;
272
273    /// Helper: create a source tree with the given agent and skill files.
274    fn make_tree(agents: &[&str], skills: &[&str]) -> TempDir {
275        let dir = TempDir::new().unwrap();
276
277        if !agents.is_empty() {
278            let agents_dir = dir.path().join("agents");
279            fs::create_dir_all(&agents_dir).unwrap();
280            for name in agents {
281                fs::write(agents_dir.join(name), "# agent content").unwrap();
282            }
283        }
284
285        if !skills.is_empty() {
286            let skills_dir = dir.path().join("skills");
287            fs::create_dir_all(&skills_dir).unwrap();
288            for name in skills {
289                let skill_dir = skills_dir.join(name);
290                fs::create_dir_all(&skill_dir).unwrap();
291                fs::write(skill_dir.join("SKILL.md"), "# skill content").unwrap();
292            }
293        }
294
295        dir
296    }
297
298    #[test]
299    fn discover_agents_only() {
300        let tree = make_tree(&["coder.md", "reviewer.md"], &[]);
301        let items = discover_source(tree.path(), None).unwrap();
302
303        assert_eq!(items.len(), 2);
304        assert_eq!(items[0].id.kind, ItemKind::Agent);
305        assert_eq!(items[0].id.name, "coder");
306        assert_eq!(items[0].source_path, PathBuf::from("agents/coder.md"));
307        assert_eq!(items[1].id.kind, ItemKind::Agent);
308        assert_eq!(items[1].id.name, "reviewer");
309        assert_eq!(items[1].source_path, PathBuf::from("agents/reviewer.md"));
310    }
311
312    #[test]
313    fn discover_skills_only() {
314        let tree = make_tree(&[], &["planning"]);
315        let items = discover_source(tree.path(), None).unwrap();
316
317        assert_eq!(items.len(), 1);
318        assert_eq!(items[0].id.kind, ItemKind::Skill);
319        assert_eq!(items[0].id.name, "planning");
320        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
321    }
322
323    #[test]
324    fn discover_agents_and_skills() {
325        let tree = make_tree(&["coder.md", "reviewer.md"], &["planning", "review"]);
326        let items = discover_source(tree.path(), None).unwrap();
327
328        assert_eq!(items.len(), 4);
329        // Agents come first (Agent < Skill), sorted by name
330        assert_eq!(items[0].id.name, "coder");
331        assert_eq!(items[0].id.kind, ItemKind::Agent);
332        assert_eq!(items[1].id.name, "reviewer");
333        assert_eq!(items[1].id.kind, ItemKind::Agent);
334        // Skills next, sorted by name
335        assert_eq!(items[2].id.name, "planning");
336        assert_eq!(items[2].id.kind, ItemKind::Skill);
337        assert_eq!(items[3].id.name, "review");
338        assert_eq!(items[3].id.kind, ItemKind::Skill);
339    }
340
341    #[test]
342    fn empty_tree_no_agents_or_skills_dir() {
343        let tree = TempDir::new().unwrap();
344        let items = discover_source(tree.path(), None).unwrap();
345        assert!(items.is_empty());
346    }
347
348    #[test]
349    fn empty_agents_dir() {
350        let tree = TempDir::new().unwrap();
351        fs::create_dir_all(tree.path().join("agents")).unwrap();
352        let items = discover_source(tree.path(), None).unwrap();
353        assert!(items.is_empty());
354    }
355
356    #[test]
357    fn non_md_files_in_agents_skipped() {
358        let tree = TempDir::new().unwrap();
359        let agents_dir = tree.path().join("agents");
360        fs::create_dir_all(&agents_dir).unwrap();
361        fs::write(agents_dir.join("coder.md"), "# agent").unwrap();
362        fs::write(agents_dir.join("notes.txt"), "not an agent").unwrap();
363        fs::write(agents_dir.join("config.yaml"), "not an agent").unwrap();
364        fs::write(agents_dir.join("README"), "not an agent").unwrap();
365
366        let items = discover_source(tree.path(), None).unwrap();
367        assert_eq!(items.len(), 1);
368        assert_eq!(items[0].id.name, "coder");
369    }
370
371    #[test]
372    fn skill_dir_without_skill_md_skipped() {
373        let tree = TempDir::new().unwrap();
374        let skills_dir = tree.path().join("skills");
375        let valid = skills_dir.join("planning");
376        let invalid = skills_dir.join("incomplete");
377        fs::create_dir_all(&valid).unwrap();
378        fs::create_dir_all(&invalid).unwrap();
379        fs::write(valid.join("SKILL.md"), "# skill").unwrap();
380        // incomplete/ has no SKILL.md
381
382        let items = discover_source(tree.path(), None).unwrap();
383        assert_eq!(items.len(), 1);
384        assert_eq!(items[0].id.name, "planning");
385    }
386
387    #[test]
388    fn hidden_files_skipped() {
389        let tree = TempDir::new().unwrap();
390        let agents_dir = tree.path().join("agents");
391        let skills_dir = tree.path().join("skills");
392        fs::create_dir_all(&agents_dir).unwrap();
393        fs::create_dir_all(&skills_dir).unwrap();
394
395        // Hidden agent file
396        fs::write(agents_dir.join(".hidden.md"), "# hidden").unwrap();
397        // Visible agent file
398        fs::write(agents_dir.join("visible.md"), "# visible").unwrap();
399
400        // Hidden skill directory
401        let hidden_skill = skills_dir.join(".secret");
402        fs::create_dir_all(&hidden_skill).unwrap();
403        fs::write(hidden_skill.join("SKILL.md"), "# secret").unwrap();
404
405        // Visible skill directory
406        let visible_skill = skills_dir.join("planning");
407        fs::create_dir_all(&visible_skill).unwrap();
408        fs::write(visible_skill.join("SKILL.md"), "# planning").unwrap();
409
410        let items = discover_source(tree.path(), None).unwrap();
411        assert_eq!(items.len(), 2);
412        assert_eq!(items[0].id.name, "visible");
413        assert_eq!(items[1].id.name, "planning");
414    }
415
416    #[test]
417    fn deterministic_ordering() {
418        let tree = make_tree(
419            &["zebra.md", "alpha.md", "middle.md"],
420            &["z-skill", "a-skill"],
421        );
422
423        let items1 = discover_source(tree.path(), None).unwrap();
424        let items2 = discover_source(tree.path(), None).unwrap();
425
426        // Same order every time
427        assert_eq!(items1, items2);
428
429        // Agents first (sorted), then skills (sorted)
430        let names: Vec<&str> = items1.iter().map(|i| i.id.name.as_str()).collect();
431        assert_eq!(
432            names,
433            vec!["alpha", "middle", "zebra", "a-skill", "z-skill"]
434        );
435    }
436
437    #[test]
438    fn subdirectories_in_agents_ignored() {
439        let tree = TempDir::new().unwrap();
440        let agents_dir = tree.path().join("agents");
441        let sub = agents_dir.join("subdir");
442        fs::create_dir_all(&sub).unwrap();
443        fs::write(sub.join("nested.md"), "# nested").unwrap();
444        fs::write(agents_dir.join("top.md"), "# top").unwrap();
445
446        let items = discover_source(tree.path(), None).unwrap();
447        assert_eq!(items.len(), 1);
448        assert_eq!(items[0].id.name, "top");
449    }
450
451    #[test]
452    fn skill_file_not_dir_ignored() {
453        // A file named like a skill but not a directory
454        let tree = TempDir::new().unwrap();
455        let skills_dir = tree.path().join("skills");
456        fs::create_dir_all(&skills_dir).unwrap();
457        fs::write(skills_dir.join("not-a-dir"), "# not a skill dir").unwrap();
458
459        let items = discover_source(tree.path(), None).unwrap();
460        assert!(items.is_empty());
461    }
462
463    #[test]
464    fn dunder_prefix_skills_discovered() {
465        // Skills with __ prefix are common (e.g., __meridian-spawn)
466        let tree = make_tree(&[], &["__meridian-spawn", "planning"]);
467        let items = discover_source(tree.path(), None).unwrap();
468
469        assert_eq!(items.len(), 2);
470        assert_eq!(items[0].id.name, "__meridian-spawn");
471        assert_eq!(items[1].id.name, "planning");
472    }
473
474    #[test]
475    fn only_agents_dir_exists() {
476        let tree = TempDir::new().unwrap();
477        let agents_dir = tree.path().join("agents");
478        fs::create_dir_all(&agents_dir).unwrap();
479        fs::write(agents_dir.join("coder.md"), "# coder").unwrap();
480        // No skills/ dir at all
481
482        let items = discover_source(tree.path(), None).unwrap();
483        assert_eq!(items.len(), 1);
484        assert_eq!(items[0].id.name, "coder");
485    }
486
487    #[test]
488    fn only_skills_dir_exists() {
489        let tree = TempDir::new().unwrap();
490        let skills_dir = tree.path().join("skills");
491        let planning = skills_dir.join("planning");
492        fs::create_dir_all(&planning).unwrap();
493        fs::write(planning.join("SKILL.md"), "# planning").unwrap();
494        // No agents/ dir at all
495
496        let items = discover_source(tree.path(), None).unwrap();
497        assert_eq!(items.len(), 1);
498        assert_eq!(items[0].id.name, "planning");
499    }
500
501    #[test]
502    fn flat_skill_repo_discovered() {
503        let tree = TempDir::new().unwrap();
504        fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
505
506        let items = discover_source(tree.path(), None).unwrap();
507        assert_eq!(items.len(), 1);
508        assert_eq!(items[0].id.kind, ItemKind::Skill);
509        assert_eq!(items[0].source_path, PathBuf::from("."));
510    }
511
512    #[test]
513    fn flat_skill_with_resources() {
514        let tree = TempDir::new().unwrap();
515        fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
516        fs::create_dir_all(tree.path().join("resources")).unwrap();
517        fs::write(tree.path().join("resources/guide.md"), "# guide").unwrap();
518
519        let items = discover_source(tree.path(), None).unwrap();
520        assert_eq!(items.len(), 1);
521        assert_eq!(items[0].id.kind, ItemKind::Skill);
522        assert_eq!(items[0].source_path, PathBuf::from("."));
523    }
524
525    #[test]
526    fn flat_skill_uses_fallback_name() {
527        let tree = TempDir::new().unwrap();
528        fs::write(tree.path().join("SKILL.md"), "# flat skill").unwrap();
529
530        let items = discover_source(tree.path(), Some("my-skill")).unwrap();
531        assert_eq!(items.len(), 1);
532        assert_eq!(items[0].id.name, "my-skill");
533    }
534
535    #[test]
536    fn flat_skill_uses_dirname_when_no_fallback() {
537        let parent = TempDir::new().unwrap();
538        let tree = parent.path().join("demo-skill");
539        fs::create_dir_all(&tree).unwrap();
540        fs::write(tree.join("SKILL.md"), "# flat skill").unwrap();
541
542        let items = discover_source(&tree, None).unwrap();
543        assert_eq!(items.len(), 1);
544        assert_eq!(items[0].id.name, "demo-skill");
545    }
546
547    #[test]
548    fn nested_structure_ignores_root_skill_md() {
549        let tree = TempDir::new().unwrap();
550        fs::write(tree.path().join("SKILL.md"), "# root skill").unwrap();
551        let planning = tree.path().join("skills/planning");
552        fs::create_dir_all(&planning).unwrap();
553        fs::write(planning.join("SKILL.md"), "# nested skill").unwrap();
554
555        let items = discover_source(tree.path(), None).unwrap();
556        assert_eq!(items.len(), 1);
557        assert_eq!(items[0].id.kind, ItemKind::Skill);
558        assert_eq!(items[0].id.name, "planning");
559        assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
560    }
561
562    // === discover_installed tests ===
563
564    #[test]
565    fn discover_installed_finds_agents_and_skills() {
566        let root = TempDir::new().unwrap();
567        let agents_dir = root.path().join("agents");
568        let skills_dir = root.path().join("skills");
569        fs::create_dir_all(&agents_dir).unwrap();
570        fs::create_dir_all(skills_dir.join("planning")).unwrap();
571        fs::write(
572            agents_dir.join("coder.md"),
573            "---\nname: coder\n---\n# Agent",
574        )
575        .unwrap();
576        fs::write(
577            skills_dir.join("planning").join("SKILL.md"),
578            "---\nname: planning\n---\n# Skill",
579        )
580        .unwrap();
581
582        let state = discover_installed(root.path()).unwrap();
583        assert_eq!(state.agents.len(), 1);
584        assert_eq!(state.agents[0].id.name, "coder");
585        assert_eq!(state.skills.len(), 1);
586        assert_eq!(state.skills[0].id.name, "planning");
587    }
588
589    #[test]
590    fn discover_installed_parses_frontmatter() {
591        let root = TempDir::new().unwrap();
592        let agents_dir = root.path().join("agents");
593        fs::create_dir_all(&agents_dir).unwrap();
594        fs::write(
595            agents_dir.join("coder.md"),
596            "---\nname: my-coder\ndescription: A coding agent\nskills:\n  - planning\n  - review\n---\n# Agent",
597        )
598        .unwrap();
599
600        let state = discover_installed(root.path()).unwrap();
601        assert_eq!(state.agents.len(), 1);
602        let agent = &state.agents[0];
603        assert_eq!(agent.frontmatter_name.as_deref(), Some("my-coder"));
604        assert_eq!(agent.description.as_deref(), Some("A coding agent"));
605        assert_eq!(agent.skill_refs, vec!["planning", "review"]);
606    }
607
608    #[test]
609    fn discover_installed_handles_missing_frontmatter() {
610        let root = TempDir::new().unwrap();
611        let agents_dir = root.path().join("agents");
612        fs::create_dir_all(&agents_dir).unwrap();
613        fs::write(agents_dir.join("bare.md"), "# No frontmatter").unwrap();
614
615        let state = discover_installed(root.path()).unwrap();
616        assert_eq!(state.agents.len(), 1);
617        assert_eq!(state.agents[0].id.name, "bare");
618        assert!(state.agents[0].frontmatter_name.is_none());
619        assert!(state.agents[0].skill_refs.is_empty());
620    }
621
622    #[test]
623    fn discover_installed_handles_symlinks() {
624        let root = TempDir::new().unwrap();
625        let agents_dir = root.path().join("agents");
626        fs::create_dir_all(&agents_dir).unwrap();
627
628        // Create a real agent file
629        let real = agents_dir.join("real.md");
630        fs::write(&real, "# Real agent").unwrap();
631
632        // Create a symlink
633        let link = agents_dir.join("linked.md");
634        std::os::unix::fs::symlink(&real, &link).unwrap();
635
636        let state = discover_installed(root.path()).unwrap();
637        assert_eq!(state.agents.len(), 2);
638
639        let linked = state
640            .agents
641            .iter()
642            .find(|a| a.id.name.as_str() == "linked")
643            .unwrap();
644        assert!(linked.is_symlink);
645
646        let real_agent = state
647            .agents
648            .iter()
649            .find(|a| a.id.name.as_str() == "real")
650            .unwrap();
651        assert!(!real_agent.is_symlink);
652    }
653}