1use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10
11use yapi_types::rpc::SourceInfo;
12use yapi_types::settings::FilteredPackage;
13
14use crate::chest::ChestError;
15use crate::chest::manifest::read_package_meta;
16use crate::glob;
17use crate::tools::path::{relative, resolve_lexically};
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
21pub enum ResourceType {
22 Extensions,
24 Skills,
26 Prompts,
28 Themes,
30}
31
32impl ResourceType {
33 pub const ALL: [ResourceType; 4] = [
35 ResourceType::Extensions,
36 ResourceType::Skills,
37 ResourceType::Prompts,
38 ResourceType::Themes,
39 ];
40
41 pub fn key(self) -> &'static str {
43 match self {
44 ResourceType::Extensions => "extensions",
45 ResourceType::Skills => "skills",
46 ResourceType::Prompts => "prompts",
47 ResourceType::Themes => "themes",
48 }
49 }
50
51 fn matches_file(self, name: &str) -> bool {
52 match self {
53 ResourceType::Extensions => is_extension_file(name),
54 ResourceType::Skills | ResourceType::Prompts => name.ends_with(".md"),
55 ResourceType::Themes => name.ends_with(".json"),
56 }
57 }
58}
59
60fn is_extension_file(name: &str) -> bool {
62 name.ends_with(".ts") || name.ends_with(".js") || name.ends_with(".wasm")
63}
64
65#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct Resolved {
68 pub info: SourceInfo,
70 pub enabled: bool,
72}
73
74#[derive(Clone, Debug, Default, PartialEq, Eq)]
76pub struct ResolvedPaths {
77 pub extensions: Vec<Resolved>,
79 pub skills: Vec<Resolved>,
81 pub prompts: Vec<Resolved>,
83 pub themes: Vec<Resolved>,
85 pub shadowed: Vec<SourceInfo>,
89 pub problems: Vec<(String, String)>,
92}
93
94impl ResolvedPaths {
95 pub fn of(&self, kind: ResourceType) -> &[Resolved] {
97 match kind {
98 ResourceType::Extensions => &self.extensions,
99 ResourceType::Skills => &self.skills,
100 ResourceType::Prompts => &self.prompts,
101 ResourceType::Themes => &self.themes,
102 }
103 }
104
105 fn of_mut(&mut self, kind: ResourceType) -> &mut Vec<Resolved> {
106 match kind {
107 ResourceType::Extensions => &mut self.extensions,
108 ResourceType::Skills => &mut self.skills,
109 ResourceType::Prompts => &mut self.prompts,
110 ResourceType::Themes => &mut self.themes,
111 }
112 }
113
114 fn add(&mut self, kind: ResourceType, path: &Path, metadata: &SourceInfo, enabled: bool) {
116 let path = path.to_string_lossy().into_owned();
117 if path.is_empty() {
118 return;
119 }
120 let info = SourceInfo {
121 path,
122 ..metadata.clone()
123 };
124 let list = self.of_mut(kind);
125 if let Some(listed) = list.iter().find(|resource| resource.info.path == info.path) {
126 let other = (&listed.info.source, &listed.info.scope) != (&info.source, &info.scope);
127 if kind == ResourceType::Extensions && other {
128 self.shadowed.push(info);
129 }
130 return;
131 }
132 list.push(Resolved { info, enabled });
133 }
134
135 pub fn enabled(&self, kind: ResourceType) -> impl Iterator<Item = &SourceInfo> {
137 self.of(kind)
138 .iter()
139 .filter(|resource| resource.enabled)
140 .map(|resource| &resource.info)
141 }
142}
143
144#[derive(Clone, Debug, PartialEq)]
146pub struct PackageInput {
147 pub source: String,
149 pub scope: String,
151 pub root: PathBuf,
153 pub filter: Option<FilteredPackage>,
155}
156
157#[derive(Clone, Debug, Default)]
159pub struct ResolveInput<'a> {
160 pub cwd: PathBuf,
163 pub agent_dir: PathBuf,
165 pub project_dir: PathBuf,
167 pub home: PathBuf,
169 pub project_trusted: bool,
171 pub packages: Vec<PackageInput>,
173 pub skills: Vec<PackageInput>,
176 pub user: [&'a [String]; 4],
178 pub project: [&'a [String]; 4],
180 pub builtins: &'a [&'a str],
182}
183
184pub const BUILTIN_PREFIX: &str = "builtin:";
186
187fn to_posix(path: &str) -> String {
188 path.replace('\\', "/")
189}
190
191fn base_name(path: &Path) -> String {
192 path.file_name()
193 .map(|name| name.to_string_lossy().into_owned())
194 .unwrap_or_default()
195}
196
197fn matches_any_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
200 let rel = relative(base, file);
201 let name = base_name(file);
202 let whole = to_posix(&file.to_string_lossy());
203 let parent = (name == "SKILL.md").then(|| file.parent()).flatten();
204 patterns.iter().any(|pattern| {
205 let pattern = to_posix(pattern);
206 if pattern.starts_with("./") {
209 return false;
210 }
211 if glob::matches(&pattern, &rel)
212 || glob::matches(&pattern, &name)
213 || glob::matches(&pattern, &whole)
214 {
215 return true;
216 }
217 parent.is_some_and(|parent| {
218 glob::matches(&pattern, &relative(base, parent))
219 || glob::matches(&pattern, &base_name(parent))
220 || glob::matches(&pattern, &to_posix(&parent.to_string_lossy()))
221 })
222 })
223}
224
225fn matches_any_exact_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
228 if patterns.is_empty() {
229 return false;
230 }
231 let rel = relative(base, file);
232 let whole = to_posix(&file.to_string_lossy());
233 let parent = (base_name(file) == "SKILL.md")
234 .then(|| file.parent())
235 .flatten();
236 patterns.iter().any(|pattern| {
237 let pattern = pattern
238 .strip_prefix("./")
239 .or_else(|| pattern.strip_prefix(".\\"))
240 .unwrap_or(pattern);
241 let pattern = to_posix(pattern);
242 if pattern == rel || pattern == whole {
243 return true;
244 }
245 parent.is_some_and(|parent| {
246 pattern == relative(base, parent) || pattern == to_posix(&parent.to_string_lossy())
247 })
248 })
249}
250
251fn is_override(entry: &str) -> bool {
252 entry.starts_with(['!', '+', '-'])
253}
254
255fn is_pattern(entry: &str) -> bool {
256 is_override(entry) || entry.contains(['*', '?'])
257}
258
259fn has_glob(entry: &str) -> bool {
260 entry.contains(['*', '?'])
261}
262
263fn stripped<'a>(entries: impl Iterator<Item = &'a String>, sign: char) -> Vec<String> {
264 entries
265 .filter_map(|entry| entry.strip_prefix(sign).map(str::to_owned))
266 .collect()
267}
268
269pub fn is_enabled_by_overrides(file: &Path, entries: &[String], base: &Path) -> bool {
272 let excludes = stripped(entries.iter(), '!');
273 let force_includes = stripped(entries.iter(), '+');
274 let force_excludes = stripped(entries.iter(), '-');
275 let mut enabled = true;
276 if !excludes.is_empty() && matches_any_pattern(file, &excludes, base) {
277 enabled = false;
278 }
279 if matches_any_exact_pattern(file, &force_includes, base) {
280 enabled = true;
281 }
282 if matches_any_exact_pattern(file, &force_excludes, base) {
283 enabled = false;
284 }
285 enabled
286}
287
288pub fn apply_patterns(all: &[PathBuf], patterns: &[String], base: &Path) -> HashSet<PathBuf> {
291 let includes: Vec<String> = patterns
292 .iter()
293 .filter(|pattern| !is_override(pattern))
294 .cloned()
295 .collect();
296 let excludes = stripped(patterns.iter(), '!');
297 let force_includes = stripped(patterns.iter(), '+');
298 let force_excludes = stripped(patterns.iter(), '-');
299 let mut result: Vec<PathBuf> = if includes.is_empty() {
300 all.to_vec()
301 } else {
302 all.iter()
303 .filter(|file| matches_any_pattern(file, &includes, base))
304 .cloned()
305 .collect()
306 };
307 if !excludes.is_empty() {
308 result.retain(|file| !matches_any_pattern(file, &excludes, base));
309 }
310 for file in all {
311 if !result.contains(file) && matches_any_exact_pattern(file, &force_includes, base) {
312 result.push(file.clone());
313 }
314 }
315 if !force_excludes.is_empty() {
316 result.retain(|file| !matches_any_exact_pattern(file, &force_excludes, base));
317 }
318 result.into_iter().collect()
319}
320
321fn apply_autoload_disabled_patterns(
324 all: &[PathBuf],
325 patterns: &[String],
326 base: &Path,
327) -> Vec<(PathBuf, bool)> {
328 let mut result: Vec<(PathBuf, bool)> = Vec::new();
329 for pattern in patterns {
330 let target = if is_override(pattern) {
331 pattern[1..].to_owned()
332 } else {
333 pattern.clone()
334 };
335 let enabled = !pattern.starts_with(['-', '!']);
336 let exact = pattern.starts_with(['+', '-']);
337 for file in all {
338 let found = if exact {
339 matches_any_exact_pattern(file, std::slice::from_ref(&target), base)
340 } else {
341 matches_any_pattern(file, std::slice::from_ref(&target), base)
342 };
343 if found {
344 match result.iter_mut().find(|(path, _)| path == file) {
345 Some(entry) => entry.1 = enabled,
346 None => result.push((file.clone(), enabled)),
347 }
348 }
349 }
350 }
351 result
352}
353
354#[derive(Clone, Debug, Default)]
357struct Ignore {
358 rules: Vec<(String, bool)>,
359}
360
361impl Ignore {
362 fn add_dir(&mut self, dir: &Path, root: &Path) {
365 let rel = relative(root, dir);
366 let prefix = if rel.is_empty() {
367 String::new()
368 } else {
369 format!("{rel}/")
370 };
371 for name in [".gitignore", ".ignore", ".fdignore"] {
372 let Ok(text) = std::fs::read_to_string(dir.join(name)) else {
373 continue;
374 };
375 for line in text.lines() {
376 let trimmed = line.trim();
377 if trimmed.is_empty() || (trimmed.starts_with('#') && !trimmed.starts_with("\\#")) {
378 continue;
379 }
380 let (mut pattern, negated) = match line.strip_prefix('!') {
381 Some(rest) => (rest, true),
382 None => (
383 line.strip_prefix('\\')
384 .filter(|rest| rest.starts_with('!'))
385 .unwrap_or(line),
386 false,
387 ),
388 };
389 pattern = pattern.strip_prefix('/').unwrap_or(pattern);
390 self.rules
391 .push((format!("{prefix}{}", pattern.trim_end()), negated));
392 }
393 }
394 }
395
396 fn ignores(&self, path: &str) -> bool {
399 let dir = path.ends_with('/');
400 let path = path.trim_end_matches('/');
401 let mut ignored = false;
402 for (pattern, negated) in &self.rules {
403 if rule_matches(pattern, path, dir) {
404 ignored = !negated;
405 }
406 }
407 ignored
408 }
409}
410
411fn rule_matches(pattern: &str, path: &str, dir: bool) -> bool {
415 let (pattern, dir_only) = match pattern.strip_suffix('/') {
416 Some(pattern) => (pattern, true),
417 None => (pattern, false),
418 };
419 if dir_only && !dir {
420 return false;
421 }
422 if pattern.contains('/') {
423 glob::matches(pattern, path)
424 } else {
425 glob::matches(pattern, path.rsplit('/').next().unwrap_or(path))
426 }
427}
428
429struct Entry {
430 path: PathBuf,
431 is_dir: bool,
432 is_file: bool,
433 name: String,
434}
435
436fn read_dir(dir: &Path) -> Vec<Entry> {
438 let Ok(entries) = std::fs::read_dir(dir) else {
439 return Vec::new();
440 };
441 let mut out: Vec<Entry> = entries
442 .flatten()
443 .filter_map(|entry| {
444 let path = entry.path();
445 let metadata = std::fs::metadata(&path).ok()?;
446 Some(Entry {
447 name: entry.file_name().to_string_lossy().into_owned(),
448 is_dir: metadata.is_dir(),
449 is_file: metadata.is_file(),
450 path,
451 })
452 })
453 .collect();
454 out.sort_by(|a, b| a.name.cmp(&b.name));
455 out
456}
457
458fn collect_files(dir: &Path, kind: ResourceType, ignore: &mut Ignore, root: &Path) -> Vec<PathBuf> {
461 let mut files = Vec::new();
462 if !dir.exists() {
463 return files;
464 }
465 ignore.add_dir(dir, root);
466 for entry in read_dir(dir) {
467 if entry.name.starts_with('.') || entry.name == "node_modules" {
468 continue;
469 }
470 let rel = relative(root, &entry.path);
471 let probe = if entry.is_dir { format!("{rel}/") } else { rel };
472 if ignore.ignores(&probe) {
473 continue;
474 }
475 if entry.is_dir {
476 files.extend(collect_files(&entry.path, kind, ignore, root));
477 } else if entry.is_file && kind.matches_file(&entry.name) {
478 files.push(entry.path);
479 }
480 }
481 files
482}
483
484#[derive(Clone, Copy, PartialEq, Eq)]
487enum SkillMode {
488 Pi,
489 Agents,
490}
491
492fn collect_skill_entries(
495 dir: &Path,
496 mode: SkillMode,
497 ignore: &mut Ignore,
498 root: &Path,
499) -> Vec<PathBuf> {
500 let mut entries = Vec::new();
501 if !dir.exists() {
502 return entries;
503 }
504 ignore.add_dir(dir, root);
505 let listing = read_dir(dir);
506 if let Some(skill) = listing.iter().find(|entry| entry.name == "SKILL.md")
507 && skill.is_file
508 && !ignore.ignores(&relative(root, &skill.path))
509 {
510 entries.push(skill.path.clone());
511 return entries;
512 }
513 for entry in listing {
514 if entry.name.starts_with('.') || entry.name == "node_modules" {
515 continue;
516 }
517 let rel = relative(root, &entry.path);
518 let markdown = entry.is_file
519 && entry.name.ends_with(".md")
520 && !ignore.ignores(&rel)
521 && ((mode == SkillMode::Pi && dir == root)
522 || (mode == SkillMode::Agents && dir != root));
523 if markdown {
524 entries.push(entry.path);
525 continue;
526 }
527 if !entry.is_dir || ignore.ignores(&format!("{rel}/")) {
528 continue;
529 }
530 entries.extend(collect_skill_entries(&entry.path, mode, ignore, root));
531 }
532 entries
533}
534
535fn collect_top_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
538 if !dir.exists() {
539 return Vec::new();
540 }
541 let mut ignore = Ignore::default();
542 ignore.add_dir(dir, dir);
543 read_dir(dir)
544 .into_iter()
545 .filter(|entry| !entry.name.starts_with('.') && entry.name != "node_modules")
546 .filter(|entry| !ignore.ignores(&relative(dir, &entry.path)))
547 .filter(|entry| entry.is_file && kind.matches_file(&entry.name))
548 .map(|entry| entry.path)
549 .collect()
550}
551
552fn collect_auto_extension_entries(dir: &Path) -> Vec<PathBuf> {
555 if !dir.exists() {
556 return Vec::new();
557 }
558 if let Some(entries) = crate::extensions::discovery::entries(dir) {
559 return entries;
560 }
561 let mut ignore = Ignore::default();
562 ignore.add_dir(dir, dir);
563 let mut entries = Vec::new();
564 for entry in read_dir(dir) {
565 if entry.name.starts_with('.') || entry.name == "node_modules" {
566 continue;
567 }
568 let rel = relative(dir, &entry.path);
569 let probe = if entry.is_dir { format!("{rel}/") } else { rel };
570 if ignore.ignores(&probe) {
571 continue;
572 }
573 if entry.is_file && is_extension_file(&entry.name) {
574 entries.push(entry.path);
575 } else if entry.is_dir {
576 entries.extend(crate::extensions::discovery::entries(&entry.path).unwrap_or_default());
577 }
578 }
579 entries
580}
581
582fn collect_resource_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
584 match kind {
585 ResourceType::Skills => {
586 collect_skill_entries(dir, SkillMode::Pi, &mut Ignore::default(), dir)
587 }
588 ResourceType::Extensions => collect_auto_extension_entries(dir),
589 _ => collect_files(dir, kind, &mut Ignore::default(), dir),
590 }
591}
592
593fn collect_files_from_paths(paths: &[PathBuf], kind: ResourceType) -> Vec<PathBuf> {
595 let mut files = Vec::new();
596 for path in paths {
597 let Ok(metadata) = std::fs::metadata(path) else {
598 continue;
599 };
600 if metadata.is_file() {
601 files.push(path.clone());
602 } else if metadata.is_dir() {
603 files.extend(collect_resource_files(path, kind));
604 }
605 }
606 files
607}
608
609#[derive(Default)]
614pub(crate) struct Manifest {
615 lists: [Option<Vec<String>>; 4],
616}
617
618impl Manifest {
619 pub(crate) fn read(root: &Path) -> (Option<Manifest>, Option<ChestError>) {
623 let (meta, problem) = read_package_meta(root);
624 let lists = ResourceType::ALL.map(|kind| meta.resources.get(kind.key()).cloned());
625 (meta.manifest.then_some(Manifest { lists }), problem)
626 }
627
628 pub(crate) fn get(&self, kind: ResourceType) -> Option<&Vec<String>> {
630 self.lists[kind as usize].as_ref()
631 }
632}
633
634fn expand_package_glob(pattern: &str, root: &Path) -> Vec<PathBuf> {
637 fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
638 for entry in read_dir(dir) {
639 let rel = relative(root, &entry.path);
640 out.push(rel);
641 if entry.is_dir && entry.name != "node_modules" && !entry.name.starts_with('.') {
642 walk(root, &entry.path, out);
643 }
644 }
645 }
646 let mut all = Vec::new();
647 walk(root, root, &mut all);
648 let mut found: Vec<PathBuf> = all
649 .into_iter()
650 .filter(|rel| rel.split('/').all(|segment| !segment.starts_with('.')))
651 .filter(|rel| glob::matches(pattern, rel))
652 .map(|rel| root.join(rel))
653 .collect();
654 found.sort();
655 found
656}
657
658fn manifest_files(entries: &[String], root: &Path, kind: ResourceType) -> Vec<PathBuf> {
660 let resolved: Vec<PathBuf> = entries
661 .iter()
662 .filter(|entry| !is_override(entry))
663 .flat_map(|entry| {
664 if has_glob(entry) {
665 expand_package_glob(entry, root)
666 } else {
667 vec![resolve_lexically(root, Path::new(entry))]
668 }
669 })
670 .collect();
671 collect_files_from_paths(&resolved, kind)
672}
673
674fn metadata(source: &str, scope: &str, origin: &str, base_dir: Option<&Path>) -> SourceInfo {
675 SourceInfo {
676 path: String::new(),
677 source: source.to_owned(),
678 scope: scope.to_owned(),
679 origin: origin.to_owned(),
680 base_dir: base_dir.map(|dir| dir.to_string_lossy().into_owned()),
681 }
682}
683
684fn collect_package_resources(
686 root: &Path,
687 acc: &mut ResolvedPaths,
688 filter: Option<&FilteredPackage>,
689 meta: &SourceInfo,
690) -> bool {
691 let (manifest, problem) = Manifest::read(root);
692 if let Some(err) = problem {
693 let folder = root.to_string_lossy().into_owned();
694 acc.problems.push((folder, err.to_string()));
695 }
696 if let Some(filter) = filter {
697 for kind in ResourceType::ALL {
698 let patterns = match kind {
699 ResourceType::Extensions => &filter.extensions,
700 ResourceType::Skills => &filter.skills,
701 ResourceType::Prompts => &filter.prompts,
702 ResourceType::Themes => &filter.themes,
703 };
704 if filter.autoload == Some(false) {
705 let patterns = patterns.clone().unwrap_or_default();
706 if patterns.is_empty() {
707 continue;
708 }
709 let all = package_files(root, manifest.as_ref(), kind);
710 for (file, enabled) in apply_autoload_disabled_patterns(&all, &patterns, root) {
711 acc.add(kind, &file, meta, enabled);
712 }
713 } else if let Some(patterns) = patterns {
714 let all = package_files(root, manifest.as_ref(), kind);
715 let enabled = if patterns.is_empty() {
716 HashSet::new()
717 } else {
718 apply_patterns(&all, patterns, root)
719 };
720 for file in &all {
721 acc.add(kind, file, meta, enabled.contains(file));
722 }
723 } else {
724 default_resources(root, manifest.as_ref(), kind, acc, meta);
725 }
726 }
727 return true;
728 }
729 if let Some(manifest) = &manifest {
730 for kind in ResourceType::ALL {
731 if let Some(entries) = manifest.get(kind) {
732 manifest_entries(entries, root, kind, acc, meta);
733 }
734 }
735 return true;
736 }
737 let mut any = false;
738 for kind in ResourceType::ALL {
739 let dir = root.join(kind.key());
740 if dir.exists() {
741 for file in collect_resource_files(&dir, kind) {
742 acc.add(kind, &file, meta, true);
743 }
744 any = true;
745 }
746 }
747 any
748}
749
750fn default_resources(
752 root: &Path,
753 manifest: Option<&Manifest>,
754 kind: ResourceType,
755 acc: &mut ResolvedPaths,
756 meta: &SourceInfo,
757) {
758 if let Some(entries) = manifest.and_then(|manifest| manifest.get(kind)) {
759 manifest_entries(entries, root, kind, acc, meta);
760 return;
761 }
762 let dir = root.join(kind.key());
763 if dir.exists() {
764 for file in collect_resource_files(&dir, kind) {
765 acc.add(kind, &file, meta, true);
766 }
767 }
768}
769
770fn package_files(root: &Path, manifest: Option<&Manifest>, kind: ResourceType) -> Vec<PathBuf> {
773 let entries = manifest.and_then(|manifest| manifest.get(kind));
774 selected_files(root, entries.map(Vec::as_slice), kind.key(), kind)
775}
776
777pub(crate) fn selected_files(
781 root: &Path,
782 entries: Option<&[String]>,
783 folder: &str,
784 kind: ResourceType,
785) -> Vec<PathBuf> {
786 if let Some(entries) = entries.filter(|entries| !entries.is_empty()) {
787 let all = manifest_files(entries, root, kind);
788 let patterns: Vec<String> = entries
789 .iter()
790 .filter(|entry| is_override(entry))
791 .cloned()
792 .collect();
793 if patterns.is_empty() {
794 return all;
795 }
796 let enabled = apply_patterns(&all, &patterns, root);
797 return all
798 .into_iter()
799 .filter(|file| enabled.contains(file))
800 .collect();
801 }
802 let dir = root.join(folder);
803 if dir.exists() {
804 collect_resource_files(&dir, kind)
805 } else {
806 Vec::new()
807 }
808}
809
810fn manifest_entries(
812 entries: &[String],
813 root: &Path,
814 kind: ResourceType,
815 acc: &mut ResolvedPaths,
816 meta: &SourceInfo,
817) {
818 let all = manifest_files(entries, root, kind);
819 let patterns: Vec<String> = entries
820 .iter()
821 .filter(|entry| is_override(entry))
822 .cloned()
823 .collect();
824 let enabled = apply_patterns(&all, &patterns, root);
825 for file in &all {
826 if enabled.contains(file) {
827 acc.add(kind, file, meta, true);
828 }
829 }
830}
831
832fn local_entries(
835 entries: &[String],
836 kind: ResourceType,
837 acc: &mut ResolvedPaths,
838 meta: &SourceInfo,
839 base: &Path,
840) {
841 if entries.is_empty() {
842 return;
843 }
844 let (patterns, plain): (Vec<String>, Vec<String>) =
845 entries.iter().cloned().partition(|entry| is_pattern(entry));
846 let resolved: Vec<PathBuf> = plain
847 .iter()
848 .map(|entry| resolve_lexically(base, Path::new(&crate::tools::path::expand(entry.trim()))))
849 .collect();
850 let all = collect_files_from_paths(&resolved, kind);
851 let enabled = apply_patterns(&all, &patterns, base);
852 for file in &all {
853 acc.add(kind, file, meta, enabled.contains(file));
854 }
855}
856
857fn ancestor_agents_skill_dirs(start: &Path) -> Vec<PathBuf> {
860 let repo = start
861 .ancestors()
862 .find(|dir| dir.join(".git").exists())
863 .map(Path::to_path_buf);
864 let mut dirs = Vec::new();
865 for dir in start.ancestors() {
866 dirs.push(dir.join(".agents").join("skills"));
867 if repo.as_deref() == Some(dir) {
868 break;
869 }
870 }
871 dirs
872}
873
874fn auto_discovered(input: &ResolveInput<'_>, acc: &mut ResolvedPaths) {
878 let user_agents = input.home.join(".agents").join("skills");
879 let project_agents: Vec<PathBuf> = if input.project_trusted {
880 ancestor_agents_skill_dirs(&input.cwd)
881 .into_iter()
882 .filter(|dir| *dir != user_agents)
883 .collect()
884 } else {
885 Vec::new()
886 };
887 let scopes = [
888 (
889 "project",
890 &input.project_dir,
891 input.project,
892 input.project_trusted,
893 project_agents,
894 ),
895 (
896 "user",
897 &input.agent_dir,
898 input.user,
899 true,
900 vec![user_agents],
901 ),
902 ];
903 for (scope, base, overrides, own, agents) in scopes {
904 let mut add = |kind: ResourceType, paths: Vec<PathBuf>, base: &Path| {
905 let meta = metadata("auto", scope, "top-level", Some(base));
906 for path in paths {
907 let enabled = is_enabled_by_overrides(&path, overrides[kind as usize], base);
908 acc.add(kind, &path, &meta, enabled);
909 }
910 };
911 for kind in ResourceType::ALL {
912 let dir = base.join(kind.key());
913 if own {
914 let paths = match kind {
915 ResourceType::Extensions => collect_auto_extension_entries(&dir),
916 ResourceType::Skills => {
917 collect_skill_entries(&dir, SkillMode::Pi, &mut Ignore::default(), &dir)
918 }
919 _ => collect_top_files(&dir, kind),
920 };
921 add(kind, paths, base);
922 }
923 if kind == ResourceType::Skills {
924 for dir in &agents {
925 let paths =
926 collect_skill_entries(dir, SkillMode::Agents, &mut Ignore::default(), dir);
927 add(kind, paths, dir.parent().unwrap_or(dir));
928 }
929 }
930 }
931 }
932}
933
934fn precedence(info: &SourceInfo) -> u8 {
936 if info.source == "builtin" {
937 return 5;
938 }
939 if info.origin == "package" {
940 return 4;
941 }
942 let scope = if info.scope == "project" { 0 } else { 2 };
943 scope + u8::from(info.source != "local")
944}
945
946fn collect_package(
950 acc: &mut ResolvedPaths,
951 root: &Path,
952 filter: Option<&FilteredPackage>,
953 local: bool,
954 mut meta: SourceInfo,
955) {
956 if root.is_file() {
957 meta.base_dir = root.parent().map(|dir| dir.to_string_lossy().into_owned());
958 acc.add(ResourceType::Extensions, root, &meta, true);
959 return;
960 }
961 if !root.is_dir() {
962 return;
963 }
964 meta.base_dir = Some(root.to_string_lossy().into_owned());
965 if !collect_package_resources(root, acc, filter, &meta) && local {
966 for entry in crate::extensions::discovery::entries(root).unwrap_or_default() {
967 acc.add(ResourceType::Extensions, &entry, &meta, true);
968 }
969 }
970}
971
972pub fn package_resources(
976 root: &Path,
977 filter: Option<&FilteredPackage>,
978 local: bool,
979) -> ResolvedPaths {
980 let mut acc = ResolvedPaths::default();
981 collect_package(
982 &mut acc,
983 root,
984 filter,
985 local,
986 metadata("local", "user", "package", None),
987 );
988 acc
989}
990
991pub fn resolve(input: &ResolveInput<'_>) -> ResolvedPaths {
994 let mut acc = ResolvedPaths::default();
995 for package in &input.packages {
996 collect_package(
997 &mut acc,
998 &package.root,
999 package.filter.as_ref(),
1000 super::source::is_local(&package.source),
1001 metadata(&package.source, &package.scope, "package", None),
1002 );
1003 }
1004 for skill in &input.skills {
1005 let meta = metadata(&skill.source, &skill.scope, "package", None);
1006 acc.add(ResourceType::Skills, &skill.root, &meta, true);
1007 }
1008 for (index, kind) in ResourceType::ALL.into_iter().enumerate() {
1009 let project_meta = metadata("local", "project", "top-level", None);
1010 let user_meta = metadata("local", "user", "top-level", None);
1011 local_entries(
1012 input.project[index],
1013 kind,
1014 &mut acc,
1015 &project_meta,
1016 &input.project_dir,
1017 );
1018 local_entries(
1019 input.user[index],
1020 kind,
1021 &mut acc,
1022 &user_meta,
1023 &input.agent_dir,
1024 );
1025 }
1026 auto_discovered(input, &mut acc);
1027 let [user_ext, ..] = input.user;
1028 let [project_ext, ..] = input.project;
1029 for name in input.builtins {
1030 let path = PathBuf::from(format!("{BUILTIN_PREFIX}{name}"));
1031 let overrides: Vec<String> = project_ext
1032 .iter()
1033 .filter(|entry| is_override(entry))
1034 .cloned()
1035 .collect();
1036 let project = apply_autoload_disabled_patterns(
1037 std::slice::from_ref(&path),
1038 &overrides,
1039 &input.project_dir,
1040 )
1041 .into_iter()
1042 .next()
1043 .map(|(_, enabled)| enabled);
1044 let meta = metadata(
1045 "builtin",
1046 if project.is_some() { "project" } else { "user" },
1047 "top-level",
1048 None,
1049 );
1050 let enabled =
1051 project.unwrap_or_else(|| is_enabled_by_overrides(&path, user_ext, &input.agent_dir));
1052 acc.add(ResourceType::Extensions, &path, &meta, enabled);
1053 }
1054 let mut shadowed = Vec::new();
1055 for kind in ResourceType::ALL {
1056 let list = acc.of_mut(kind);
1057 list.sort_by_key(|resource| precedence(&resource.info));
1058 let mut seen = HashSet::new();
1059 list.retain(|resource| {
1060 let canonical = std::fs::canonicalize(&resource.info.path)
1061 .unwrap_or_else(|_| PathBuf::from(&resource.info.path));
1062 let first = seen.insert(canonical);
1063 if !first && kind == ResourceType::Extensions {
1064 shadowed.push(resource.info.clone());
1065 }
1066 first
1067 });
1068 }
1069 acc.shadowed.extend(shadowed);
1070 acc
1071}
1072
1073pub fn settings_lists(document: &serde_json::Map<String, serde_json::Value>) -> [Vec<String>; 4] {
1076 ResourceType::ALL.map(|kind| string_list(document, kind.key()))
1077}
1078
1079pub fn string_list(
1082 document: &serde_json::Map<String, serde_json::Value>,
1083 key: &str,
1084) -> Vec<String> {
1085 document
1086 .get(key)
1087 .and_then(serde_json::Value::as_array)
1088 .into_iter()
1089 .flatten()
1090 .filter_map(|entry| entry.as_str().map(str::to_owned))
1091 .collect()
1092}
1093
1094pub fn dedupe_packages(
1099 project: &[serde_json::Value],
1100 user: &[serde_json::Value],
1101 identity: impl Fn(&str, &str) -> String,
1102) -> Vec<(serde_json::Value, &'static str)> {
1103 let mut result: Vec<(serde_json::Value, &'static str)> = Vec::new();
1104 let mut seen: Vec<(String, usize)> = Vec::new();
1105 let entries = project
1106 .iter()
1107 .map(|entry| (entry, "project"))
1108 .chain(user.iter().map(|entry| (entry, "user")));
1109 for (entry, scope) in entries {
1110 let Some(source) = super::entry_source(entry) else {
1111 continue;
1112 };
1113 let id = identity(source, scope);
1114 match seen
1115 .iter()
1116 .find(|(seen_id, _)| *seen_id == id)
1117 .map(|(_, index)| *index)
1118 {
1119 None => {
1120 seen.push((id, result.len()));
1121 result.push((entry.clone(), scope));
1122 }
1123 Some(index) => {
1124 let (existing, existing_scope) = &result[index];
1125 if *existing_scope == "project" && scope == "user" {
1126 if is_delta(existing) {
1127 result.push((entry.clone(), scope));
1128 }
1129 } else if scope == "project" {
1130 result[index] = (entry.clone(), scope);
1131 }
1132 }
1133 }
1134 }
1135 result
1136}
1137
1138fn is_delta(entry: &serde_json::Value) -> bool {
1141 entry.is_object() && entry["autoload"] == serde_json::Value::Bool(false)
1142}
1143
1144pub fn locate_packages(
1150 project: &[serde_json::Value],
1151 user: &[serde_json::Value],
1152 identity: impl Fn(&str, &str) -> String,
1153) -> Vec<(serde_json::Value, &'static str, String, &'static str)> {
1154 let packages = dedupe_packages(project, user, &identity);
1155 packages
1156 .iter()
1157 .map(|(entry, scope)| {
1158 let source = super::entry_source(entry).unwrap_or_default();
1160 let base = (*scope == "project" && is_delta(entry))
1161 .then(|| {
1162 let id = identity(source, scope);
1163 packages
1164 .iter()
1165 .filter(|(_, other)| *other == "user")
1166 .filter_map(|(other, _)| super::entry_source(other))
1167 .find(|other| identity(other, "user") == id)
1168 })
1169 .flatten();
1170 let (located, located_scope) = base.map_or((source, *scope), |base| (base, "user"));
1171 (entry.clone(), *scope, located.to_owned(), located_scope)
1172 })
1173 .collect()
1174}
1175
1176pub fn package_inputs(
1181 project: &[serde_json::Value],
1182 user: &[serde_json::Value],
1183 base: impl Fn(&str) -> PathBuf,
1184 install_path: impl Fn(&super::source::Source, &str) -> PathBuf,
1185) -> Vec<PackageInput> {
1186 use super::source::parse;
1187 let identity = |source: &str, scope: &str| parse(source).identity(&base(scope));
1188 let mut inputs = Vec::new();
1189 for (entry, scope, located, located_scope) in locate_packages(project, user, identity) {
1190 let Some(source) = super::entry_source(&entry) else {
1191 continue;
1192 };
1193 if source.starts_with("chest:") {
1196 continue;
1197 }
1198 let filter: Option<FilteredPackage> = entry
1199 .is_object()
1200 .then(|| serde_json::from_value(entry.clone()).ok())
1201 .flatten();
1202 let root = install_path(&parse(&located), located_scope);
1203 if root.exists() {
1204 inputs.push(PackageInput {
1205 source: source.to_owned(),
1206 scope: scope.to_owned(),
1207 root,
1208 filter,
1209 });
1210 }
1211 }
1212 inputs
1213}