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
60pub fn is_extension_file(name: &str) -> bool {
63 name.ends_with(".ts") || name.ends_with(".js") || name.ends_with(".wasm")
64}
65
66#[derive(Clone, Debug, PartialEq, Eq)]
68pub struct Resolved {
69 pub info: SourceInfo,
71 pub enabled: bool,
73}
74
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
77pub struct ResolvedPaths {
78 pub extensions: Vec<Resolved>,
80 pub skills: Vec<Resolved>,
82 pub prompts: Vec<Resolved>,
84 pub themes: Vec<Resolved>,
86 pub shadowed: Vec<SourceInfo>,
90 pub problems: Vec<(String, String)>,
93 pub(crate) keep: Option<Keep>,
96 pub(crate) chest_files: Vec<(ResourceType, PathBuf, usize)>,
99}
100
101#[derive(Clone, Debug, Default, PartialEq, Eq)]
103pub(crate) struct Keep {
104 only: ProjectOnly,
105 agent_dir: PathBuf,
106 home: PathBuf,
107}
108
109impl ResolvedPaths {
110 pub fn of(&self, kind: ResourceType) -> &[Resolved] {
112 match kind {
113 ResourceType::Extensions => &self.extensions,
114 ResourceType::Skills => &self.skills,
115 ResourceType::Prompts => &self.prompts,
116 ResourceType::Themes => &self.themes,
117 }
118 }
119
120 fn of_mut(&mut self, kind: ResourceType) -> &mut Vec<Resolved> {
121 match kind {
122 ResourceType::Extensions => &mut self.extensions,
123 ResourceType::Skills => &mut self.skills,
124 ResourceType::Prompts => &mut self.prompts,
125 ResourceType::Themes => &mut self.themes,
126 }
127 }
128
129 fn add(&mut self, kind: ResourceType, path: &Path, metadata: &SourceInfo, enabled: bool) {
131 let path = path.to_string_lossy().into_owned();
132 if path.is_empty() {
133 return;
134 }
135 let info = SourceInfo {
136 path,
137 ..metadata.clone()
138 };
139 if let Some(keep) = &self.keep
142 && info.scope == "user"
143 && info.source != "builtin"
144 && !keep.only.keeps(&info, kind, &keep.agent_dir, &keep.home)
145 {
146 return;
147 }
148 let chest = info.source.starts_with("chest:");
151 let canonical = (chest || self.chest_files.iter().any(|(other, ..)| *other == kind))
152 .then(|| std::fs::canonicalize(&info.path).ok())
153 .flatten();
154 let same_file = canonical.as_ref().and_then(|file| {
155 let mut files = self.chest_files.iter();
156 let found = files.find(|(other, path, _)| *other == kind && path == file);
157 found.map(|(_, _, index)| *index)
158 });
159 let list = self.of_mut(kind);
160 let listed = list
161 .iter()
162 .position(|resource| resource.info.path == info.path)
163 .or(same_file);
164 if let Some(index) = listed {
165 let listed = &list[index].info;
166 let other = (&listed.source, &listed.scope) != (&info.source, &info.scope);
167 if kind == ResourceType::Extensions && other {
168 self.shadowed.push(info);
169 }
170 return;
171 }
172 let index = list.len();
173 list.push(Resolved { info, enabled });
174 if let (true, Some(file)) = (chest, canonical) {
175 self.chest_files.push((kind, file, index));
176 }
177 }
178
179 pub fn enabled(&self, kind: ResourceType) -> impl Iterator<Item = &SourceInfo> {
181 self.of(kind)
182 .iter()
183 .filter(|resource| resource.enabled)
184 .map(|resource| &resource.info)
185 }
186}
187
188#[derive(Clone, Debug, PartialEq)]
190pub struct PackageInput {
191 pub source: String,
193 pub scope: String,
195 pub root: PathBuf,
197 pub filter: Option<FilteredPackage>,
199}
200
201#[derive(Clone, Debug, Default)]
203pub struct ResolveInput<'a> {
204 pub cwd: PathBuf,
207 pub agent_dir: PathBuf,
209 pub project_dir: PathBuf,
211 pub home: PathBuf,
213 pub project_trusted: bool,
215 pub packages: Vec<PackageInput>,
217 pub skills: Vec<PackageInput>,
220 pub user: [&'a [String]; 4],
222 pub project: [&'a [String]; 4],
224 pub builtins: &'a [&'a str],
226 pub project_only: Option<ProjectOnly>,
229}
230
231pub const BUILTIN_PREFIX: &str = "builtin:";
233
234fn to_posix(path: &str) -> String {
235 path.replace('\\', "/")
236}
237
238fn base_name(path: &Path) -> String {
239 path.file_name()
240 .map(|name| name.to_string_lossy().into_owned())
241 .unwrap_or_default()
242}
243
244fn matches_any_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
247 let rel = relative(base, file);
248 let name = base_name(file);
249 let whole = to_posix(&file.to_string_lossy());
250 let parent = (name == "SKILL.md").then(|| file.parent()).flatten();
251 patterns.iter().any(|pattern| {
252 let pattern = to_posix(pattern);
253 if pattern.starts_with("./") {
256 return false;
257 }
258 if glob::matches(&pattern, &rel)
259 || glob::matches(&pattern, &name)
260 || glob::matches(&pattern, &whole)
261 {
262 return true;
263 }
264 parent.is_some_and(|parent| {
265 glob::matches(&pattern, &relative(base, parent))
266 || glob::matches(&pattern, &base_name(parent))
267 || glob::matches(&pattern, &to_posix(&parent.to_string_lossy()))
268 })
269 })
270}
271
272fn matches_any_exact_pattern(file: &Path, patterns: &[String], base: &Path) -> bool {
275 if patterns.is_empty() {
276 return false;
277 }
278 let rel = relative(base, file);
279 let whole = to_posix(&file.to_string_lossy());
280 let parent = (base_name(file) == "SKILL.md")
281 .then(|| file.parent())
282 .flatten();
283 patterns.iter().any(|pattern| {
284 let pattern = pattern
285 .strip_prefix("./")
286 .or_else(|| pattern.strip_prefix(".\\"))
287 .unwrap_or(pattern);
288 let pattern = to_posix(pattern);
289 if pattern == rel || pattern == whole {
290 return true;
291 }
292 parent.is_some_and(|parent| {
293 pattern == relative(base, parent) || pattern == to_posix(&parent.to_string_lossy())
294 })
295 })
296}
297
298fn is_override(entry: &str) -> bool {
299 entry.starts_with(['!', '+', '-'])
300}
301
302fn is_pattern(entry: &str) -> bool {
303 is_override(entry) || entry.contains(['*', '?'])
304}
305
306fn has_glob(entry: &str) -> bool {
307 entry.contains(['*', '?'])
308}
309
310fn stripped<'a>(entries: impl Iterator<Item = &'a String>, sign: char) -> Vec<String> {
311 entries
312 .filter_map(|entry| entry.strip_prefix(sign).map(str::to_owned))
313 .collect()
314}
315
316pub fn is_enabled_by_overrides(file: &Path, entries: &[String], base: &Path) -> bool {
319 let excludes = stripped(entries.iter(), '!');
320 let force_includes = stripped(entries.iter(), '+');
321 let force_excludes = stripped(entries.iter(), '-');
322 let mut enabled = true;
323 if !excludes.is_empty() && matches_any_pattern(file, &excludes, base) {
324 enabled = false;
325 }
326 if matches_any_exact_pattern(file, &force_includes, base) {
327 enabled = true;
328 }
329 if matches_any_exact_pattern(file, &force_excludes, base) {
330 enabled = false;
331 }
332 enabled
333}
334
335pub fn apply_patterns(all: &[PathBuf], patterns: &[String], base: &Path) -> HashSet<PathBuf> {
338 let includes: Vec<String> = patterns
339 .iter()
340 .filter(|pattern| !is_override(pattern))
341 .cloned()
342 .collect();
343 let excludes = stripped(patterns.iter(), '!');
344 let force_includes = stripped(patterns.iter(), '+');
345 let force_excludes = stripped(patterns.iter(), '-');
346 let mut result: Vec<PathBuf> = if includes.is_empty() {
347 all.to_vec()
348 } else {
349 all.iter()
350 .filter(|file| matches_any_pattern(file, &includes, base))
351 .cloned()
352 .collect()
353 };
354 if !excludes.is_empty() {
355 result.retain(|file| !matches_any_pattern(file, &excludes, base));
356 }
357 for file in all {
358 if !result.contains(file) && matches_any_exact_pattern(file, &force_includes, base) {
359 result.push(file.clone());
360 }
361 }
362 if !force_excludes.is_empty() {
363 result.retain(|file| !matches_any_exact_pattern(file, &force_excludes, base));
364 }
365 result.into_iter().collect()
366}
367
368fn apply_autoload_disabled_patterns(
371 all: &[PathBuf],
372 patterns: &[String],
373 base: &Path,
374) -> Vec<(PathBuf, bool)> {
375 let mut result: Vec<(PathBuf, bool)> = Vec::new();
376 for pattern in patterns {
377 let target = if is_override(pattern) {
378 pattern[1..].to_owned()
379 } else {
380 pattern.clone()
381 };
382 let enabled = !pattern.starts_with(['-', '!']);
383 let exact = pattern.starts_with(['+', '-']);
384 for file in all {
385 let found = if exact {
386 matches_any_exact_pattern(file, std::slice::from_ref(&target), base)
387 } else {
388 matches_any_pattern(file, std::slice::from_ref(&target), base)
389 };
390 if found {
391 match result.iter_mut().find(|(path, _)| path == file) {
392 Some(entry) => entry.1 = enabled,
393 None => result.push((file.clone(), enabled)),
394 }
395 }
396 }
397 }
398 result
399}
400
401#[derive(Clone, Debug, Default)]
404struct Ignore {
405 rules: Vec<(String, bool)>,
406}
407
408impl Ignore {
409 fn add_dir(&mut self, dir: &Path, root: &Path) {
412 let rel = relative(root, dir);
413 let prefix = if rel.is_empty() {
414 String::new()
415 } else {
416 format!("{rel}/")
417 };
418 for name in [".gitignore", ".ignore", ".fdignore"] {
419 let Ok(text) = std::fs::read_to_string(dir.join(name)) else {
420 continue;
421 };
422 for line in text.lines() {
423 let trimmed = line.trim();
424 if trimmed.is_empty() || (trimmed.starts_with('#') && !trimmed.starts_with("\\#")) {
425 continue;
426 }
427 let (mut pattern, negated) = match line.strip_prefix('!') {
428 Some(rest) => (rest, true),
429 None => (
430 line.strip_prefix('\\')
431 .filter(|rest| rest.starts_with('!'))
432 .unwrap_or(line),
433 false,
434 ),
435 };
436 pattern = pattern.strip_prefix('/').unwrap_or(pattern);
437 self.rules
438 .push((format!("{prefix}{}", pattern.trim_end()), negated));
439 }
440 }
441 }
442
443 fn ignores(&self, path: &str) -> bool {
446 let dir = path.ends_with('/');
447 let path = path.trim_end_matches('/');
448 let mut ignored = false;
449 for (pattern, negated) in &self.rules {
450 if rule_matches(pattern, path, dir) {
451 ignored = !negated;
452 }
453 }
454 ignored
455 }
456}
457
458fn rule_matches(pattern: &str, path: &str, dir: bool) -> bool {
462 let (pattern, dir_only) = match pattern.strip_suffix('/') {
463 Some(pattern) => (pattern, true),
464 None => (pattern, false),
465 };
466 if dir_only && !dir {
467 return false;
468 }
469 if pattern.contains('/') {
470 glob::matches(pattern, path)
471 } else {
472 glob::matches(pattern, path.rsplit('/').next().unwrap_or(path))
473 }
474}
475
476struct Entry {
477 path: PathBuf,
478 is_dir: bool,
479 is_file: bool,
480 name: String,
481}
482
483fn read_dir(dir: &Path) -> Vec<Entry> {
485 let Ok(entries) = std::fs::read_dir(dir) else {
486 return Vec::new();
487 };
488 let mut out: Vec<Entry> = entries
489 .flatten()
490 .filter_map(|entry| {
491 let path = entry.path();
492 let metadata = std::fs::metadata(&path).ok()?;
493 Some(Entry {
494 name: entry.file_name().to_string_lossy().into_owned(),
495 is_dir: metadata.is_dir(),
496 is_file: metadata.is_file(),
497 path,
498 })
499 })
500 .collect();
501 out.sort_by(|a, b| a.name.cmp(&b.name));
502 out
503}
504
505fn collect_files(dir: &Path, kind: ResourceType, ignore: &mut Ignore, root: &Path) -> Vec<PathBuf> {
508 let mut files = Vec::new();
509 if !dir.exists() {
510 return files;
511 }
512 ignore.add_dir(dir, root);
513 for entry in read_dir(dir) {
514 if entry.name.starts_with('.') || entry.name == "node_modules" {
515 continue;
516 }
517 let rel = relative(root, &entry.path);
518 let probe = if entry.is_dir { format!("{rel}/") } else { rel };
519 if ignore.ignores(&probe) {
520 continue;
521 }
522 if entry.is_dir {
523 files.extend(collect_files(&entry.path, kind, ignore, root));
524 } else if entry.is_file && kind.matches_file(&entry.name) {
525 files.push(entry.path);
526 }
527 }
528 files
529}
530
531#[derive(Clone, Copy, PartialEq, Eq)]
534enum SkillMode {
535 Pi,
536 Agents,
537}
538
539fn collect_skill_entries(
542 dir: &Path,
543 mode: SkillMode,
544 ignore: &mut Ignore,
545 root: &Path,
546) -> Vec<PathBuf> {
547 let mut entries = Vec::new();
548 if !dir.exists() {
549 return entries;
550 }
551 ignore.add_dir(dir, root);
552 let listing = read_dir(dir);
553 if let Some(skill) = listing.iter().find(|entry| entry.name == "SKILL.md")
554 && skill.is_file
555 && !ignore.ignores(&relative(root, &skill.path))
556 {
557 entries.push(skill.path.clone());
558 return entries;
559 }
560 for entry in listing {
561 if entry.name.starts_with('.') || entry.name == "node_modules" {
562 continue;
563 }
564 let rel = relative(root, &entry.path);
565 let markdown = entry.is_file
566 && entry.name.ends_with(".md")
567 && !ignore.ignores(&rel)
568 && ((mode == SkillMode::Pi && dir == root)
569 || (mode == SkillMode::Agents && dir != root));
570 if markdown {
571 entries.push(entry.path);
572 continue;
573 }
574 if !entry.is_dir || ignore.ignores(&format!("{rel}/")) {
575 continue;
576 }
577 entries.extend(collect_skill_entries(&entry.path, mode, ignore, root));
578 }
579 entries
580}
581
582fn collect_top_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
585 if !dir.exists() {
586 return Vec::new();
587 }
588 let mut ignore = Ignore::default();
589 ignore.add_dir(dir, dir);
590 read_dir(dir)
591 .into_iter()
592 .filter(|entry| !entry.name.starts_with('.') && entry.name != "node_modules")
593 .filter(|entry| !ignore.ignores(&relative(dir, &entry.path)))
594 .filter(|entry| entry.is_file && kind.matches_file(&entry.name))
595 .map(|entry| entry.path)
596 .collect()
597}
598
599fn collect_auto_extension_entries(dir: &Path) -> Vec<PathBuf> {
602 if !dir.exists() {
603 return Vec::new();
604 }
605 if let Some(entries) = crate::extensions::discovery::entries(dir) {
606 return entries;
607 }
608 let mut ignore = Ignore::default();
609 ignore.add_dir(dir, dir);
610 let mut entries = Vec::new();
611 for entry in read_dir(dir) {
612 if entry.name.starts_with('.') || entry.name == "node_modules" {
613 continue;
614 }
615 let rel = relative(dir, &entry.path);
616 let probe = if entry.is_dir { format!("{rel}/") } else { rel };
617 if ignore.ignores(&probe) {
618 continue;
619 }
620 if entry.is_file && is_extension_file(&entry.name) {
621 entries.push(entry.path);
622 } else if entry.is_dir {
623 entries.extend(crate::extensions::discovery::entries(&entry.path).unwrap_or_default());
624 }
625 }
626 entries
627}
628
629fn collect_resource_files(dir: &Path, kind: ResourceType) -> Vec<PathBuf> {
631 match kind {
632 ResourceType::Skills => {
633 collect_skill_entries(dir, SkillMode::Pi, &mut Ignore::default(), dir)
634 }
635 ResourceType::Extensions => collect_auto_extension_entries(dir),
636 _ => collect_files(dir, kind, &mut Ignore::default(), dir),
637 }
638}
639
640fn collect_files_from_paths(paths: &[PathBuf], kind: ResourceType) -> Vec<PathBuf> {
642 let mut files = Vec::new();
643 for path in paths {
644 let Ok(metadata) = std::fs::metadata(path) else {
645 continue;
646 };
647 if metadata.is_file() {
648 files.push(path.clone());
649 } else if metadata.is_dir() {
650 files.extend(collect_resource_files(path, kind));
651 }
652 }
653 files
654}
655
656#[derive(Default)]
661pub(crate) struct Manifest {
662 lists: [Option<Vec<String>>; 4],
663}
664
665impl Manifest {
666 pub(crate) fn read(root: &Path) -> (Option<Manifest>, Option<ChestError>) {
670 let (meta, problem) = read_package_meta(root);
671 let lists = ResourceType::ALL.map(|kind| meta.resources.get(kind.key()).cloned());
672 (meta.manifest.then_some(Manifest { lists }), problem)
673 }
674
675 pub(crate) fn get(&self, kind: ResourceType) -> Option<&Vec<String>> {
677 self.lists[kind as usize].as_ref()
678 }
679}
680
681fn expand_package_glob(pattern: &str, root: &Path) -> Vec<PathBuf> {
684 fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) {
685 for entry in read_dir(dir) {
686 let rel = relative(root, &entry.path);
687 out.push(rel);
688 if entry.is_dir && entry.name != "node_modules" && !entry.name.starts_with('.') {
689 walk(root, &entry.path, out);
690 }
691 }
692 }
693 let mut all = Vec::new();
694 walk(root, root, &mut all);
695 let mut found: Vec<PathBuf> = all
696 .into_iter()
697 .filter(|rel| rel.split('/').all(|segment| !segment.starts_with('.')))
698 .filter(|rel| glob::matches(pattern, rel))
699 .map(|rel| root.join(rel))
700 .collect();
701 found.sort();
702 found
703}
704
705fn manifest_files(entries: &[String], root: &Path, kind: ResourceType) -> Vec<PathBuf> {
707 let resolved: Vec<PathBuf> = entries
708 .iter()
709 .filter(|entry| !is_override(entry))
710 .flat_map(|entry| {
711 if has_glob(entry) {
712 expand_package_glob(entry, root)
713 } else {
714 vec![resolve_lexically(root, Path::new(entry))]
715 }
716 })
717 .collect();
718 collect_files_from_paths(&resolved, kind)
719}
720
721fn metadata(source: &str, scope: &str, origin: &str, base_dir: Option<&Path>) -> SourceInfo {
722 SourceInfo {
723 path: String::new(),
724 source: source.to_owned(),
725 scope: scope.to_owned(),
726 origin: origin.to_owned(),
727 base_dir: base_dir.map(|dir| dir.to_string_lossy().into_owned()),
728 }
729}
730
731fn collect_package_resources(
733 root: &Path,
734 acc: &mut ResolvedPaths,
735 filter: Option<&FilteredPackage>,
736 meta: &SourceInfo,
737) -> bool {
738 let (manifest, problem) = Manifest::read(root);
739 if let Some(err) = problem {
740 let folder = root.to_string_lossy().into_owned();
741 acc.problems.push((folder, err.to_string()));
742 }
743 if let Some(filter) = filter {
744 for kind in ResourceType::ALL {
745 let patterns = match kind {
746 ResourceType::Extensions => &filter.extensions,
747 ResourceType::Skills => &filter.skills,
748 ResourceType::Prompts => &filter.prompts,
749 ResourceType::Themes => &filter.themes,
750 };
751 if filter.autoload == Some(false) {
752 let patterns = patterns.clone().unwrap_or_default();
753 if patterns.is_empty() {
754 continue;
755 }
756 let all = package_files(root, manifest.as_ref(), kind);
757 for (file, enabled) in apply_autoload_disabled_patterns(&all, &patterns, root) {
758 acc.add(kind, &file, meta, enabled);
759 }
760 } else if let Some(patterns) = patterns {
761 let all = package_files(root, manifest.as_ref(), kind);
762 let enabled = if patterns.is_empty() {
763 HashSet::new()
764 } else {
765 apply_patterns(&all, patterns, root)
766 };
767 for file in &all {
768 acc.add(kind, file, meta, enabled.contains(file));
769 }
770 } else {
771 default_resources(root, manifest.as_ref(), kind, acc, meta);
772 }
773 }
774 return true;
775 }
776 if let Some(manifest) = &manifest {
777 for kind in ResourceType::ALL {
778 if let Some(entries) = manifest.get(kind) {
779 manifest_entries(entries, root, kind, acc, meta);
780 }
781 }
782 return true;
783 }
784 let mut any = false;
785 for kind in ResourceType::ALL {
786 let dir = root.join(kind.key());
787 if dir.exists() {
788 for file in collect_resource_files(&dir, kind) {
789 acc.add(kind, &file, meta, true);
790 }
791 any = true;
792 }
793 }
794 any
795}
796
797fn default_resources(
799 root: &Path,
800 manifest: Option<&Manifest>,
801 kind: ResourceType,
802 acc: &mut ResolvedPaths,
803 meta: &SourceInfo,
804) {
805 if let Some(entries) = manifest.and_then(|manifest| manifest.get(kind)) {
806 manifest_entries(entries, root, kind, acc, meta);
807 return;
808 }
809 let dir = root.join(kind.key());
810 if dir.exists() {
811 for file in collect_resource_files(&dir, kind) {
812 acc.add(kind, &file, meta, true);
813 }
814 }
815}
816
817fn package_files(root: &Path, manifest: Option<&Manifest>, kind: ResourceType) -> Vec<PathBuf> {
820 let entries = manifest.and_then(|manifest| manifest.get(kind));
821 selected_files(root, entries.map(Vec::as_slice), kind.key(), kind)
822}
823
824pub(crate) fn selected_files(
828 root: &Path,
829 entries: Option<&[String]>,
830 folder: &str,
831 kind: ResourceType,
832) -> Vec<PathBuf> {
833 if let Some(entries) = entries.filter(|entries| !entries.is_empty()) {
834 let all = manifest_files(entries, root, kind);
835 let patterns: Vec<String> = entries
836 .iter()
837 .filter(|entry| is_override(entry))
838 .cloned()
839 .collect();
840 if patterns.is_empty() {
841 return all;
842 }
843 let enabled = apply_patterns(&all, &patterns, root);
844 return all
845 .into_iter()
846 .filter(|file| enabled.contains(file))
847 .collect();
848 }
849 let dir = root.join(folder);
850 if dir.exists() {
851 collect_resource_files(&dir, kind)
852 } else {
853 Vec::new()
854 }
855}
856
857fn manifest_entries(
859 entries: &[String],
860 root: &Path,
861 kind: ResourceType,
862 acc: &mut ResolvedPaths,
863 meta: &SourceInfo,
864) {
865 let all = manifest_files(entries, root, kind);
866 let patterns: Vec<String> = entries
867 .iter()
868 .filter(|entry| is_override(entry))
869 .cloned()
870 .collect();
871 let enabled = apply_patterns(&all, &patterns, root);
872 for file in &all {
873 if enabled.contains(file) {
874 acc.add(kind, file, meta, true);
875 }
876 }
877}
878
879fn local_entries(
882 entries: &[String],
883 kind: ResourceType,
884 acc: &mut ResolvedPaths,
885 meta: &SourceInfo,
886 base: &Path,
887) {
888 if entries.is_empty() {
889 return;
890 }
891 let (patterns, plain): (Vec<String>, Vec<String>) =
892 entries.iter().cloned().partition(|entry| is_pattern(entry));
893 let resolved: Vec<PathBuf> = plain
894 .iter()
895 .map(|entry| resolve_lexically(base, Path::new(&crate::tools::path::expand(entry.trim()))))
896 .collect();
897 let all = collect_files_from_paths(&resolved, kind);
898 let enabled = apply_patterns(&all, &patterns, base);
899 for file in &all {
900 acc.add(kind, file, meta, enabled.contains(file));
901 }
902}
903
904fn ancestor_agents_skill_dirs(start: &Path) -> Vec<PathBuf> {
907 let repo = start
908 .ancestors()
909 .find(|dir| dir.join(".git").exists())
910 .map(Path::to_path_buf);
911 let mut dirs = Vec::new();
912 for dir in start.ancestors() {
913 dirs.push(dir.join(".agents").join("skills"));
914 if repo.as_deref() == Some(dir) {
915 break;
916 }
917 }
918 dirs
919}
920
921fn auto_discovered(input: &ResolveInput<'_>, acc: &mut ResolvedPaths) {
925 let user_agents = input.home.join(".agents").join("skills");
926 let project_agents: Vec<PathBuf> = if input.project_trusted {
927 ancestor_agents_skill_dirs(&input.cwd)
928 .into_iter()
929 .filter(|dir| *dir != user_agents)
930 .collect()
931 } else {
932 Vec::new()
933 };
934 let scopes = [
935 (
936 "project",
937 &input.project_dir,
938 input.project,
939 input.project_trusted,
940 project_agents,
941 ),
942 (
943 "user",
944 &input.agent_dir,
945 input.user,
946 true,
947 vec![user_agents],
948 ),
949 ];
950 for (scope, base, overrides, own, agents) in scopes {
951 let mut add = |kind: ResourceType, paths: Vec<PathBuf>, base: &Path| {
952 let meta = metadata("auto", scope, "top-level", Some(base));
953 for path in paths {
954 let enabled = is_enabled_by_overrides(&path, overrides[kind as usize], base);
955 acc.add(kind, &path, &meta, enabled);
956 }
957 };
958 for kind in ResourceType::ALL {
959 let dir = base.join(kind.key());
960 if own {
961 let paths = match kind {
962 ResourceType::Extensions => collect_auto_extension_entries(&dir),
963 ResourceType::Skills => {
964 collect_skill_entries(&dir, SkillMode::Pi, &mut Ignore::default(), &dir)
965 }
966 _ => collect_top_files(&dir, kind),
967 };
968 add(kind, paths, base);
969 }
970 if kind == ResourceType::Skills {
971 for dir in &agents {
972 let paths =
973 collect_skill_entries(dir, SkillMode::Agents, &mut Ignore::default(), dir);
974 add(kind, paths, dir.parent().unwrap_or(dir));
975 }
976 }
977 }
978 }
979}
980
981fn precedence(info: &SourceInfo) -> u8 {
985 if info.source == "builtin" {
986 return 5;
987 }
988 if info.origin == "package" && !info.source.starts_with("chest:") {
989 return 4;
990 }
991 let scope = if info.scope == "project" { 0 } else { 2 };
992 scope + u8::from(info.source != "local")
993}
994
995fn collect_package(
999 acc: &mut ResolvedPaths,
1000 root: &Path,
1001 filter: Option<&FilteredPackage>,
1002 local: bool,
1003 mut meta: SourceInfo,
1004) {
1005 if root.is_file() {
1006 meta.base_dir = root.parent().map(|dir| dir.to_string_lossy().into_owned());
1007 acc.add(ResourceType::Extensions, root, &meta, true);
1008 return;
1009 }
1010 if !root.is_dir() {
1011 return;
1012 }
1013 meta.base_dir = Some(root.to_string_lossy().into_owned());
1014 if !collect_package_resources(root, acc, filter, &meta) && local {
1015 for entry in crate::extensions::discovery::entries(root).unwrap_or_default() {
1016 acc.add(ResourceType::Extensions, &entry, &meta, true);
1017 }
1018 }
1019}
1020
1021pub fn package_resources(
1025 root: &Path,
1026 filter: Option<&FilteredPackage>,
1027 local: bool,
1028) -> ResolvedPaths {
1029 let mut acc = ResolvedPaths::default();
1030 collect_package(
1031 &mut acc,
1032 root,
1033 filter,
1034 local,
1035 metadata("local", "user", "package", None),
1036 );
1037 acc
1038}
1039
1040pub fn resolve(input: &ResolveInput<'_>) -> ResolvedPaths {
1043 let mut acc = ResolvedPaths {
1044 keep: input.project_only.clone().map(|only| Keep {
1045 only,
1046 agent_dir: input.agent_dir.clone(),
1047 home: input.home.clone(),
1048 }),
1049 ..ResolvedPaths::default()
1050 };
1051 for project in [true, false] {
1054 let ours = |input: &&PackageInput| (input.scope == "project") == project;
1055 for package in input.packages.iter().filter(ours) {
1056 collect_package(
1057 &mut acc,
1058 &package.root,
1059 package.filter.as_ref(),
1060 super::source::is_local(&package.source),
1061 metadata(&package.source, &package.scope, "package", None),
1062 );
1063 }
1064 for skill in input.skills.iter().filter(ours) {
1065 let meta = metadata(&skill.source, &skill.scope, "package", None);
1066 let file = skill.root.join("SKILL.md");
1068 let path = if file.is_file() { &file } else { &skill.root };
1069 acc.add(ResourceType::Skills, path, &meta, true);
1070 }
1071 }
1072 for (index, kind) in ResourceType::ALL.into_iter().enumerate() {
1073 let project_meta = metadata("local", "project", "top-level", None);
1074 let user_meta = metadata("local", "user", "top-level", None);
1075 local_entries(
1076 input.project[index],
1077 kind,
1078 &mut acc,
1079 &project_meta,
1080 &input.project_dir,
1081 );
1082 local_entries(
1083 input.user[index],
1084 kind,
1085 &mut acc,
1086 &user_meta,
1087 &input.agent_dir,
1088 );
1089 }
1090 auto_discovered(input, &mut acc);
1091 let [user_ext, ..] = input.user;
1092 let [project_ext, ..] = input.project;
1093 for name in input.builtins {
1094 let path = PathBuf::from(format!("{BUILTIN_PREFIX}{name}"));
1095 let overrides: Vec<String> = project_ext
1096 .iter()
1097 .filter(|entry| is_override(entry))
1098 .cloned()
1099 .collect();
1100 let project = apply_autoload_disabled_patterns(
1101 std::slice::from_ref(&path),
1102 &overrides,
1103 &input.project_dir,
1104 )
1105 .into_iter()
1106 .next()
1107 .map(|(_, enabled)| enabled);
1108 let meta = metadata(
1109 "builtin",
1110 if project.is_some() { "project" } else { "user" },
1111 "top-level",
1112 None,
1113 );
1114 let enabled =
1115 project.unwrap_or_else(|| is_enabled_by_overrides(&path, user_ext, &input.agent_dir));
1116 acc.add(ResourceType::Extensions, &path, &meta, enabled);
1117 }
1118 let mut shadowed = Vec::new();
1119 for kind in ResourceType::ALL {
1120 let list = acc.of_mut(kind);
1121 list.sort_by_key(|resource| precedence(&resource.info));
1122 let mut seen = HashSet::new();
1123 list.retain(|resource| {
1124 let canonical = std::fs::canonicalize(&resource.info.path)
1125 .unwrap_or_else(|_| PathBuf::from(&resource.info.path));
1126 let first = seen.insert(canonical);
1127 if !first && kind == ResourceType::Extensions {
1128 shadowed.push(resource.info.clone());
1129 }
1130 first
1131 });
1132 }
1133 acc.shadowed.extend(shadowed);
1134 acc.keep = None;
1135 acc.chest_files.clear();
1136 acc
1137}
1138
1139#[derive(Clone, Debug, Default, PartialEq, Eq)]
1142pub struct ProjectOnly {
1143 pub packages: Vec<String>,
1145 pub kinds: [Vec<String>; 4],
1148 pub mcp_servers: Vec<String>,
1150}
1151
1152impl ProjectOnly {
1153 pub fn from_settings(document: &serde_json::Map<String, serde_json::Value>) -> ProjectOnly {
1155 let empty = serde_json::Map::new();
1156 let object = document
1157 .get("projectOnly")
1158 .and_then(serde_json::Value::as_object)
1159 .unwrap_or(&empty);
1160 let list = |key: &str| string_list(object, key);
1161 let patterns = |kind: ResourceType| {
1163 let list = list(kind.key()).into_iter();
1164 list.map(|pattern| pattern.trim_end_matches('/').to_owned())
1165 .collect()
1166 };
1167 ProjectOnly {
1168 packages: list("packages"),
1169 kinds: ResourceType::ALL.map(patterns),
1170 mcp_servers: list("mcpServers"),
1171 }
1172 }
1173
1174 pub fn keeps(
1178 &self,
1179 info: &SourceInfo,
1180 kind: ResourceType,
1181 agent_dir: &Path,
1182 home: &Path,
1183 ) -> bool {
1184 let package = info.origin == "package" && self.keeps_package(&info.source, agent_dir);
1185 package || self.keeps_path(Path::new(&info.path), kind, agent_dir, home)
1186 }
1187
1188 fn keeps_path(&self, path: &Path, kind: ResourceType, agent_dir: &Path, home: &Path) -> bool {
1191 let patterns: Vec<String> = self.kinds[kind as usize]
1192 .iter()
1193 .map(|pattern| match pattern.strip_prefix("~/") {
1194 Some(rest) => home.join(rest).to_string_lossy().into_owned(),
1195 None if pattern == "~" => home.to_string_lossy().into_owned(),
1196 None => pattern.clone(),
1197 })
1198 .collect();
1199 matches_any_pattern(path, &patterns, agent_dir)
1200 }
1201
1202 pub fn keeps_part(&self, input: &PackageInput, agent_dir: &Path, home: &Path) -> bool {
1206 let local = super::source::is_local(&input.source);
1207 let resources = package_resources(&input.root, input.filter.as_ref(), local);
1208 ResourceType::ALL.into_iter().any(|kind| {
1209 let mut files = resources.enabled(kind);
1210 files.any(|info| self.keeps_path(Path::new(&info.path), kind, agent_dir, home))
1211 })
1212 }
1213
1214 pub fn needs_package(&self, source: &str, agent_dir: &Path) -> bool {
1218 self.kinds.iter().any(|list| !list.is_empty()) || self.keeps_package(source, agent_dir)
1219 }
1220
1221 pub fn keeps_package(&self, source: &str, agent_dir: &Path) -> bool {
1223 self.packages
1224 .iter()
1225 .any(|entry| entry == "*" || same_package(entry, source, agent_dir))
1226 }
1227
1228 pub fn needs_chest(&self) -> bool {
1231 self.kinds.iter().any(|list| !list.is_empty())
1232 || self
1233 .packages
1234 .iter()
1235 .any(|entry| entry == "*" || entry.starts_with("chest:"))
1236 }
1237}
1238
1239fn same_package(entry: &str, source: &str, agent_dir: &Path) -> bool {
1243 use super::source::parse;
1244 match (entry.strip_prefix("chest:"), source.strip_prefix("chest:")) {
1245 (Some(entry), Some(source)) => entry == source,
1246 (None, None) => parse(entry).identity(agent_dir) == parse(source).identity(agent_dir),
1247 _ => false,
1248 }
1249}
1250
1251pub fn settings_lists(document: &serde_json::Map<String, serde_json::Value>) -> [Vec<String>; 4] {
1254 ResourceType::ALL.map(|kind| string_list(document, kind.key()))
1255}
1256
1257pub fn string_list(
1260 document: &serde_json::Map<String, serde_json::Value>,
1261 key: &str,
1262) -> Vec<String> {
1263 document
1264 .get(key)
1265 .and_then(serde_json::Value::as_array)
1266 .into_iter()
1267 .flatten()
1268 .filter_map(|entry| entry.as_str().map(str::to_owned))
1269 .collect()
1270}
1271
1272pub fn dedupe_packages(
1277 project: &[serde_json::Value],
1278 user: &[serde_json::Value],
1279 identity: impl Fn(&str, &str) -> String,
1280) -> Vec<(serde_json::Value, &'static str)> {
1281 let mut result: Vec<(serde_json::Value, &'static str)> = Vec::new();
1282 let mut seen: Vec<(String, usize)> = Vec::new();
1283 let entries = project
1284 .iter()
1285 .map(|entry| (entry, "project"))
1286 .chain(user.iter().map(|entry| (entry, "user")));
1287 for (entry, scope) in entries {
1288 let Some(source) = super::entry_source(entry) else {
1289 continue;
1290 };
1291 let id = identity(source, scope);
1292 match seen
1293 .iter()
1294 .find(|(seen_id, _)| *seen_id == id)
1295 .map(|(_, index)| *index)
1296 {
1297 None => {
1298 seen.push((id, result.len()));
1299 result.push((entry.clone(), scope));
1300 }
1301 Some(index) => {
1302 let (existing, existing_scope) = &result[index];
1303 if *existing_scope == "project" && scope == "user" {
1304 if is_delta(existing) {
1305 result.push((entry.clone(), scope));
1306 }
1307 } else if scope == "project" {
1308 result[index] = (entry.clone(), scope);
1309 }
1310 }
1311 }
1312 }
1313 result
1314}
1315
1316fn is_delta(entry: &serde_json::Value) -> bool {
1319 entry.is_object() && entry["autoload"] == serde_json::Value::Bool(false)
1320}
1321
1322pub fn locate_packages(
1328 project: &[serde_json::Value],
1329 user: &[serde_json::Value],
1330 identity: impl Fn(&str, &str) -> String,
1331) -> Vec<(serde_json::Value, &'static str, String, &'static str)> {
1332 let packages = dedupe_packages(project, user, &identity);
1333 packages
1334 .iter()
1335 .map(|(entry, scope)| {
1336 let source = super::entry_source(entry).unwrap_or_default();
1338 let base = (*scope == "project" && is_delta(entry))
1339 .then(|| {
1340 let id = identity(source, scope);
1341 packages
1342 .iter()
1343 .filter(|(_, other)| *other == "user")
1344 .filter_map(|(other, _)| super::entry_source(other))
1345 .find(|other| identity(other, "user") == id)
1346 })
1347 .flatten();
1348 let (located, located_scope) = base.map_or((source, *scope), |base| (base, "user"));
1349 (entry.clone(), *scope, located.to_owned(), located_scope)
1350 })
1351 .collect()
1352}
1353
1354pub fn package_inputs(
1359 project: &[serde_json::Value],
1360 user: &[serde_json::Value],
1361 base: impl Fn(&str) -> PathBuf,
1362 install_path: impl Fn(&super::source::Source, &str) -> PathBuf,
1363) -> Vec<PackageInput> {
1364 use super::source::parse;
1365 let identity = |source: &str, scope: &str| parse(source).identity(&base(scope));
1366 let mut inputs = Vec::new();
1367 for (entry, scope, located, located_scope) in locate_packages(project, user, identity) {
1368 let Some(source) = super::entry_source(&entry) else {
1369 continue;
1370 };
1371 if source.starts_with("chest:") {
1374 continue;
1375 }
1376 let filter: Option<FilteredPackage> = entry
1377 .is_object()
1378 .then(|| serde_json::from_value(entry.clone()).ok())
1379 .flatten();
1380 let root = install_path(&parse(&located), located_scope);
1381 if root.exists() {
1382 inputs.push(PackageInput {
1383 source: source.to_owned(),
1384 scope: scope.to_owned(),
1385 root,
1386 filter,
1387 });
1388 }
1389 }
1390 inputs
1391}