Skip to main content

yapi_core/packages/
resolve.rs

1//! Every resource that settings, packages and the agent and project
2//! directories provide, each enabled or not, in load precedence order.
3//!
4//! Port of `DefaultPackageManager.resolve` and its pattern rules in
5//! `core/package-manager.ts` in pi `v1.0.0`. yapi also loads `.wasm` native
6//! extensions wherever pi loads `.ts` and `.js` ones.
7
8use 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/// A kind of resource.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
21pub enum ResourceType {
22    /// Extension entry files.
23    Extensions,
24    /// Skill files.
25    Skills,
26    /// Prompt template files.
27    Prompts,
28    /// Theme files.
29    Themes,
30}
31
32impl ResourceType {
33    /// Every kind, in pi's order.
34    pub const ALL: [ResourceType; 4] = [
35        ResourceType::Extensions,
36        ResourceType::Skills,
37        ResourceType::Prompts,
38        ResourceType::Themes,
39    ];
40
41    /// The settings and manifest key.
42    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
60/// pi's extension files, and yapi's native extensions.
61fn is_extension_file(name: &str) -> bool {
62    name.ends_with(".ts") || name.ends_with(".js") || name.ends_with(".wasm")
63}
64
65/// A resource and whether it loads.
66#[derive(Clone, Debug, PartialEq, Eq)]
67pub struct Resolved {
68    /// Its path and where it comes from.
69    pub info: SourceInfo,
70    /// Whether settings leave it on.
71    pub enabled: bool,
72}
73
74/// pi's `ResolvedPaths`: resolved resources by kind.
75#[derive(Clone, Debug, Default, PartialEq, Eq)]
76pub struct ResolvedPaths {
77    /// Extensions.
78    pub extensions: Vec<Resolved>,
79    /// Skills.
80    pub skills: Vec<Resolved>,
81    /// Prompt templates.
82    pub prompts: Vec<Resolved>,
83    /// Themes.
84    pub themes: Vec<Resolved>,
85    /// Extensions another source named again, each under that source,
86    /// which does not load them: by the same path, or by another path to
87    /// the same file.
88    pub shadowed: Vec<SourceInfo>,
89    /// Packages whose `chest.toml` does not parse, each by its folder with
90    /// why. Their resources resolve as if it were absent.
91    pub problems: Vec<(String, String)>,
92}
93
94impl ResolvedPaths {
95    /// The resources of `kind`.
96    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    /// Adds the resource at `path` with `metadata` unless it is listed.
115    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    /// The enabled resources of `kind`, in order.
136    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/// A package to collect from, after pi's deduplication.
145#[derive(Clone, Debug, PartialEq)]
146pub struct PackageInput {
147    /// The source as written in settings.
148    pub source: String,
149    /// `user`, `project` or `temporary`.
150    pub scope: String,
151    /// Where it is installed: a directory, or a file for a local file.
152    pub root: PathBuf,
153    /// The settings entry's filters, when it is an object.
154    pub filter: Option<FilteredPackage>,
155}
156
157/// What [`resolve`] reads.
158#[derive(Clone, Debug, Default)]
159pub struct ResolveInput<'a> {
160    /// The project's directory, whose ancestors' `.agents/skills` are
161    /// project skills: the working directory unless the run names another.
162    pub cwd: PathBuf,
163    /// The agent directory.
164    pub agent_dir: PathBuf,
165    /// The project's directory of yapi files, `<cwd>/.yapi`.
166    pub project_dir: PathBuf,
167    /// The home directory, for `~/.agents/skills`.
168    pub home: PathBuf,
169    /// Whether the project's own resources load.
170    pub project_trusted: bool,
171    /// Installed packages, project entries first.
172    pub packages: Vec<PackageInput>,
173    /// Skill folders a package manager placed, each one skill of the
174    /// package its source and scope name.
175    pub skills: Vec<PackageInput>,
176    /// The user's `extensions`, `skills`, `prompts` and `themes` settings.
177    pub user: [&'a [String]; 4],
178    /// The project's.
179    pub project: [&'a [String]; 4],
180    /// Built-in extension names.
181    pub builtins: &'a [&'a str],
182}
183
184/// The path prefix naming a built-in extension.
185pub 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
197/// pi's `matchesAnyPattern`: a glob against the path relative to `base`,
198/// the file name or the whole path, and for `SKILL.md` its directory too.
199fn 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        // minimatch keeps a leading `./`, which no relative path, name or
207        // absolute path has.
208        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
225/// pi's `matchesAnyExactPattern`: the path relative to `base` or the whole
226/// path, without a leading `./`, and for `SKILL.md` its directory too.
227fn 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
269/// pi's `isEnabledByOverrides`: `!` globs disable, then `+` paths enable,
270/// then `-` paths disable.
271pub 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
288/// pi's `applyPatterns`: plain globs select (all when there are none), `!`
289/// globs remove, `+` paths add back and `-` paths remove.
290pub 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
321/// pi's `applyAutoloadDisabledPatterns`: each pattern turns the files it
322/// names on (`+`, plain) or off (`-`, `!`), the last one deciding.
323fn 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/// The rules of `.gitignore`, `.ignore` and `.fdignore` files met while
355/// walking a directory, as a subset of the `ignore` package pi uses.
356#[derive(Clone, Debug, Default)]
357struct Ignore {
358    rules: Vec<(String, bool)>,
359}
360
361impl Ignore {
362    /// pi's `addIgnoreRules`: the rules in `dir`, prefixed with its path
363    /// relative to `root`.
364    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    /// Whether `path`, relative to the walk's root and ending in `/` for a
397    /// directory, is ignored.
398    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
411/// One rule against a relative path. A rule with a `/` is anchored to the
412/// root, and one without matches a name at any depth. Walks skip dot entries
413/// before asking, so wildcards need not match them.
414fn 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
436/// A directory's entries, sorted by name, following symbolic links.
437fn 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
458/// pi's `collectFiles`: files of `kind` under `dir`, recursively, skipping
459/// dot entries, `node_modules` and ignored paths.
460fn 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/// Where skills are discovered: pi's own directories take `.md` files at the
485/// top, `.agents` directories only below it.
486#[derive(Clone, Copy, PartialEq, Eq)]
487enum SkillMode {
488    Pi,
489    Agents,
490}
491
492/// pi's `collectSkillEntries`: a directory with a `SKILL.md` is one skill.
493/// Otherwise, its `.md` files (by mode) and the skills of its directories.
494fn 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
535/// pi's `collectAutoPromptEntries` and `collectAutoThemeEntries`: the files
536/// of `kind` directly in `dir`.
537fn 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
552/// pi's `collectAutoExtensionEntries`: a directory's own entry points, else
553/// its extension files and the entry points of its directories.
554fn 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
582/// pi's `collectResourceFiles`.
583fn 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
593/// pi's `collectFilesFromPaths`: files as they are, directories collected.
594fn 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/// pi's `readPiManifest`, extended kind by kind: each resource list comes
610/// from the package's `chest.toml` `[package]`, else its `package.json`
611/// `yapi` key, else its `pi` key, as [`read_package_meta`] reads them. A list is
612/// `None` when no source lists the kind.
613#[derive(Default)]
614pub(crate) struct Manifest {
615    lists: [Option<Vec<String>>; 4],
616}
617
618impl Manifest {
619    /// The manifest of the package at `root`, or `None` when no source
620    /// decides its resources, so the conventional folders apply. With it, why
621    /// its `chest.toml` was left out, as [`read_package_meta`] says.
622    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    /// The entries of `kind`, when listed.
629    pub(crate) fn get(&self, kind: ResourceType) -> Option<&Vec<String>> {
630        self.lists[kind as usize].as_ref()
631    }
632}
633
634/// pi's `expandPackageGlob`: matches under `root` with no dot segment,
635/// sorted.
636fn 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
658/// pi's `collectFilesFromManifestEntries`.
659fn 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
684/// pi's `collectPackageResources`: whether the package provided anything.
685fn 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
750/// pi's `collectDefaultResources`.
751fn 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
770/// pi's `collectManifestFiles`: the files the manifest's entries select,
771/// or the conventional directory's.
772fn 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
777/// The files of `kind` that the package at `root` provides: those its
778/// manifest `entries` select, with their globs and `!` patterns, else
779/// those in its folder `folder`.
780pub(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
810/// pi's `addManifestEntries`: the enabled files only.
811fn 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
832/// pi's `resolveLocalEntries`: settings entries as paths, with their
833/// patterns deciding which files are on.
834fn 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
857/// pi's `collectAncestorAgentsSkillDirs`: `.agents/skills` in `start` and
858/// each parent up to the repository root, or the filesystem root.
859fn 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
874/// pi's `addAutoDiscoveredResources`: the project's resources when it is
875/// trusted, then the user's, each scope with its extensions, skills,
876/// `.agents` skills, prompts and themes.
877fn 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
934/// pi's `resourcePrecedenceRank`.
935fn 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
946/// One package's resources, installed at `root`: a file is one extension,
947/// and a folder gives what its manifest or resource folders list. pi takes a
948/// folder with neither as one extension only when it is a `local` path.
949fn 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
972/// The resources of the package installed at `root`, as [`resolve`] finds
973/// them for a settings entry with `filter`, each enabled or not. `local`
974/// says whether its source is a local path.
975pub 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
991/// pi's `resolve`: packages, then settings entries, then discovered files,
992/// then built-in extensions, ordered by precedence without repeats.
993pub 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
1073/// The `extensions`, `skills`, `prompts` and `themes` lists of a settings
1074/// document.
1075pub 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
1079/// The strings in list `key` of a settings document or package entry,
1080/// skipping entries of other types.
1081pub 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
1094/// pi's `dedupePackages`: the `packages` entries of the project, then the
1095/// user, with their scopes, `project` or `user`. A project entry wins over
1096/// the user's for the same package, as `identity` names it for a source and
1097/// scope, unless it sets `autoload: false`: then both stay.
1098pub 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
1138/// Whether a project's `packages` entry only adjusts the user's entry for
1139/// the same package: it sets `autoload: false`.
1140fn is_delta(entry: &serde_json::Value) -> bool {
1141    entry.is_object() && entry["autoload"] == serde_json::Value::Bool(false)
1142}
1143
1144/// pi's `dedupePackages` with `findAutoloadDeltaBase`: each entry that
1145/// [`dedupe_packages`] keeps, with its scope, then the source and scope its
1146/// package is installed under, pi's `resolvedSource` and `resolvedScope`. A
1147/// project entry with `autoload: false` takes the user's entry for the same
1148/// package, when there is one.
1149pub 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            // `dedupe_packages` keeps only entries with a source.
1159            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
1176/// The packages of [`locate_packages`] that are installed, as `install_path`
1177/// places a source written in a scope's settings. A project entry with
1178/// `autoload: false` only overrides the resources it names, on top of the
1179/// user's entry.
1180pub 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        // Only chest names its packages `chest:`, so no settings entry
1194        // passes for one.
1195        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}