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 ".agents/skills",
26];
27const AGENT_CONTAINER_ROOTS: &[&str] = &[
28 "agents",
29 ".claude/agents",
30 ".codex/agents",
31 ".agents/agents",
32];
33const MANIFEST_SKILL_KEYS: &[&str] = &["skills", "skill_paths", "skillPaths"];
34const MANIFEST_AGENT_KEYS: &[&str] = &["agents", "agent_paths", "agentPaths"];
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct DiscoveredItem {
39 pub id: ItemId,
40 pub source_path: PathBuf,
42}
43
44pub fn discover_source(
46 tree_path: &Path,
47 fallback_name: Option<&str>,
48) -> Result<Vec<DiscoveredItem>, MarsError> {
49 let mut items = Vec::new();
50
51 scan_agent_dir(
52 tree_path,
53 Path::new("agents"),
54 &mut items,
55 &mut HashSet::new(),
56 )?;
57 scan_skill_dir(
58 tree_path,
59 Path::new("skills"),
60 &mut items,
61 &mut HashSet::new(),
62 )?;
63
64 if items.is_empty() && tree_path.join("SKILL.md").is_file() {
65 let name = fallback_name
66 .map(String::from)
67 .unwrap_or_else(|| package_basename(tree_path));
68 items.push(DiscoveredItem {
69 id: ItemId {
70 kind: ItemKind::Skill,
71 name: ItemName::from(name),
72 },
73 source_path: PathBuf::from("."),
74 });
75 }
76
77 sort_items(&mut items);
78 Ok(items)
79}
80
81pub fn discover_fallback(
83 package_root: &Path,
84 source_name: Option<&str>,
85) -> Result<Vec<DiscoveredItem>, MarsError> {
86 if package_root.join("SKILL.md").is_file() {
87 return Ok(vec![DiscoveredItem {
88 id: ItemId {
89 kind: ItemKind::Skill,
90 name: ItemName::from(package_basename(package_root)),
91 },
92 source_path: PathBuf::from("."),
93 }]);
94 }
95
96 let source_name = source_name.unwrap_or("unknown-source");
97 let explicit_items = discover_manifest_declared_items(package_root, source_name)?;
98 if !explicit_items.is_empty() {
99 return finalize_items(source_name, explicit_items);
100 }
101
102 let heuristic_items = discover_heuristic_layer_items(package_root)?;
103 finalize_items(source_name, heuristic_items)
104}
105
106pub fn discover_resolved_source(
108 package_root: &Path,
109 source_name: Option<&str>,
110) -> Result<Vec<DiscoveredItem>, MarsError> {
111 if package_root.join("mars.toml").is_file() {
112 discover_source(package_root, source_name)
113 } else {
114 discover_fallback(package_root, source_name)
115 }
116}
117
118fn scan_skill_dir(
119 package_root: &Path,
120 relative_root: &Path,
121 items: &mut Vec<DiscoveredItem>,
122 visited: &mut HashSet<PathBuf>,
123) -> Result<(), MarsError> {
124 let dir = package_root.join(relative_root);
125 if !dir.is_dir() {
126 return Ok(());
127 }
128
129 for path in read_dir_paths_sorted(&dir)? {
130 if !path.is_dir() {
131 continue;
132 }
133 if let Some(name) = path.file_name().and_then(|name| name.to_str())
134 && name.starts_with('.')
135 {
136 continue;
137 }
138 let rel = relative_to(package_root, &path)?;
139 register_skill_dir(package_root, &rel, items, visited)?;
140 }
141
142 Ok(())
143}
144
145fn scan_agent_dir(
146 package_root: &Path,
147 relative_root: &Path,
148 items: &mut Vec<DiscoveredItem>,
149 visited: &mut HashSet<PathBuf>,
150) -> Result<(), MarsError> {
151 let dir = package_root.join(relative_root);
152 if !dir.is_dir() {
153 return Ok(());
154 }
155
156 for path in read_dir_paths_sorted(&dir)? {
157 if !path.is_file() {
158 continue;
159 }
160 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
161 continue;
162 }
163 let rel = relative_to(package_root, &path)?;
164 register_agent_file(&rel, items, visited);
165 }
166
167 Ok(())
168}
169
170fn scan_manifest_declared_path(
171 package_root: &Path,
172 declared_path: &DeclaredPath,
173 items: &mut Vec<DiscoveredItem>,
174) -> Result<(), MarsError> {
175 let mut visited = HashSet::new();
176 let candidate = package_root.join(&declared_path.relative_path);
177 match declared_path.kind {
178 ItemKind::Skill => {
179 if candidate.join("SKILL.md").is_file() {
180 register_skill_dir(
181 package_root,
182 &declared_path.relative_path,
183 items,
184 &mut visited,
185 )?;
186 } else if matches_container_root(&declared_path.relative_path, SKILL_CONTAINER_ROOTS) {
187 scan_skill_dir(
188 package_root,
189 &declared_path.relative_path,
190 items,
191 &mut visited,
192 )?;
193 }
194 }
195 ItemKind::Agent => {
196 if candidate.is_file()
197 && candidate.extension().and_then(|ext| ext.to_str()) == Some("md")
198 {
199 register_agent_file(&declared_path.relative_path, items, &mut visited);
200 } else if matches_container_root(&declared_path.relative_path, AGENT_CONTAINER_ROOTS) {
201 scan_agent_dir(
202 package_root,
203 &declared_path.relative_path,
204 items,
205 &mut visited,
206 )?;
207 }
208 }
209 }
210
211 Ok(())
212}
213
214fn register_skill_dir(
215 package_root: &Path,
216 relative_path: &Path,
217 items: &mut Vec<DiscoveredItem>,
218 visited: &mut HashSet<PathBuf>,
219) -> Result<(), MarsError> {
220 let normalized = normalize_relative_path(relative_path);
221 if !visited.insert(normalized.clone()) {
222 return Ok(());
223 }
224 if !package_root.join(&normalized).join("SKILL.md").is_file() {
225 return Ok(());
226 }
227 let name = normalized
228 .file_name()
229 .and_then(|name| name.to_str())
230 .unwrap_or_default();
231 items.push(DiscoveredItem {
232 id: ItemId {
233 kind: ItemKind::Skill,
234 name: ItemName::from(name.to_string()),
235 },
236 source_path: normalized,
237 });
238 Ok(())
239}
240
241fn register_agent_file(
242 relative_path: &Path,
243 items: &mut Vec<DiscoveredItem>,
244 visited: &mut HashSet<PathBuf>,
245) {
246 let normalized = normalize_relative_path(relative_path);
247 if !visited.insert(normalized.clone()) {
248 return;
249 }
250 let name = normalized
251 .file_stem()
252 .and_then(|name| name.to_str())
253 .unwrap_or_default();
254 items.push(DiscoveredItem {
255 id: ItemId {
256 kind: ItemKind::Agent,
257 name: ItemName::from(name.to_string()),
258 },
259 source_path: normalized,
260 });
261}
262
263fn discover_manifest_declared_items(
264 package_root: &Path,
265 source_name: &str,
266) -> Result<Vec<DiscoveredItem>, MarsError> {
267 let mut items = Vec::new();
268 for declared_path in collect_manifest_declared_paths(package_root, source_name)? {
269 scan_manifest_declared_path(package_root, &declared_path, &mut items)?;
270 }
271 Ok(dedupe_items_by_path(items))
272}
273
274fn discover_heuristic_layer_items(package_root: &Path) -> Result<Vec<DiscoveredItem>, MarsError> {
275 let candidates = collect_heuristic_candidates(package_root)?;
276 let Some(min_layer) = candidates.iter().map(|candidate| candidate.layer).min() else {
277 return Ok(Vec::new());
278 };
279
280 let items = candidates
281 .into_iter()
282 .filter(|candidate| candidate.layer == min_layer)
283 .map(|candidate| candidate.item)
284 .collect();
285 let items = dedupe_items_by_path(items);
286 Ok(dedupe_items_by_name_first_seen(items))
287}
288
289fn collect_heuristic_candidates(package_root: &Path) -> Result<Vec<LayeredCandidate>, MarsError> {
290 let mut candidates = Vec::new();
291 let mut queue = VecDeque::from([(package_root.to_path_buf(), 0usize)]);
292
293 while let Some((base_dir, depth)) = queue.pop_front() {
294 if depth > MAX_HEURISTIC_FS_DEPTH {
295 continue;
296 }
297
298 let base_rel = if base_dir == package_root {
299 PathBuf::new()
300 } else {
301 relative_to(package_root, &base_dir)?
302 };
303 collect_heuristic_candidates_at_base(package_root, &base_rel, &mut candidates)?;
304
305 if depth == MAX_HEURISTIC_FS_DEPTH {
306 continue;
307 }
308
309 for path in read_dir_paths_sorted(&base_dir)? {
310 if !path.is_dir() {
311 continue;
312 }
313 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
314 continue;
315 };
316 if RECURSIVE_SKIP_DIRS.contains(&name) {
317 continue;
318 }
319 queue.push_back((path, depth + 1));
320 }
321 }
322
323 Ok(candidates)
324}
325
326fn collect_heuristic_candidates_at_base(
327 package_root: &Path,
328 base_rel: &Path,
329 candidates: &mut Vec<LayeredCandidate>,
330) -> Result<(), MarsError> {
331 collect_direct_skill_children(package_root, base_rel, candidates)?;
332 for root in SKILL_CONTAINER_ROOTS {
333 collect_skill_container_candidates(
334 package_root,
335 &join_relative(base_rel, Path::new(root)),
336 candidates,
337 )?;
338 }
339 for root in AGENT_CONTAINER_ROOTS {
340 collect_agent_container_candidates(
341 package_root,
342 &join_relative(base_rel, Path::new(root)),
343 candidates,
344 )?;
345 }
346 Ok(())
347}
348
349fn collect_direct_skill_children(
350 package_root: &Path,
351 base_rel: &Path,
352 candidates: &mut Vec<LayeredCandidate>,
353) -> Result<(), MarsError> {
354 let base_dir = package_root.join(base_rel);
355 if !base_dir.is_dir() {
356 return Ok(());
357 }
358
359 for path in read_dir_paths_sorted(&base_dir)? {
360 if !path.is_dir() {
361 continue;
362 }
363 if let Some(name) = path.file_name().and_then(|name| name.to_str())
364 && name.starts_with('.')
365 {
366 continue;
367 }
368 let rel = relative_to(package_root, &path)?;
369 if !path.join("SKILL.md").is_file() {
370 continue;
371 }
372 candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
373 }
374
375 Ok(())
376}
377
378fn collect_skill_container_candidates(
379 package_root: &Path,
380 container_rel: &Path,
381 candidates: &mut Vec<LayeredCandidate>,
382) -> Result<(), MarsError> {
383 let container_dir = package_root.join(container_rel);
384 if !container_dir.is_dir() {
385 return Ok(());
386 }
387
388 for path in read_dir_paths_sorted(&container_dir)? {
389 if !path.is_dir() {
390 continue;
391 }
392 if let Some(name) = path.file_name().and_then(|name| name.to_str())
393 && name.starts_with('.')
394 {
395 continue;
396 }
397 if !path.join("SKILL.md").is_file() {
398 continue;
399 }
400 let rel = relative_to(package_root, &path)?;
401 candidates.push(LayeredCandidate::new(ItemKind::Skill, rel)?);
402 }
403
404 Ok(())
405}
406
407fn collect_agent_container_candidates(
408 package_root: &Path,
409 container_rel: &Path,
410 candidates: &mut Vec<LayeredCandidate>,
411) -> Result<(), MarsError> {
412 let container_dir = package_root.join(container_rel);
413 if !container_dir.is_dir() {
414 return Ok(());
415 }
416
417 for path in read_dir_paths_sorted(&container_dir)? {
418 if !path.is_file() {
419 continue;
420 }
421 if path.extension().and_then(|ext| ext.to_str()) != Some("md") {
422 continue;
423 }
424 let rel = relative_to(package_root, &path)?;
425 candidates.push(LayeredCandidate::new(ItemKind::Agent, rel)?);
426 }
427
428 Ok(())
429}
430
431fn finalize_items(
432 source_name: &str,
433 mut items: Vec<DiscoveredItem>,
434) -> Result<Vec<DiscoveredItem>, MarsError> {
435 ensure_unique_names(source_name, &items)?;
436 sort_items(&mut items);
437 Ok(items)
438}
439
440fn dedupe_items_by_path(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
441 let mut seen = HashSet::new();
442 let mut deduped = Vec::with_capacity(items.len());
443 for item in items {
444 if seen.insert(item.source_path.clone()) {
445 deduped.push(item);
446 }
447 }
448 deduped
449}
450
451fn dedupe_items_by_name_first_seen(items: Vec<DiscoveredItem>) -> Vec<DiscoveredItem> {
452 let mut seen = HashSet::new();
453 let mut deduped = Vec::with_capacity(items.len());
454 for item in items {
455 let key = (item.id.kind, item.id.name.to_string());
456 if seen.insert(key) {
457 deduped.push(item);
458 }
459 }
460 deduped
461}
462
463fn collect_manifest_declared_paths(
464 package_root: &Path,
465 source_name: &str,
466) -> Result<Vec<DeclaredPath>, MarsError> {
467 let mut declared = Vec::new();
468 for manifest in PLUGIN_MANIFESTS {
469 let path = package_root.join(manifest);
470 if !path.is_file() {
471 continue;
472 }
473 let content = std::fs::read_to_string(&path)?;
474 let json: Value = serde_json::from_str(&content).map_err(|e| MarsError::Source {
475 source_name: source_name.to_string(),
476 message: format!("failed to parse plugin manifest `{}`: {e}", path.display()),
477 })?;
478 declared.extend(parse_declared_paths(&json));
479 }
480
481 let mut resolved = Vec::new();
482 let mut seen = HashSet::new();
483 for raw in declared {
484 if !raw.raw_path.starts_with("./") {
485 continue;
486 }
487 let normalized = normalize_manifest_declared_path(&raw.raw_path).ok_or_else(|| {
488 MarsError::ManifestDeclaredPathEscape {
489 source_name: source_name.to_string(),
490 manifest_path: raw.raw_path.display().to_string(),
491 package_root: package_root.to_path_buf(),
492 }
493 })?;
494 let candidate = package_root.join(&normalized);
495 if !candidate.exists() {
496 return Err(MarsError::ManifestDeclaredPathMissing {
497 source_name: source_name.to_string(),
498 manifest_path: raw.raw_path.display().to_string(),
499 package_root: package_root.to_path_buf(),
500 });
501 }
502 let canonical =
503 candidate
504 .canonicalize()
505 .map_err(|_| MarsError::ManifestDeclaredPathMissing {
506 source_name: source_name.to_string(),
507 manifest_path: raw.raw_path.display().to_string(),
508 package_root: package_root.to_path_buf(),
509 })?;
510 let canonical_root = package_root.canonicalize().map_err(|e| MarsError::Source {
511 source_name: source_name.to_string(),
512 message: format!(
513 "failed to canonicalize package root `{}`: {e}",
514 package_root.display()
515 ),
516 })?;
517 if !canonical.starts_with(&canonical_root) {
518 return Err(MarsError::ManifestDeclaredPathEscape {
519 source_name: source_name.to_string(),
520 manifest_path: raw.raw_path.display().to_string(),
521 package_root: package_root.to_path_buf(),
522 });
523 }
524 let rel = relative_to(package_root, &candidate)?;
525 if seen.insert((raw.kind, rel.clone())) {
526 resolved.push(DeclaredPath {
527 kind: raw.kind,
528 relative_path: rel,
529 });
530 }
531 }
532 Ok(resolved)
533}
534
535fn ensure_unique_names(source_name: &str, items: &[DiscoveredItem]) -> Result<(), MarsError> {
536 let mut seen: HashMap<(ItemKind, String), PathBuf> = HashMap::new();
537 for item in items {
538 let key = (item.id.kind, item.id.name.to_string());
539 if let Some(existing) = seen.insert(key.clone(), item.source_path.clone()) {
540 return Err(MarsError::DiscoveryCollision {
541 source_name: source_name.to_string(),
542 kind: item.id.kind.to_string(),
543 item_name: item.id.name.to_string(),
544 path_a: existing,
545 path_b: item.source_path.clone(),
546 });
547 }
548 }
549 Ok(())
550}
551
552fn relative_to(base: &Path, child: &Path) -> Result<PathBuf, MarsError> {
553 child
554 .strip_prefix(base)
555 .map(|path| path.to_path_buf())
556 .map_err(|_| MarsError::Source {
557 source_name: "discover".to_string(),
558 message: format!(
559 "path `{}` is not under package root `{}`",
560 child.display(),
561 base.display()
562 ),
563 })
564}
565
566fn normalize_relative_path(path: &Path) -> PathBuf {
567 let mut normalized = PathBuf::new();
568 for component in path.components() {
569 normalized.push(component.as_os_str());
570 }
571 normalized
572}
573
574fn normalize_manifest_declared_path(path: &Path) -> Option<PathBuf> {
575 let mut normalized = PathBuf::new();
576 for component in path.components() {
577 match component {
578 Component::CurDir => {}
579 Component::Normal(seg) => normalized.push(seg),
580 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
581 }
582 }
583 if normalized.as_os_str().is_empty() {
584 None
585 } else {
586 Some(normalized)
587 }
588}
589
590fn package_basename(path: &Path) -> String {
591 path.file_name()
592 .and_then(|name| name.to_str())
593 .filter(|name| !name.is_empty())
594 .unwrap_or("unknown-skill")
595 .to_string()
596}
597
598fn read_dir_paths_sorted(dir: &Path) -> Result<Vec<PathBuf>, MarsError> {
599 let mut paths = Vec::new();
600 for entry in std::fs::read_dir(dir)? {
601 paths.push(entry?.path());
602 }
603 paths.sort();
604 Ok(paths)
605}
606
607fn join_relative(base: &Path, suffix: &Path) -> PathBuf {
608 if base.as_os_str().is_empty() {
609 suffix.to_path_buf()
610 } else {
611 base.join(suffix)
612 }
613}
614
615fn matches_container_root(path: &Path, roots: &[&str]) -> bool {
616 roots.iter().any(|root| path == Path::new(root))
617}
618
619fn parse_declared_paths(json: &Value) -> Vec<RawDeclaredPath> {
620 let Some(map) = json.as_object() else {
621 return Vec::new();
622 };
623
624 let mut declared = Vec::new();
625 for key in MANIFEST_SKILL_KEYS {
626 if let Some(value) = map.get(*key) {
627 collect_declared_paths_from_value(ItemKind::Skill, value, &mut declared);
628 }
629 }
630 for key in MANIFEST_AGENT_KEYS {
631 if let Some(value) = map.get(*key) {
632 collect_declared_paths_from_value(ItemKind::Agent, value, &mut declared);
633 }
634 }
635 declared
636}
637
638fn collect_declared_paths_from_value(
639 kind: ItemKind,
640 value: &Value,
641 declared: &mut Vec<RawDeclaredPath>,
642) {
643 match value {
644 Value::String(path) => declared.push(RawDeclaredPath {
645 kind,
646 raw_path: PathBuf::from(path),
647 }),
648 Value::Array(values) => {
649 for child in values {
650 collect_declared_paths_from_value(kind, child, declared);
651 }
652 }
653 Value::Object(map) => {
654 if let Some(path) = map.get("path").and_then(|value| value.as_str()) {
655 declared.push(RawDeclaredPath {
656 kind,
657 raw_path: PathBuf::from(path),
658 });
659 }
660 }
661 _ => {}
662 }
663}
664
665fn split_segments(path: &Path) -> Vec<String> {
666 path.components()
667 .filter_map(|component| match component {
668 Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
669 _ => None,
670 })
671 .collect()
672}
673
674fn logical_layer(kind: ItemKind, relative_path: &Path) -> Result<usize, MarsError> {
675 let segments = split_segments(relative_path);
676 let default_layer = match kind {
677 ItemKind::Skill => segments.len(),
678 ItemKind::Agent => usize::MAX,
679 };
680 let container_roots = match kind {
681 ItemKind::Skill => SKILL_CONTAINER_ROOTS,
682 ItemKind::Agent => AGENT_CONTAINER_ROOTS,
683 };
684
685 let mut layer = default_layer;
686 for root in container_roots {
687 let root_segments: Vec<&str> = root.split('/').collect();
688 if segments.len() < root_segments.len() + 1 {
689 continue;
690 }
691 let start = segments.len() - 1 - root_segments.len();
692 if segments[start..start + root_segments.len()]
693 .iter()
694 .map(String::as_str)
695 .eq(root_segments.iter().copied())
696 {
697 layer = layer.min(start + 1);
698 }
699 }
700
701 if layer == usize::MAX || layer == 0 || layer > MAX_FALLBACK_DEPTH {
702 return Err(MarsError::Source {
703 source_name: "discover".to_string(),
704 message: format!(
705 "invalid logical discovery layer for `{}`",
706 relative_path.display()
707 ),
708 });
709 }
710
711 Ok(layer)
712}
713
714#[derive(Debug, Clone)]
715struct RawDeclaredPath {
716 kind: ItemKind,
717 raw_path: PathBuf,
718}
719
720#[derive(Debug, Clone)]
721struct DeclaredPath {
722 kind: ItemKind,
723 relative_path: PathBuf,
724}
725
726#[derive(Debug, Clone)]
727struct LayeredCandidate {
728 item: DiscoveredItem,
729 layer: usize,
730}
731
732impl LayeredCandidate {
733 fn new(kind: ItemKind, source_path: PathBuf) -> Result<Self, MarsError> {
734 let item = match kind {
735 ItemKind::Skill => DiscoveredItem {
736 id: ItemId {
737 kind,
738 name: ItemName::from(
739 source_path
740 .file_name()
741 .and_then(|name| name.to_str())
742 .unwrap_or_default()
743 .to_string(),
744 ),
745 },
746 source_path: normalize_relative_path(&source_path),
747 },
748 ItemKind::Agent => DiscoveredItem {
749 id: ItemId {
750 kind,
751 name: ItemName::from(
752 source_path
753 .file_stem()
754 .and_then(|name| name.to_str())
755 .unwrap_or_default()
756 .to_string(),
757 ),
758 },
759 source_path: normalize_relative_path(&source_path),
760 },
761 };
762
763 Ok(Self {
764 layer: logical_layer(kind, &item.source_path)?,
765 item,
766 })
767 }
768}
769
770fn sort_items(items: &mut [DiscoveredItem]) {
771 items.sort_by(|a, b| {
772 a.id.cmp(&b.id)
773 .then_with(|| a.source_path.cmp(&b.source_path))
774 });
775}
776
777#[derive(Debug, Clone)]
779pub struct InstalledItem {
780 pub id: ItemId,
781 pub path: PathBuf,
783 pub frontmatter_name: Option<String>,
785 pub description: Option<String>,
787 pub skill_refs: Vec<String>,
789}
790
791#[derive(Debug, Clone)]
793pub struct InstalledState {
794 pub agents: Vec<InstalledItem>,
795 pub skills: Vec<InstalledItem>,
796}
797
798pub fn discover_installed(root: &Path) -> Result<InstalledState, MarsError> {
800 let mut agents = Vec::new();
801 let mut skills = Vec::new();
802
803 let mut scratch = Vec::new();
804 let mut visited = HashSet::new();
805 scan_agent_dir(root, Path::new("agents"), &mut scratch, &mut visited)?;
806 for item in scratch.drain(..) {
807 let path = root.join(&item.source_path);
808 let (frontmatter_name, description, skill_refs) = parse_installed_frontmatter(&path);
809 agents.push(InstalledItem {
810 id: item.id,
811 path,
812 frontmatter_name,
813 description,
814 skill_refs,
815 });
816 }
817
818 scan_skill_dir(root, Path::new("skills"), &mut scratch, &mut HashSet::new())?;
819 for item in scratch.drain(..) {
820 let path = root.join(&item.source_path);
821 let skill_md = if item.source_path == Path::new(".") {
822 root.join("SKILL.md")
823 } else {
824 path.join("SKILL.md")
825 };
826 let (frontmatter_name, description, _) = parse_installed_frontmatter(&skill_md);
827 skills.push(InstalledItem {
828 id: item.id,
829 path,
830 frontmatter_name,
831 description,
832 skill_refs: Vec::new(),
833 });
834 }
835
836 sort_installed(&mut agents);
837 sort_installed(&mut skills);
838 Ok(InstalledState { agents, skills })
839}
840
841fn parse_installed_frontmatter(path: &Path) -> (Option<String>, Option<String>, Vec<String>) {
842 let content = match std::fs::read_to_string(path) {
843 Ok(c) => c,
844 Err(_) => return (None, None, Vec::new()),
845 };
846 match crate::frontmatter::parse(&content) {
847 Ok(fm) => {
848 let name = fm.name().map(str::to_owned);
849 let description = fm
850 .get("description")
851 .and_then(|value| value.as_str())
852 .map(str::to_owned);
853 (name, description, fm.skills())
854 }
855 Err(_) => (None, None, Vec::new()),
856 }
857}
858
859fn sort_installed(items: &mut [InstalledItem]) {
860 items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.path.cmp(&b.path)));
861}
862
863#[cfg(test)]
864mod tests {
865 use super::*;
866 use std::fs;
867 use tempfile::TempDir;
868
869 #[test]
870 fn conventional_discovery_finds_agents_and_skills() {
871 let dir = TempDir::new().unwrap();
872 fs::create_dir_all(dir.path().join("agents")).unwrap();
873 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
874 fs::write(dir.path().join("agents/coder.md"), "# coder").unwrap();
875 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
876
877 let items = discover_source(dir.path(), None).unwrap();
878 assert_eq!(items.len(), 2);
879 assert!(
880 items
881 .iter()
882 .any(|item| item.source_path == Path::new("agents/coder.md"))
883 );
884 assert!(
885 items
886 .iter()
887 .any(|item| item.source_path == Path::new("skills/planning"))
888 );
889 }
890
891 #[test]
892 fn dispatcher_prefers_conventional_when_manifest_exists() {
893 let dir = TempDir::new().unwrap();
894 fs::write(
895 dir.path().join("mars.toml"),
896 "[package]\nname='demo'\nversion='0.1.0'\n",
897 )
898 .unwrap();
899 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
900 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
901 fs::create_dir_all(dir.path().join("nested")).unwrap();
902 fs::write(dir.path().join("nested/SKILL.md"), "# nested").unwrap();
903
904 let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
905 assert_eq!(items.len(), 1);
906 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
907 }
908
909 #[test]
910 fn fallback_short_circuits_root_skill() {
911 let dir = TempDir::new().unwrap();
912 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
913 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
914 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
915
916 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
917 assert_eq!(items.len(), 1);
918 assert_eq!(
919 items[0].id.name.as_str(),
920 dir.path().file_name().unwrap().to_string_lossy().as_ref()
921 );
922 assert_eq!(items[0].source_path, PathBuf::from("."));
923 }
924
925 #[test]
926 fn fallback_priority_scan_finds_skill_dirs_and_agents() {
927 let dir = TempDir::new().unwrap();
928 fs::create_dir_all(dir.path().join("skills/.experimental/find-skills")).unwrap();
929 fs::create_dir_all(dir.path().join(".claude/agents")).unwrap();
930 fs::write(
931 dir.path().join("skills/.experimental/find-skills/SKILL.md"),
932 "# skill",
933 )
934 .unwrap();
935 fs::write(dir.path().join(".claude/agents/reviewer.md"), "# agent").unwrap();
936
937 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
938 assert_eq!(items.len(), 2);
939 assert!(
940 items
941 .iter()
942 .any(|item| item.source_path == Path::new("skills/.experimental/find-skills"))
943 );
944 assert!(
945 items
946 .iter()
947 .any(|item| item.source_path == Path::new(".claude/agents/reviewer.md"))
948 );
949 }
950
951 #[test]
952 fn conventional_root_skill_does_not_override_conventional_items() {
953 let dir = TempDir::new().unwrap();
954 fs::write(
955 dir.path().join("mars.toml"),
956 "[package]\nname='demo'\nversion='0.1.0'\n",
957 )
958 .unwrap();
959 fs::write(dir.path().join("SKILL.md"), "# root").unwrap();
960 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
961 fs::write(dir.path().join("skills/planning/SKILL.md"), "# planning").unwrap();
962
963 let items = discover_resolved_source(dir.path(), Some("demo")).unwrap();
964 assert_eq!(items.len(), 1);
965 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
966 }
967
968 #[test]
969 fn fallback_manifest_paths_precede_heuristic_layers() {
970 let dir = TempDir::new().unwrap();
971 fs::create_dir_all(dir.path().join("top-level")).unwrap();
972 fs::create_dir_all(dir.path().join("plugins/deep-skill")).unwrap();
973 fs::write(dir.path().join("top-level/SKILL.md"), "# top").unwrap();
974 fs::write(dir.path().join("plugins/deep-skill/SKILL.md"), "# deep").unwrap();
975 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
976 fs::write(
977 dir.path().join(".claude-plugin/plugin.json"),
978 r#"{"skills":[{"path":"./plugins/deep-skill"}]}"#,
979 )
980 .unwrap();
981
982 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
983 assert_eq!(items.len(), 1);
984 assert_eq!(items[0].source_path, PathBuf::from("plugins/deep-skill"));
985 }
986
987 #[test]
988 fn fallback_dedupes_overlapping_manifest_and_container_paths() {
989 let dir = TempDir::new().unwrap();
990 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
991 fs::write(dir.path().join("skills/planning/SKILL.md"), "# skill").unwrap();
992 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
993 fs::write(
994 dir.path().join(".claude-plugin/plugin.json"),
995 r#"{"skills":[{"path":"./skills/planning"}]}"#,
996 )
997 .unwrap();
998
999 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1000 assert_eq!(items.len(), 1);
1001 assert_eq!(items[0].source_path, PathBuf::from("skills/planning"));
1002 }
1003
1004 #[test]
1005 fn fallback_manifest_declares_agent_paths_without_heuristic_json_walk() {
1006 let dir = TempDir::new().unwrap();
1007 fs::create_dir_all(dir.path().join("agents")).unwrap();
1008 fs::write(dir.path().join("agents/reviewer.md"), "# reviewer").unwrap();
1009 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1010 fs::write(
1011 dir.path().join(".claude-plugin/plugin.json"),
1012 r#"{"agents":[{"path":"./agents/reviewer.md"}],"metadata":{"agents":[{"path":"./ignore.md"}]}}"#,
1013 )
1014 .unwrap();
1015
1016 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1017 assert_eq!(items.len(), 1);
1018 assert_eq!(items[0].source_path, PathBuf::from("agents/reviewer.md"));
1019 }
1020
1021 #[test]
1022 fn fallback_prefers_first_match_after_visit_dedupe() {
1023 let dir = TempDir::new().unwrap();
1024 fs::create_dir_all(dir.path().join("skills/plan")).unwrap();
1025 fs::create_dir_all(dir.path().join("plan")).unwrap();
1026 fs::write(dir.path().join("skills/plan/SKILL.md"), "# skill a").unwrap();
1027 fs::write(dir.path().join("plan/SKILL.md"), "# skill b").unwrap();
1028
1029 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1030 assert_eq!(items.len(), 1);
1031 assert_eq!(items[0].source_path, PathBuf::from("plan"));
1032 }
1033
1034 #[test]
1035 fn fallback_prefers_first_mirrored_skill_match() {
1036 let dir = TempDir::new().unwrap();
1037 fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1038 fs::create_dir_all(dir.path().join("caveman")).unwrap();
1039 fs::write(dir.path().join("skills/caveman/SKILL.md"), "# same").unwrap();
1040 fs::write(dir.path().join("caveman/SKILL.md"), "# same").unwrap();
1041
1042 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1043 assert_eq!(items.len(), 1);
1044 assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1045 }
1046
1047 #[test]
1048 fn fallback_manifest_declared_escape_is_rejected() {
1049 let dir = TempDir::new().unwrap();
1050 fs::create_dir_all(dir.path().join(".claude-plugin")).unwrap();
1051 fs::write(
1052 dir.path().join(".claude-plugin/plugin.json"),
1053 r#"{"skills":[{"path":"./../escape"}]}"#,
1054 )
1055 .unwrap();
1056
1057 let err = discover_fallback(dir.path(), Some("demo")).unwrap_err();
1058 assert!(matches!(err, MarsError::ManifestDeclaredPathEscape { .. }));
1059 }
1060
1061 #[test]
1062 fn fallback_selects_first_non_empty_logical_layer() {
1063 let dir = TempDir::new().unwrap();
1064 fs::create_dir_all(dir.path().join("top")).unwrap();
1065 fs::create_dir_all(dir.path().join("nested/deeper/skill")).unwrap();
1066 fs::write(dir.path().join("top/SKILL.md"), "# top").unwrap();
1067 fs::write(dir.path().join("nested/deeper/skill/SKILL.md"), "# skill").unwrap();
1068
1069 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1070 assert_eq!(items.len(), 1);
1071 assert_eq!(items[0].source_path, PathBuf::from("top"));
1072 }
1073
1074 #[test]
1075 fn fallback_groups_layout_variants_into_same_logical_layer() {
1076 let dir = TempDir::new().unwrap();
1077 fs::create_dir_all(dir.path().join("caveman")).unwrap();
1078 fs::create_dir_all(dir.path().join("skills/caveman")).unwrap();
1079 fs::write(dir.path().join("caveman/SKILL.md"), "# direct").unwrap();
1080 fs::write(dir.path().join("skills/caveman/SKILL.md"), "# container").unwrap();
1081
1082 let items = discover_fallback(dir.path(), Some("demo")).unwrap();
1083 assert_eq!(items.len(), 1);
1084 assert_eq!(items[0].source_path, PathBuf::from("caveman"));
1085 }
1086
1087 #[test]
1088 fn discover_installed_reads_frontmatter() {
1089 let dir = TempDir::new().unwrap();
1090 fs::create_dir_all(dir.path().join("agents")).unwrap();
1091 fs::create_dir_all(dir.path().join("skills/planning")).unwrap();
1092 fs::write(
1093 dir.path().join("agents/coder.md"),
1094 "---\nname: coder\ndescription: test\nskills: [planning]\n---\n# Coder\n",
1095 )
1096 .unwrap();
1097 fs::write(
1098 dir.path().join("skills/planning/SKILL.md"),
1099 "---\nname: planning\ndescription: test\n---\n# Planning\n",
1100 )
1101 .unwrap();
1102
1103 let state = discover_installed(dir.path()).unwrap();
1104 assert_eq!(state.agents.len(), 1);
1105 assert_eq!(state.skills.len(), 1);
1106 assert_eq!(state.agents[0].skill_refs, vec!["planning"]);
1107 assert_eq!(
1108 state.skills[0].frontmatter_name.as_deref(),
1109 Some("planning")
1110 );
1111 }
1112}