1use 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
19const 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];
27const 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#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct DiscoveredItem {
42 pub id: ItemId,
43 pub source_path: PathBuf,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48struct LayeredItem {
49 item: DiscoveredItem,
50 layer: usize,
55}
56
57pub 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
66pub 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
79pub 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 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 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 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#[derive(Debug, Clone)]
674pub struct InstalledItem {
675 pub id: ItemId,
676 pub path: PathBuf,
678 pub frontmatter_name: Option<String>,
680 pub description: Option<String>,
682 pub skill_refs: Vec<String>,
684}
685
686#[derive(Debug, Clone)]
688pub struct InstalledState {
689 pub agents: Vec<InstalledItem>,
690 pub skills: Vec<InstalledItem>,
691}
692
693pub 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;