Skip to main content

rpi_cli/
packages.rs

1//! Discovery of Pi-compatible package resources.
2//!
3//! This module resolves package manifests and static resource paths. Extension
4//! paths are handed to the Node bridge by `js_extensions`; the Rust cdylib
5//! loader remains a separate extension mechanism. A package is a directory containing a
6//! `package.json` (or a conventional `skills/`, `prompts/`, `themes/` tree).
7//! The optional `pi`/`rpi` manifest object may override those resource paths.
8
9use std::collections::{BTreeMap, HashMap, HashSet};
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14use crate::config;
15
16const PACKAGE_SOURCE_MARKER: &str = ".rpi-package-source.json";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct PackageRoot {
20    pub root: PathBuf,
21    pub name: String,
22    pub version: Option<String>,
23    pub manifest: Option<PathBuf>,
24    /// Original settings entry used to resolve this package.
25    pub spec: String,
26    /// Provenance used by update checks. Managed-directory placement alone is
27    /// not enough to prove that a package came from the npm registry.
28    pub source: PackageSource,
29    /// Native Pi/npm-managed install root when this package lives below an
30    /// owned `npm/node_modules` tree. These packages must be updated through
31    /// the package manager at the root so its manifest and lockfile stay in
32    /// sync; only rpi's standalone package roots may use leaf-directory swaps.
33    npm_install_root: Option<PathBuf>,
34    /// Canonical `node_modules` ancestor that validated a legacy global
35    /// install. This is discovery-only provenance: callers must migrate the
36    /// package into an rpi/native managed root before any update.
37    legacy_npm_root: Option<PathBuf>,
38    /// Project `autoload:false` entries are deltas over a matching user entry.
39    /// Keep the marker so scope merging can retain both sides of the delta.
40    autoload_delta: bool,
41    scope: ResolveScope,
42    git_store_root: Option<PathBuf>,
43    git_revision: Option<String>,
44    /// Configured source whose managed checkout is absent. These records are
45    /// emitted only while planning remediation so the caller can restore the
46    /// installation without exposing nonexistent resources as loadable files.
47    missing_install: bool,
48    filter: Option<crate::settings::PackageFilter>,
49    skills: Vec<PathBuf>,
50    prompts: Vec<PathBuf>,
51    themes: Vec<PathBuf>,
52    system_prompts: Vec<PathBuf>,
53    append_system_prompts: Vec<PathBuf>,
54    /// JavaScript/TypeScript extension entry files discovered from
55    /// `pi.extensions`/`rpi.extensions` or the conventional directory.
56    pub extensions: Vec<PathBuf>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum PackageSource {
61    Npm {
62        name: String,
63        spec: String,
64        /// Configured version, range, tag, or one-level `npm:` alias target.
65        requested: Option<String>,
66        /// Only an exact semantic version is pinned. Ranges and tags update.
67        pinned: bool,
68    },
69    Git,
70    Local,
71    Unknown,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub(crate) struct ParsedNpmPackageSpec {
76    /// Dependency key and on-disk `node_modules` slot.
77    pub(crate) install_name: String,
78    /// Name required in the installed package's manifest. This differs from
79    /// `install_name` only for one-level registry aliases.
80    pub(crate) manifest_name: String,
81    /// Version, range, tag, or the complete `npm:<target>` alias selector.
82    pub(crate) requested: Option<String>,
83    /// Version, range, or tag applied to the package named by `manifest_name`.
84    /// For aliases this strips the outer `npm:<target>` portion so runtime
85    /// compatibility checks compare the installed target version correctly.
86    pub(crate) target_selector: Option<String>,
87    pub(crate) is_alias: bool,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PackageDiagnostic {
92    pub spec: String,
93    pub message: String,
94    pub blocks_update: bool,
95}
96
97#[derive(Debug, Clone, Default)]
98pub struct PackageResources {
99    pub packages: Vec<PackageRoot>,
100    pub diagnostics: Vec<PackageDiagnostic>,
101}
102
103impl PackageResources {
104    pub fn extension_paths(&self) -> Vec<PathBuf> {
105        self.packages
106            .iter()
107            .flat_map(|p| p.extensions.iter().cloned())
108            .collect()
109    }
110    pub fn skill_dirs(&self) -> Vec<PathBuf> {
111        self.packages
112            .iter()
113            .flat_map(|p| p.skills.iter().cloned())
114            .collect()
115    }
116
117    pub fn prompt_dirs(&self) -> Vec<PathBuf> {
118        self.packages
119            .iter()
120            .flat_map(|p| p.prompts.iter().cloned())
121            .collect()
122    }
123
124    pub fn theme_files(&self) -> Vec<PathBuf> {
125        self.packages
126            .iter()
127            .flat_map(|p| resource_inventory(&p.root, &p.themes, FilterResourceKind::Themes))
128            .collect()
129    }
130
131    pub fn system_prompt_files(&self) -> Vec<PathBuf> {
132        self.packages
133            .iter()
134            .flat_map(|p| p.system_prompts.iter().cloned())
135            .collect()
136    }
137
138    pub fn append_system_prompt_files(&self) -> Vec<PathBuf> {
139        self.packages
140            .iter()
141            .flat_map(|p| p.append_system_prompts.iter().cloned())
142            .collect()
143    }
144
145    pub fn find_theme(&self, name: &str) -> Option<PathBuf> {
146        let wanted = Path::new(name);
147        self.theme_files().into_iter().find(|path| {
148            path == wanted
149                || path.file_stem().and_then(|s| s.to_str()) == Some(name)
150                || path.file_name().and_then(|s| s.to_str()) == Some(name)
151        })
152    }
153}
154
155/// Resolve package specs from the settings file and conventional local roots.
156/// Empty or missing `packages` means no packages are enabled, matching Pi's
157/// explicit package list instead of silently executing every directory found
158/// under the user's home directory.
159pub fn discover_from_settings(cwd: &Path) -> PackageResources {
160    discover_configured_packages(cwd, false, true)
161}
162
163fn discover_from_settings_for_update(
164    cwd: &Path,
165    project_trusted: bool,
166) -> Result<(PackageResources, Option<crate::npm::NpmCommand>), String> {
167    let project_settings = if project_trusted {
168        crate::settings::load_active_project_settings(cwd)
169            .map_err(|error| format!("could not load project package settings: {error}"))?
170            .map(|(_, settings)| settings)
171    } else {
172        None
173    };
174    let user_settings = crate::settings::load_settings()
175        .map_err(|error| format!("could not load global package settings: {error}"))?;
176    let project_specs = project_settings
177        .as_ref()
178        .and_then(|settings| settings.packages.as_deref())
179        .unwrap_or_default();
180    let user_specs = user_settings.packages.as_deref().unwrap_or_default();
181    let has_configured_packages = !project_specs.is_empty() || !user_specs.is_empty();
182    let npm_command = if has_configured_packages {
183        let configured = project_settings
184            .as_ref()
185            .and_then(|settings| settings.npm_command.as_deref())
186            .or(user_settings.npm_command.as_deref());
187        Some(
188            crate::npm::NpmCommand::from_argv(configured).map_err(|error| {
189                format!("invalid npmCommand in active package settings: {error}")
190            })?,
191        )
192    } else {
193        None
194    };
195    let resources = discover_configured_package_specs_for_update_with_command(
196        cwd,
197        project_specs,
198        user_specs,
199        npm_command.as_ref(),
200    );
201    Ok((resources, npm_command))
202}
203
204fn discover_configured_packages(
205    cwd: &Path,
206    recover_for_update: bool,
207    include_project: bool,
208) -> PackageResources {
209    let project_specs = include_project
210        .then(|| {
211            crate::settings::load_project_settings(cwd)
212                .into_iter()
213                .filter_map(|settings| settings.packages)
214                .flatten()
215                .collect::<Vec<_>>()
216        })
217        .unwrap_or_default();
218    let user_specs = crate::settings::load_settings()
219        .ok()
220        .and_then(|settings| settings.packages)
221        .unwrap_or_default();
222    discover_configured_package_specs(
223        cwd,
224        &project_specs,
225        &user_specs,
226        recover_for_update,
227        include_project,
228    )
229}
230
231fn discover_configured_package_specs(
232    cwd: &Path,
233    project_specs: &[crate::settings::PackageSetting],
234    user_specs: &[crate::settings::PackageSetting],
235    recover_for_update: bool,
236    include_project: bool,
237) -> PackageResources {
238    let npm_command = crate::npm::NpmCommand::resolve(cwd, include_project).ok();
239    discover_configured_package_specs_with_command(
240        cwd,
241        project_specs,
242        user_specs,
243        recover_for_update,
244        npm_command.as_ref(),
245    )
246}
247
248fn discover_configured_package_specs_with_command(
249    cwd: &Path,
250    project_specs: &[crate::settings::PackageSetting],
251    user_specs: &[crate::settings::PackageSetting],
252    recover_for_update: bool,
253    npm_command: Option<&crate::npm::NpmCommand>,
254) -> PackageResources {
255    let project = discover_with_scope_and_command(
256        cwd,
257        project_specs,
258        ResolveScope::Project,
259        recover_for_update,
260        npm_command,
261    );
262    let user = discover_with_scope_and_command(
263        cwd,
264        user_specs,
265        ResolveScope::User,
266        recover_for_update,
267        npm_command,
268    );
269    merge_scoped_resources([project, user])
270}
271
272fn discover_configured_package_specs_for_update_with_command(
273    cwd: &Path,
274    project_specs: &[crate::settings::PackageSetting],
275    user_specs: &[crate::settings::PackageSetting],
276    npm_command: Option<&crate::npm::NpmCommand>,
277) -> PackageResources {
278    let project = discover_with_scope_and_command(
279        cwd,
280        project_specs,
281        ResolveScope::Project,
282        true,
283        npm_command,
284    );
285    let user =
286        discover_with_scope_and_command(cwd, user_specs, ResolveScope::User, true, npm_command);
287    // Runtime resolution is project-first for an identity collision, but the
288    // update command must reconcile both physical installations. Native Pi's
289    // update() likewise queues global and project settings independently.
290    let mut combined = PackageResources::default();
291    for mut resources in [project, user] {
292        combined.packages.append(&mut resources.packages);
293        combined.diagnostics.append(&mut resources.diagnostics);
294    }
295    combined
296}
297
298/// Resolve packages for the explicitly enabled JS/TS runtime. Unlike the
299/// metadata-only discovery helpers, this mirrors native Pi by restoring a
300/// missing npm/Git source and reconciling an installed npm version that no
301/// longer satisfies its configured semver range. Callers must already have
302/// passed the project trust gate before selecting the project variant.
303pub fn resolve_from_settings(cwd: &Path) -> PackageResources {
304    resolve_configured_packages_for_runtime(cwd, true)
305}
306
307/// Runtime resolution restricted to global settings. This is used when the
308/// current project is not trusted, so no project command or storage path is
309/// read or touched.
310pub fn resolve_from_global_settings(cwd: &Path) -> PackageResources {
311    resolve_configured_packages_for_runtime(cwd, false)
312}
313
314/// Offline runtime resolution never invokes npm or Git. Installed npm
315/// packages that do not satisfy their configured version are withheld rather
316/// than executing stale code, matching native Pi's offline missing-source
317/// behavior.
318pub fn resolve_offline_from_settings(cwd: &Path) -> PackageResources {
319    resolve_configured_packages_offline(cwd, true)
320}
321
322pub fn resolve_offline_from_global_settings(cwd: &Path) -> PackageResources {
323    resolve_configured_packages_offline(cwd, false)
324}
325
326fn resolve_configured_packages_offline(cwd: &Path, include_project: bool) -> PackageResources {
327    let project_specs = include_project
328        .then(|| {
329            crate::settings::load_project_settings(cwd)
330                .into_iter()
331                .filter_map(|settings| settings.packages)
332                .flatten()
333                .collect::<Vec<_>>()
334        })
335        .unwrap_or_default();
336    let user_specs = crate::settings::load_settings()
337        .ok()
338        .and_then(|settings| settings.packages)
339        .unwrap_or_default();
340    let mut resources = discover_configured_package_specs_with_command(
341        cwd,
342        &project_specs,
343        &user_specs,
344        false,
345        None,
346    );
347    let failures = runtime_npm_mismatch_failures(
348        &resources,
349        "configured npm version is unavailable while offline",
350    );
351    apply_runtime_failures(&mut resources, failures);
352    resources
353}
354
355fn resolve_configured_packages_for_runtime(cwd: &Path, include_project: bool) -> PackageResources {
356    let project_specs = include_project
357        .then(|| {
358            crate::settings::load_project_settings(cwd)
359                .into_iter()
360                .filter_map(|settings| settings.packages)
361                .flatten()
362                .collect::<Vec<_>>()
363        })
364        .unwrap_or_default();
365    let user_specs = crate::settings::load_settings()
366        .ok()
367        .and_then(|settings| settings.packages)
368        .unwrap_or_default();
369    let planned =
370        discover_configured_package_specs(cwd, &project_specs, &user_specs, true, include_project);
371
372    // Store enough identity alongside each operation to prevent a failed or
373    // partially completed package-manager command from exposing the old,
374    // incompatible package to the JS runtime.
375    let mut npm_roots: BTreeMap<PathBuf, Vec<(String, String, String)>> = BTreeMap::new();
376    let mut standalone_npm = Vec::new();
377    let mut missing_git = Vec::new();
378    let mut planning_failures: HashMap<String, (String, String)> = HashMap::new();
379
380    for package in &planned.packages {
381        if runtime_npm_needs_install(package) {
382            let PackageSource::Npm { name, spec, .. } = &package.source else {
383                unreachable!();
384            };
385            let identity = package_identity(package);
386            match package.npm_store_root_for_update(cwd, include_project) {
387                Ok(Some(root)) => {
388                    npm_roots
389                        .entry(root)
390                        .or_default()
391                        .push((name.clone(), spec.clone(), identity))
392                }
393                Ok(None) => standalone_npm.push((
394                    package.root.clone(),
395                    package.name.clone(),
396                    name.clone(),
397                    spec.clone(),
398                    identity,
399                )),
400                Err(error) => {
401                    planning_failures.insert(identity, (package.spec.clone(), error));
402                }
403            }
404        } else if package.missing_install && matches!(package.source, PackageSource::Git) {
405            missing_git.push(package.clone());
406        }
407    }
408
409    if npm_roots.is_empty()
410        && standalone_npm.is_empty()
411        && missing_git.is_empty()
412        && planning_failures.is_empty()
413    {
414        return planned;
415    }
416
417    let mut failures = planning_failures;
418    match crate::npm::NpmCommand::resolve(cwd, include_project) {
419        Ok(npm_command) => {
420            for (root, display_name, name, source_spec, identity) in standalone_npm {
421                if let Err(error) = crate::install_pi::update_npm_package_for_startup(
422                    &root,
423                    &name,
424                    &source_spec,
425                    &npm_command,
426                ) {
427                    failures.insert(
428                        identity,
429                        (
430                            source_spec,
431                            format!("could not restore npm package {display_name}: {error}"),
432                        ),
433                    );
434                }
435            }
436            for (root, packages) in npm_roots {
437                let install_specs = packages
438                    .iter()
439                    .map(|(name, source, _)| (name.clone(), source.clone()))
440                    .collect::<Vec<_>>();
441                if let Err(error) = crate::install_pi::update_npm_store_root_for_startup(
442                    &root,
443                    &install_specs,
444                    &npm_command,
445                    cwd,
446                    include_project,
447                ) {
448                    for (_, source, identity) in packages {
449                        failures.insert(
450                            identity,
451                            (source, format!("could not restore npm package: {error}")),
452                        );
453                    }
454                }
455            }
456            for package in missing_git {
457                let identity = package_identity(&package);
458                if let Err(error) = crate::install_pi::install_missing_git_package_for_startup(
459                    cwd,
460                    package.scope == ResolveScope::User,
461                    &package.spec,
462                    &npm_command,
463                ) {
464                    failures.insert(
465                        identity,
466                        (
467                            package.spec.clone(),
468                            format!("could not restore git package: {error}"),
469                        ),
470                    );
471                }
472            }
473        }
474        Err(error) => {
475            for (_, source, identity) in npm_roots.into_values().flatten() {
476                failures.insert(identity, (source, error.clone()));
477            }
478            for (_, _, _, source, identity) in standalone_npm {
479                failures.insert(identity, (source, error.clone()));
480            }
481            for package in missing_git {
482                failures.insert(package_identity(&package), (package.spec, error.clone()));
483            }
484        }
485    }
486
487    let mut resolved =
488        discover_configured_package_specs(cwd, &project_specs, &user_specs, false, include_project);
489    failures.extend(runtime_npm_mismatch_failures(
490        &resolved,
491        "package manager completed but the installed npm version still does not satisfy settings",
492    ));
493    if failures.is_empty() {
494        return resolved;
495    }
496    apply_runtime_failures(&mut resolved, failures);
497    resolved
498}
499
500fn runtime_npm_mismatch_failures(
501    resources: &PackageResources,
502    message: &str,
503) -> HashMap<String, (String, String)> {
504    resources
505        .packages
506        .iter()
507        .filter(|package| runtime_npm_needs_install(package))
508        .map(|package| {
509            (
510                package_identity(package),
511                (package.spec.clone(), message.to_string()),
512            )
513        })
514        .collect()
515}
516
517fn apply_runtime_failures(
518    resources: &mut PackageResources,
519    failures: HashMap<String, (String, String)>,
520) {
521    if failures.is_empty() {
522        return;
523    }
524    resources
525        .packages
526        .retain(|package| !failures.contains_key(&package_identity(package)));
527    resources.diagnostics.retain(|diagnostic| {
528        !failures
529            .values()
530            .any(|(source, _)| source == &diagnostic.spec)
531    });
532    resources.diagnostics.extend(
533        failures
534            .into_values()
535            .map(|(spec, message)| PackageDiagnostic {
536                spec,
537                message,
538                blocks_update: true,
539            }),
540    );
541}
542
543fn runtime_npm_needs_install(package: &PackageRoot) -> bool {
544    let PackageSource::Npm { spec, .. } = &package.source else {
545        return false;
546    };
547    if package.missing_install {
548        return true;
549    }
550    let Some(requested) = parse_npm_package_spec(spec).and_then(|parsed| parsed.target_selector)
551    else {
552        return false;
553    };
554    npm_version_matches_requirement(package.version.as_deref(), &requested) == Some(false)
555}
556
557/// Return `None` for npm tags or range syntax the Rust semver parser cannot
558/// represent. Native Pi does not version-check tags, and treating an unknown
559/// range as satisfied avoids a reinstall loop while retaining exact, caret,
560/// tilde, wildcard, comparator, OR, and hyphen range support.
561fn npm_version_matches_requirement(installed: Option<&str>, requested: &str) -> Option<bool> {
562    let requested = requested.trim();
563    if requested.is_empty() {
564        return None;
565    }
566    if let Some((left, right)) = requested.split_once("||") {
567        let mut recognized = false;
568        for branch in std::iter::once(left).chain(right.split("||")) {
569            if let Some(matches) = npm_version_matches_requirement(installed, branch) {
570                recognized = true;
571                if matches {
572                    return Some(true);
573                }
574            }
575        }
576        return recognized.then_some(false);
577    }
578    let installed = installed
579        .and_then(|version| semver::Version::parse(version.trim().trim_start_matches('v')).ok());
580    if is_exact_npm_version(requested) {
581        let expected = semver::Version::parse(requested.trim_start_matches('v')).ok()?;
582        return Some(installed.as_ref() == Some(&expected));
583    }
584    if let Some((minimum, maximum)) = requested.split_once(" - ") {
585        let (minimum, _) = parse_npm_partial_version(minimum)?;
586        let (maximum, maximum_parts) = parse_npm_partial_version(maximum)?;
587        return Some(installed.as_ref().is_some_and(|installed| {
588            let below_upper = match maximum_parts {
589                1 => maximum
590                    .major
591                    .checked_add(1)
592                    .is_some_and(|major| installed < &semver::Version::new(major, 0, 0)),
593                2 => maximum.minor.checked_add(1).is_some_and(|minor| {
594                    installed < &semver::Version::new(maximum.major, minor, 0)
595                }),
596                _ => installed <= &maximum,
597            };
598            installed >= &minimum && below_upper
599        }));
600    }
601
602    let tokens = requested.split_whitespace().collect::<Vec<_>>();
603    let normalized = normalize_npm_comparator_set(&tokens).unwrap_or_else(|| requested.to_string());
604    // Bare partial versions have npm semantics that differ from Cargo's
605    // caret-default syntax. Express their upper bound explicitly.
606    if let Some((partial, parts)) = parse_npm_partial_version(requested) {
607        if parts < 3 {
608            return if parts == 1 {
609                Some(
610                    installed
611                        .as_ref()
612                        .is_some_and(|version| version.major == partial.major),
613                )
614            } else {
615                Some(installed.as_ref().is_some_and(|version| {
616                    version.major == partial.major && version.minor == partial.minor
617                }))
618            };
619        }
620    }
621    semver::VersionReq::parse(&normalized)
622        .ok()
623        .map(|requirement| {
624            installed
625                .as_ref()
626                .is_some_and(|installed| requirement.matches(installed))
627        })
628}
629
630/// Cargo's semver parser requires comma-separated comparators and does not
631/// accept npm's optional whitespace between an operator and its version.
632/// Normalize only a conservative comparator set; tags and other npm-only
633/// syntax continue to return `None` instead of being guessed at.
634fn normalize_npm_comparator_set(tokens: &[&str]) -> Option<String> {
635    if tokens.len() <= 1 {
636        return None;
637    }
638    let mut normalized = Vec::new();
639    let mut index = 0;
640    while index < tokens.len() {
641        let token = tokens[index];
642        if matches!(token, "<" | "<=" | ">" | ">=" | "=" | "^" | "~") {
643            let version = *tokens.get(index + 1)?;
644            if !version
645                .trim_start_matches('v')
646                .chars()
647                .next()
648                .is_some_and(|character| character.is_ascii_digit())
649            {
650                return None;
651            }
652            normalized.push(format!("{token}{version}"));
653            index += 2;
654            continue;
655        }
656        if !token.chars().next().is_some_and(|character| {
657            matches!(character, '<' | '>' | '=' | '^' | '~') || character.is_ascii_digit()
658        }) {
659            return None;
660        }
661        normalized.push(token.to_string());
662        index += 1;
663    }
664    Some(normalized.join(", "))
665}
666
667fn parse_npm_partial_version(value: &str) -> Option<(semver::Version, usize)> {
668    let value = value.trim().trim_start_matches('v');
669    if let Ok(version) = semver::Version::parse(value) {
670        return Some((version, 3));
671    }
672    let parts = value.split('.').collect::<Vec<_>>();
673    if parts.is_empty()
674        || parts.len() > 3
675        || parts
676            .iter()
677            .any(|part| part.is_empty() || !part.chars().all(|ch| ch.is_ascii_digit()))
678    {
679        return None;
680    }
681    let major = parts[0].parse().ok()?;
682    let minor = parts.get(1).and_then(|part| part.parse().ok()).unwrap_or(0);
683    let patch = parts.get(2).and_then(|part| part.parse().ok()).unwrap_or(0);
684    Some((semver::Version::new(major, minor, patch), parts.len()))
685}
686
687fn merge_scoped_resources(
688    resources: impl IntoIterator<Item = PackageResources>,
689) -> PackageResources {
690    let mut merged = PackageResources::default();
691    let mut seen_identities: HashMap<String, usize> = HashMap::new();
692    for mut resource in resources {
693        merged.diagnostics.append(&mut resource.diagnostics);
694        for package in resource.packages {
695            let identity = package_identity(&package);
696            if let Some(existing_index) = seen_identities.get(&identity).copied() {
697                let existing = &merged.packages[existing_index];
698                // Native Pi keeps a project autoload:false entry as a delta
699                // over the matching global package. All other collisions are
700                // project-first (the resources iterator is project then user).
701                if package.scope == ResolveScope::User && existing.autoload_delta {
702                    let mut base = package;
703                    if let Some(filter) = existing.filter.as_ref() {
704                        apply_autoload_delta_to_package(&mut base, filter);
705                    }
706                    merged.packages[existing_index] = base;
707                }
708            } else {
709                seen_identities.insert(identity, merged.packages.len());
710                merged.packages.push(package);
711            }
712        }
713    }
714    merged
715}
716
717/// Resolve only packages declared in the global settings file. Project-local
718/// package declarations are intentionally excluded when the current project
719/// has not been trusted.
720pub fn discover_from_global_settings(cwd: &Path) -> PackageResources {
721    let specs = crate::settings::load_settings()
722        .ok()
723        .and_then(|settings| settings.packages)
724        .unwrap_or_default();
725    discover_with_scope(cwd, &specs, ResolveScope::User, false)
726}
727
728/// Discover packages in settings order. Package resources are intentionally
729/// returned after project and global resources; callers append these paths last
730/// so a package cannot shadow a project-local or user-local resource.
731pub fn discover(cwd: &Path, specs: &[String]) -> PackageResources {
732    let entries = specs
733        .iter()
734        .cloned()
735        .map(crate::settings::PackageSetting::from)
736        .collect::<Vec<_>>();
737    discover_with_scope(cwd, &entries, ResolveScope::Any, false)
738}
739
740fn discover_with_scope(
741    cwd: &Path,
742    specs: &[crate::settings::PackageSetting],
743    scope: ResolveScope,
744    recover_for_update: bool,
745) -> PackageResources {
746    let global_npm_command = if matches!(scope, ResolveScope::Any | ResolveScope::User) {
747        crate::npm::NpmCommand::resolve(cwd, false).ok()
748    } else {
749        None
750    };
751    discover_with_scope_and_command(
752        cwd,
753        specs,
754        scope,
755        recover_for_update,
756        global_npm_command.as_ref(),
757    )
758}
759
760fn discover_with_scope_and_command(
761    cwd: &Path,
762    specs: &[crate::settings::PackageSetting],
763    scope: ResolveScope,
764    recover_for_update: bool,
765    global_npm_command: Option<&crate::npm::NpmCommand>,
766) -> PackageResources {
767    let mut out = PackageResources::default();
768    let mut seen = HashSet::new();
769    let legacy_npm_names = specs
770        .iter()
771        .filter_map(|entry| npm_source_from_spec(entry.source()))
772        .filter_map(|source| match source {
773            PackageSource::Npm { name, .. } => Some(name),
774            _ => None,
775        })
776        .collect::<HashSet<_>>()
777        .into_iter()
778        .collect::<Vec<_>>();
779    let mut legacy_npm_paths = None;
780    for entry in specs
781        .iter()
782        .filter(|entry| !entry.source().trim().is_empty())
783    {
784        let spec = entry.source();
785        let filter = match entry {
786            crate::settings::PackageSetting::Filtered(filter) => Some(filter),
787            crate::settings::PackageSetting::Source(_) => None,
788        };
789        if recover_for_update {
790            let mut recovery_failed = false;
791            for target in update_recovery_targets(cwd, spec, scope) {
792                if let Err(message) = crate::install_pi::recover_configured_package_root(&target) {
793                    out.diagnostics.push(PackageDiagnostic {
794                        spec: spec.to_string(),
795                        message,
796                        blocks_update: true,
797                    });
798                    recovery_failed = true;
799                    break;
800                }
801                if target.is_dir() {
802                    break;
803                }
804            }
805            if recovery_failed {
806                continue;
807            }
808        }
809        let Some(resolved) = resolve_spec_with_command(
810            cwd,
811            spec,
812            scope,
813            global_npm_command,
814            &legacy_npm_names,
815            &mut legacy_npm_paths,
816        ) else {
817            if recover_for_update {
818                match missing_package_for_update(cwd, spec, scope, filter) {
819                    Ok(Some(package)) => {
820                        let key = package_identity(&package);
821                        if seen.insert(key) {
822                            out.packages.push(package);
823                        }
824                        continue;
825                    }
826                    // Native Pi's manual update ignores local sources. A
827                    // missing local path therefore does not turn an otherwise
828                    // valid package update into a failure.
829                    Ok(None) => continue,
830                    Err(message) => {
831                        out.diagnostics.push(PackageDiagnostic {
832                            spec: spec.to_string(),
833                            message,
834                            blocks_update: true,
835                        });
836                        continue;
837                    }
838                }
839            }
840            out.diagnostics.push(PackageDiagnostic {
841                spec: spec.to_string(),
842                message: "package path/name could not be resolved".to_string(),
843                blocks_update: true,
844            });
845            continue;
846        };
847        let root = resolved.root;
848        let key = normalize_key(&root);
849        if !seen.insert(key) {
850            continue;
851        }
852        match load_package_with_legacy_root(
853            root,
854            spec,
855            cwd,
856            scope,
857            filter,
858            resolved.legacy_npm_root,
859        ) {
860            Ok(package) => out.packages.push(package),
861            Err(message) => out.diagnostics.push(PackageDiagnostic {
862                spec: spec.to_string(),
863                message,
864                blocks_update: true,
865            }),
866        }
867    }
868    out
869}
870
871fn missing_package_for_update(
872    cwd: &Path,
873    spec: &str,
874    scope: ResolveScope,
875    filter: Option<&crate::settings::PackageFilter>,
876) -> Result<Option<PackageRoot>, String> {
877    let (root, name, source, npm_install_root, git_store_root, git_revision) =
878        if let Some(source @ PackageSource::Npm { .. }) = npm_source_from_spec(spec) {
879            let PackageSource::Npm { name, .. } = &source else {
880                unreachable!();
881            };
882            let name = name.clone();
883            let install_root = managed_npm_root_for_scope(cwd, scope)?;
884            let root = install_root.join("node_modules").join(&name);
885            (root, name, source, Some(install_root), None, None)
886        } else if let Some(git) = parse_git_source(spec) {
887            let store_root = managed_git_root_for_scope(cwd, scope)?;
888            let root = store_root.join(&git.host).join(&git.path);
889            let name = git
890                .path
891                .rsplit('/')
892                .next()
893                .filter(|name| !name.is_empty())
894                .unwrap_or(&git.path)
895                .to_string();
896            (
897                root,
898                name,
899                PackageSource::Git,
900                None,
901                Some(store_root),
902                git.revision,
903            )
904        } else {
905            return Ok(None);
906        };
907
908    Ok(Some(PackageRoot {
909        root,
910        name,
911        version: None,
912        manifest: None,
913        spec: spec.to_string(),
914        source,
915        npm_install_root,
916        legacy_npm_root: None,
917        autoload_delta: filter.is_some_and(|filter| filter.autoload == Some(false)),
918        scope,
919        git_store_root,
920        git_revision,
921        missing_install: true,
922        filter: filter.cloned(),
923        skills: Vec::new(),
924        prompts: Vec::new(),
925        themes: Vec::new(),
926        system_prompts: Vec::new(),
927        append_system_prompts: Vec::new(),
928        extensions: Vec::new(),
929    }))
930}
931
932fn managed_npm_root_for_scope(cwd: &Path, scope: ResolveScope) -> Result<PathBuf, String> {
933    match scope {
934        ResolveScope::Project => Ok(cwd.join(".pi/npm")),
935        ResolveScope::User | ResolveScope::Any => config::agent_dir()
936            .map(|agent| agent.join("npm"))
937            .map_err(|error| error.to_string()),
938    }
939}
940
941fn managed_git_root_for_scope(cwd: &Path, scope: ResolveScope) -> Result<PathBuf, String> {
942    match scope {
943        ResolveScope::Project => Ok(cwd.join(".pi/git")),
944        ResolveScope::User | ResolveScope::Any => config::agent_dir()
945            .map(|agent| agent.join("git"))
946            .map_err(|error| error.to_string()),
947    }
948}
949
950fn absolute_file_spec_path(spec: &str) -> Option<PathBuf> {
951    let path = PathBuf::from(spec.strip_prefix("file:")?);
952    path.is_absolute().then_some(path)
953}
954
955fn update_recovery_targets(cwd: &Path, spec: &str, scope: ResolveScope) -> Vec<PathBuf> {
956    let mut targets = absolute_file_spec_path(spec)
957        .into_iter()
958        .collect::<Vec<_>>();
959    if let Some(git) = parse_git_source(spec) {
960        let relative = Path::new(&git.host).join(&git.path);
961        if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
962            targets.push(cwd.join(".rpi/git").join(&relative));
963            targets.push(cwd.join(".pi/git").join(&relative));
964        }
965        if matches!(scope, ResolveScope::Any | ResolveScope::User) {
966            if let Ok(agent) = config::agent_dir() {
967                targets.push(agent.join("git").join(&relative));
968            }
969            if let Some(home) = dirs::home_dir() {
970                targets.push(home.join(".pi/agent/git").join(&relative));
971            }
972        }
973        return targets;
974    }
975    let Some(PackageSource::Npm { name, .. }) = npm_source_from_spec(spec) else {
976        return targets;
977    };
978    let package_key = name.strip_prefix('@').unwrap_or(&name).replace('/', "__");
979    if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
980        targets.push(cwd.join(".rpi/packages").join(&name));
981        if package_key != name {
982            targets.push(cwd.join(".rpi/packages").join(&package_key));
983        }
984        targets.push(cwd.join(".pi/packages").join(&name));
985        if package_key != name {
986            targets.push(cwd.join(".pi/packages").join(&package_key));
987        }
988        targets.push(cwd.join(".pi/npm/node_modules").join(&name));
989    }
990    if matches!(scope, ResolveScope::Any | ResolveScope::User) {
991        if let Ok(agent) = config::agent_dir() {
992            targets.push(agent.join("packages").join(&name));
993            if package_key != name {
994                targets.push(agent.join("packages").join(&package_key));
995            }
996            targets.push(agent.join("npm/node_modules").join(&name));
997        }
998        if let Some(home) = dirs::home_dir() {
999            targets.push(home.join(".pi/agent/packages").join(&name));
1000            if package_key != name {
1001                targets.push(home.join(".pi/agent/packages").join(&package_key));
1002            }
1003            targets.push(home.join(".pi/agent/npm/node_modules").join(&name));
1004        }
1005    }
1006    targets
1007}
1008
1009/// Validate and load one package spec. Used by `rpi package add` before the
1010/// spec is persisted to settings.
1011pub fn resolve_package(cwd: &Path, spec: &str) -> Result<PackageRoot, String> {
1012    let root = resolve_spec(cwd, spec, ResolveScope::Any)
1013        .ok_or_else(|| "package path/name could not be resolved".to_string())?;
1014    load_package(root, spec, cwd, ResolveScope::Any, None)
1015}
1016
1017/// Load a theme from an explicit JSON path without discovering configured Pi
1018/// packages. Startup code that has passed the package gate uses
1019/// [`load_theme_with_resources`] to resolve package theme names.
1020pub fn load_theme(cwd: &Path, name_or_path: &str) -> Result<rpi_tui::Theme, String> {
1021    load_theme_with_resources(cwd, name_or_path, &PackageResources::default())
1022}
1023
1024/// Load a package theme from an already-resolved resource set. Startup callers
1025/// use this variant so a disabled package configuration cannot be re-discovered
1026/// indirectly from a TUI theme selector.
1027pub fn load_theme_with_resources(
1028    _cwd: &Path,
1029    name_or_path: &str,
1030    resources: &PackageResources,
1031) -> Result<rpi_tui::Theme, String> {
1032    let path = {
1033        let direct = PathBuf::from(name_or_path);
1034        if direct.is_file() {
1035            Some(direct)
1036        } else {
1037            resources.find_theme(name_or_path)
1038        }
1039    }
1040    .ok_or_else(|| format!("theme `{name_or_path}` was not found in enabled packages"))?;
1041    let text = std::fs::read_to_string(&path)
1042        .map_err(|error| format!("could not read theme {}: {error}", path.display()))?;
1043    let value = parse_json_with_comments(&text)
1044        .map_err(|error| format!("invalid theme {}: {error}", path.display()))?;
1045    let mut theme = rpi_tui::Theme::default();
1046    let colors = value.get("colors").unwrap_or(&value);
1047    let target = &mut theme.colors;
1048    macro_rules! color {
1049        ($field:ident, $($key:literal),+ $(,)?) => {
1050            if let Some(value) = first_value(colors, &[$($key),+]) {
1051                if let Some(parsed) = parse_color(value) {
1052                    target.$field = parsed;
1053                }
1054            }
1055        };
1056    }
1057    color!(text, "text");
1058    color!(muted, "muted");
1059    color!(dim, "dim");
1060    color!(accent, "accent");
1061    color!(error, "error");
1062    color!(success, "success");
1063    color!(warning, "warning");
1064    color!(info, "info");
1065    color!(background, "background", "bg");
1066    color!(surface, "surface", "userMessageBg");
1067    color!(border, "border");
1068    color!(border_accent, "borderAccent");
1069    color!(border_muted, "borderMuted");
1070    color!(selection, "selection", "selectedBg");
1071    color!(cursor, "cursor");
1072    color!(thinking_text, "thinkingText");
1073    color!(md_heading, "mdHeading");
1074    color!(md_link, "mdLink");
1075    color!(md_link_url, "mdLinkUrl");
1076    color!(md_code, "mdCode");
1077    color!(md_code_bg, "mdCodeBg");
1078    color!(md_code_block, "mdCodeBlock");
1079    color!(md_code_block_bg, "mdCodeBlockBg");
1080    color!(md_code_block_border, "mdCodeBlockBorder");
1081    color!(md_quote, "mdQuote");
1082    color!(md_quote_border, "mdQuoteBorder");
1083    color!(md_hr, "mdHr");
1084    color!(md_list_bullet, "mdListBullet");
1085    color!(tool_pending_bg, "toolPendingBg");
1086    color!(tool_success_bg, "toolSuccessBg");
1087    color!(tool_error_bg, "toolErrorBg");
1088    color!(tool_title, "toolTitle");
1089    color!(tool_output, "toolOutput");
1090    color!(bash_mode, "bashMode");
1091    color!(tool_diff_added, "toolDiffAdded");
1092    color!(tool_diff_removed, "toolDiffRemoved");
1093    color!(tool_diff_context, "toolDiffContext");
1094    if let Some(border) = value.get("borderStyle").and_then(Value::as_str) {
1095        theme.border_style = match border.to_ascii_lowercase().as_str() {
1096            "sharp" => rpi_tui::theme::BorderStyle::Sharp,
1097            "double" => rpi_tui::theme::BorderStyle::Double,
1098            "thick" => rpi_tui::theme::BorderStyle::Thick,
1099            "none" => rpi_tui::theme::BorderStyle::None,
1100            _ => rpi_tui::theme::BorderStyle::Rounded,
1101        };
1102    }
1103    if let Some(corner) = value.get("cornerStyle").and_then(Value::as_str) {
1104        theme.corner_style = if corner.eq_ignore_ascii_case("sharp") {
1105            rpi_tui::theme::CornerStyle::Sharp
1106        } else {
1107            rpi_tui::theme::CornerStyle::Rounded
1108        };
1109    }
1110    Ok(theme)
1111}
1112
1113fn first_value<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> {
1114    keys.iter().find_map(|key| value.get(*key))
1115}
1116
1117fn parse_color(value: &Value) -> Option<rpi_tui::Color> {
1118    match value {
1119        Value::String(raw) => {
1120            let value = raw.trim();
1121            let hex = value.strip_prefix('#')?;
1122            if hex.len() == 6 {
1123                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
1124                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
1125                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
1126                Some(rpi_tui::Color::Rgb(r, g, b))
1127            } else if let Some(index) = value.strip_prefix("ansi256:") {
1128                Some(rpi_tui::Color::Ansi256(index.parse().ok()?))
1129            } else {
1130                None
1131            }
1132        }
1133        Value::Array(values) if values.len() == 3 => Some(rpi_tui::Color::Rgb(
1134            values[0].as_u64()?.try_into().ok()?,
1135            values[1].as_u64()?.try_into().ok()?,
1136            values[2].as_u64()?.try_into().ok()?,
1137        )),
1138        Value::Object(map) => {
1139            let r = map.get("r")?.as_u64()?.try_into().ok()?;
1140            let g = map.get("g")?.as_u64()?.try_into().ok()?;
1141            let b = map.get("b")?.as_u64()?.try_into().ok()?;
1142            Some(rpi_tui::Color::Rgb(r, g, b))
1143        }
1144        _ => None,
1145    }
1146}
1147
1148/// `rpi package ...` command for managing the enabled Pi package list. This is
1149/// a local package manager; use `install-pi` when the package must be fetched.
1150pub fn run_cli(args: &[String]) -> i32 {
1151    crate::args::normalize_offline_mode(args);
1152    let args = crate::args::without_offline_flag(args);
1153    let args = args.as_slice();
1154    let command = args.first().map(String::as_str).unwrap_or("list");
1155    let cwd = match std::env::current_dir() {
1156        Ok(path) => path,
1157        Err(error) => {
1158            eprintln!("error: could not determine current directory: {error}");
1159            return 1;
1160        }
1161    };
1162    match command {
1163        "list" => {
1164            let project_trusted = match package_command_project_trusted(&cwd, &args[1..]) {
1165                Ok(trusted) => trusted,
1166                Err(error) => {
1167                    eprintln!("error: {error}");
1168                    return 2;
1169                }
1170            };
1171            let resources = if project_trusted {
1172                discover_from_settings(&cwd)
1173            } else {
1174                discover_from_global_settings(&cwd)
1175            };
1176            let native = crate::install::installed_native_packages();
1177            if args.iter().any(|arg| arg == "--json") {
1178                let mut values: Vec<_> = resources
1179                    .packages
1180                    .iter()
1181                    .map(|p| {
1182                        serde_json::json!({
1183                            "name": p.name,
1184                            "version": p.version,
1185                            "type": "ts",
1186                            "root": p.root,
1187                            "manifest": p.manifest,
1188                            "skills": p.skill_dirs_for_display(),
1189                            "prompts": p.prompt_dirs_for_display(),
1190                            "themes": p.theme_files_for_display(),
1191                        })
1192                    })
1193                    .collect();
1194                values.extend(native.iter().map(|package| {
1195                    serde_json::json!({
1196                        "name": package.name,
1197                        "version": package.version,
1198                        "type": "rust",
1199                        "source": package.source,
1200                    })
1201                }));
1202                println!(
1203                    "{}",
1204                    serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".into())
1205                );
1206            } else if resources.packages.is_empty() && native.is_empty() {
1207                println!("no Pi packages enabled");
1208            } else {
1209                for package in &resources.packages {
1210                    let version = package.version.as_deref().unwrap_or("-");
1211                    println!("{}@{} {}", package.name, version, package.root.display());
1212                }
1213                for package in native {
1214                    let source = package.source.as_deref().unwrap_or("crates.io");
1215                    println!("{}@{} [rust] {}", package.name, package.version, source);
1216                }
1217            }
1218            for diagnostic in resources.diagnostics {
1219                eprintln!(
1220                    "warning: package {}: {}",
1221                    diagnostic.spec, diagnostic.message
1222                );
1223            }
1224            0
1225        }
1226        "add" => {
1227            let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
1228                eprintln!("error: missing package path or name");
1229                print_help();
1230                return 2;
1231            };
1232            if let Err(error) = resolve_package(&cwd, spec) {
1233                eprintln!("error: {error}");
1234                return 1;
1235            }
1236            let mut settings = match crate::settings::load_settings() {
1237                Ok(settings) => settings,
1238                Err(error) => {
1239                    eprintln!("error: refusing to change unreadable package settings: {error}");
1240                    return 1;
1241                }
1242            };
1243            let packages = settings.packages.get_or_insert_with(Vec::new);
1244            if !packages.iter().any(|existing| existing.source() == spec) {
1245                packages.push(crate::settings::PackageSetting::from(spec.clone()));
1246                if let Err(error) = crate::settings::save_settings(&settings) {
1247                    eprintln!("error: could not save package settings: {error}");
1248                    return 1;
1249                }
1250                println!("enabled Pi package {spec}");
1251            } else {
1252                println!("Pi package already enabled: {spec}");
1253            }
1254            0
1255        }
1256        "remove" | "rm" => {
1257            let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
1258                eprintln!("error: missing package path or name");
1259                print_help();
1260                return 2;
1261            };
1262            let mut settings = match crate::settings::load_settings() {
1263                Ok(settings) => settings,
1264                Err(error) => {
1265                    eprintln!("error: refusing to change unreadable package settings: {error}");
1266                    return 1;
1267                }
1268            };
1269            let Some(packages) = settings.packages.as_mut() else {
1270                println!("Pi package is not enabled: {spec}");
1271                return 0;
1272            };
1273            let before = packages.len();
1274            packages.retain(|existing| existing.source() != spec);
1275            if packages.len() == before {
1276                println!("Pi package is not enabled: {spec}");
1277                return 0;
1278            }
1279            if packages.is_empty() {
1280                settings.packages = None;
1281            }
1282            if let Err(error) = crate::settings::save_settings(&settings) {
1283                eprintln!("error: could not save package settings: {error}");
1284                return 1;
1285            }
1286            println!("disabled Pi package {spec}");
1287            0
1288        }
1289        "update" => {
1290            if args
1291                .iter()
1292                .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
1293            {
1294                print_update_help();
1295                return 0;
1296            }
1297            let project_trusted = match package_command_project_trusted(&cwd, &args[1..]) {
1298                Ok(trusted) => trusted,
1299                Err(error) => {
1300                    eprintln!("error: {error}");
1301                    return 2;
1302                }
1303            };
1304            update_packages_with_scope(&cwd, project_trusted, UpdateScope::Native)
1305        }
1306        "help" | "--help" | "-h" => {
1307            print_help();
1308            0
1309        }
1310        other => {
1311            eprintln!("error: unknown package command `{other}`");
1312            print_help();
1313            2
1314        }
1315    }
1316}
1317
1318/// Compatibility helper for callers that need the Rust-native package scope.
1319pub fn run_native_update(args: &[String]) -> i32 {
1320    run_top_level_update(args, UpdateScope::Native)
1321}
1322
1323/// Top-level `rpi pi-package update`: update only configured Pi npm/Git
1324/// packages.
1325pub fn run_pi_package_update(args: &[String]) -> i32 {
1326    // Consume the explicit `update` subcommand before handling shared flags.
1327    if args.first().map(String::as_str) == Some("update") {
1328        run_top_level_update(&args[1..], UpdateScope::Pi)
1329    } else {
1330        run_top_level_update(args, UpdateScope::Pi)
1331    }
1332}
1333
1334fn run_top_level_update(args: &[String], scope: UpdateScope) -> i32 {
1335    crate::args::normalize_offline_mode(args);
1336    let args = crate::args::without_offline_flag(args);
1337    let cwd = match std::env::current_dir() {
1338        Ok(path) => path,
1339        Err(error) => {
1340            eprintln!("error: could not determine current directory: {error}");
1341            return 1;
1342        }
1343    };
1344    if args
1345        .iter()
1346        .any(|arg| matches!(arg.as_str(), "--help" | "-h"))
1347    {
1348        print_scoped_update_help(scope);
1349        return 0;
1350    }
1351    let project_trusted = if scope.includes_pi() {
1352        match package_command_project_trusted(&cwd, &args) {
1353            Ok(trusted) => trusted,
1354            Err(error) => {
1355                eprintln!("error: {error}");
1356                return 2;
1357            }
1358        }
1359    } else {
1360        if let Some(arg) = args.first() {
1361            eprintln!("error: unknown native update option `{arg}`");
1362            return 2;
1363        }
1364        false
1365    };
1366    update_packages_with_scope(&cwd, project_trusted, scope)
1367}
1368
1369fn print_help() {
1370    println!(
1371        "Usage: rpi package <command>\n\nCommands:\n  list [--json] [--approve|--no-approve]\n                     List enabled TS packages and installed Rust extensions\n  add <path-or-name> Enable a local/package.json package\n  remove <path-or-name>\n                     Disable a Pi package\n  update [--offline]\n                     Update installed Rust-native extensions\n\nProject packages load by default without confirmation; use --no-approve to disable project package access. TS package resources are loaded from skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md, and extensions. Rust-native extensions are installed with `rpi install`. Pi packages are updated with `rpi pi-package update`."
1372    );
1373}
1374
1375fn print_update_help() {
1376    println!(
1377        "Usage: rpi package update [--offline]\n\nUpdate installed Rust-native extensions only.\n\nUse `rpi pi-package update` for configured Pi npm/Git packages."
1378    );
1379}
1380
1381fn print_scoped_update_help(scope: UpdateScope) {
1382    match scope {
1383        UpdateScope::Native => println!(
1384            "Usage: rpi package update [--offline]\n\nUpdate installed Rust-native extensions only."
1385        ),
1386        UpdateScope::Pi => println!(
1387            "Usage: rpi pi-package update [--approve|--no-approve] [--offline]\n\nUpdate configured Pi npm/Git packages only.\n\nThe rpi CLI itself is updated with `rpi update`."
1388        ),
1389    }
1390}
1391
1392fn package_command_project_trusted(cwd: &Path, args: &[String]) -> Result<bool, String> {
1393    let mut override_value = None;
1394    for arg in args {
1395        let value = match arg.as_str() {
1396            "--approve" | "-a" => Some(true),
1397            "--no-approve" | "-na" => Some(false),
1398            "--json" => None,
1399            value => return Err(format!("unknown package option `{value}`")),
1400        };
1401        if let Some(value) = value {
1402            if override_value.replace(value).is_some() {
1403                return Err("--approve and --no-approve cannot be combined or repeated".to_string());
1404            }
1405        }
1406    }
1407    if let Some(value) = override_value {
1408        return Ok(value);
1409    }
1410    Ok(crate::config::project_trust_decision(cwd)
1411        .map_err(|error| error.to_string())?
1412        .unwrap_or(true))
1413}
1414
1415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1416enum UpdateScope {
1417    Native,
1418    Pi,
1419}
1420
1421impl UpdateScope {
1422    fn includes_native(self) -> bool {
1423        matches!(self, Self::Native)
1424    }
1425
1426    fn includes_pi(self) -> bool {
1427        matches!(self, Self::Pi)
1428    }
1429}
1430
1431fn update_packages_with_scope(cwd: &Path, project_trusted: bool, scope: UpdateScope) -> i32 {
1432    if crate::args::offline_env_enabled() {
1433        println!("package update skipped: offline mode is enabled");
1434        return 0;
1435    }
1436    // Native updates validate their registry before mutating anything. Pi
1437    // package updates independently validate settings before recovering an
1438    // interrupted npm/Git directory swap.
1439    let native = if scope.includes_native() {
1440        match crate::install::installed_native_packages_strict() {
1441            Ok(packages) => packages,
1442            Err(error) => {
1443                eprintln!(
1444                    "error: refusing native package update while metadata is invalid: {error}"
1445                );
1446                return 1;
1447            }
1448        }
1449    } else {
1450        Vec::new()
1451    };
1452    // Load every settings document before performing Pi package recovery or
1453    // invoking a package manager. A malformed active file must make the Pi
1454    // update a no-op rather than silently narrowing the requested package set.
1455    let (resources, preflight_npm_command) = if scope.includes_pi() {
1456        match discover_from_settings_for_update(cwd, project_trusted) {
1457            Ok(result) => result,
1458            Err(error) => {
1459                eprintln!("error: refusing Pi package update with unreadable settings: {error}");
1460                return 1;
1461            }
1462        }
1463    } else {
1464        (PackageResources::default(), None)
1465    };
1466    let blocked = resources
1467        .diagnostics
1468        .iter()
1469        .filter(|diagnostic| diagnostic.blocks_update)
1470        .count();
1471    for diagnostic in &resources.diagnostics {
1472        eprintln!(
1473            "warning: could not load package {}: {}",
1474            diagnostic.spec, diagnostic.message
1475        );
1476    }
1477    if blocked > 0 {
1478        eprintln!("error: refusing a partial package update after discovery failures");
1479        return 1;
1480    }
1481    let needs_package_command = resources.packages.iter().any(|package| {
1482        package.updateable_npm_source().is_some() || package.updateable_git_source()
1483    });
1484    let npm_command = if needs_package_command {
1485        match preflight_npm_command {
1486            Some(command) => Some(command),
1487            None => {
1488                eprintln!("error: package update command was not validated before discovery");
1489                return 1;
1490            }
1491        }
1492    } else {
1493        None
1494    };
1495    let mut failed = 0;
1496    if resources.packages.is_empty() && native.is_empty() {
1497        println!("no packages available for update");
1498        return 0;
1499    }
1500    let mut updated = 0;
1501    let mut skipped = 0;
1502    for package in native {
1503        if package.source.is_some() {
1504            println!(
1505                "skipped local Rust package {} (no registry source)",
1506                package.name
1507            );
1508            skipped += 1;
1509            continue;
1510        }
1511        let args = vec![package.name.clone(), "--force".to_string()];
1512        if crate::install::run(&args) == 0 {
1513            updated += 1;
1514        } else {
1515            eprintln!("warning: could not update Rust package {}", package.name);
1516            failed += 1;
1517        }
1518    }
1519
1520    let mut standalone_npm = Vec::new();
1521    let mut npm_store_roots: BTreeMap<PathBuf, Vec<(String, String)>> = BTreeMap::new();
1522    let mut git_updates = Vec::new();
1523    for package in resources.packages {
1524        if let Some((name, source_spec)) = package.updateable_npm_source() {
1525            match package.npm_store_root_for_update(cwd, project_trusted) {
1526                Ok(Some(install_root)) => {
1527                    npm_store_roots
1528                        .entry(install_root)
1529                        .or_default()
1530                        .push((name.to_string(), source_spec.to_string()));
1531                }
1532                Ok(None) => {
1533                    standalone_npm.push((
1534                        package.root.clone(),
1535                        package.name.clone(),
1536                        name.to_string(),
1537                        source_spec.to_string(),
1538                    ));
1539                }
1540                Err(error) => {
1541                    eprintln!(
1542                        "warning: could not plan update for {}: {error}",
1543                        package.name
1544                    );
1545                    failed += 1;
1546                }
1547            }
1548        } else if package.updateable_git_source() {
1549            git_updates.push(package);
1550        } else {
1551            println!(
1552                "skipped package {} (not an unpinned npm source)",
1553                package.name
1554            );
1555            skipped += 1;
1556        }
1557    }
1558
1559    let npm_update_count = standalone_npm.len()
1560        + npm_store_roots
1561            .values()
1562            .map(std::vec::Vec::len)
1563            .sum::<usize>();
1564    let git_update_count = git_updates.len();
1565    if npm_update_count > 0 || git_update_count > 0 {
1566        match npm_command.as_ref() {
1567            Some(npm_command) => {
1568                for (root, display_name, name, source_spec) in standalone_npm {
1569                    match crate::install_pi::update_npm_package(
1570                        &root,
1571                        &name,
1572                        &source_spec,
1573                        &npm_command,
1574                    ) {
1575                        Ok(_) => {
1576                            println!("updated npm package {display_name}");
1577                            updated += 1;
1578                        }
1579                        Err(error) => {
1580                            eprintln!("warning: could not update {display_name}: {error}");
1581                            failed += 1;
1582                        }
1583                    }
1584                }
1585                for (root, packages) in npm_store_roots {
1586                    match crate::install_pi::update_npm_store_root(
1587                        &root,
1588                        &packages,
1589                        &npm_command,
1590                        cwd,
1591                        project_trusted,
1592                    ) {
1593                        Ok(()) => {
1594                            for (name, _) in &packages {
1595                                println!("updated npm package {name}");
1596                            }
1597                            updated += packages.len();
1598                        }
1599                        Err(error) => {
1600                            let names = packages
1601                                .iter()
1602                                .map(|(name, _)| name.as_str())
1603                                .collect::<Vec<_>>()
1604                                .join(", ");
1605                            eprintln!(
1606                                "warning: could not update npm packages {names} in {}: {error}",
1607                                root.display()
1608                            );
1609                            failed += packages.len();
1610                        }
1611                    }
1612                }
1613                for package in git_updates {
1614                    if package.missing_install {
1615                        match crate::install_pi::install_missing_git_package(
1616                            cwd,
1617                            package.scope == ResolveScope::User,
1618                            &package.spec,
1619                            &npm_command,
1620                        ) {
1621                            Ok(_) => {
1622                                println!("updated git package {}", package.name);
1623                                updated += 1;
1624                            }
1625                            Err(error) => {
1626                                eprintln!("warning: could not update {}: {error}", package.name);
1627                                failed += 1;
1628                            }
1629                        }
1630                        continue;
1631                    }
1632                    let Some(store_root) = package.safe_git_store_root(cwd) else {
1633                        eprintln!(
1634                            "warning: refusing to update git package {} outside a managed git store",
1635                            package.name
1636                        );
1637                        failed += 1;
1638                        continue;
1639                    };
1640                    match crate::install_pi::update_git_package(
1641                        &package.root,
1642                        &store_root,
1643                        &package.spec,
1644                        &npm_command,
1645                    ) {
1646                        Ok(()) => {
1647                            println!("updated git package {}", package.name);
1648                            updated += 1;
1649                        }
1650                        Err(error) => {
1651                            eprintln!("warning: could not update {}: {error}", package.name);
1652                            failed += 1;
1653                        }
1654                    }
1655                }
1656            }
1657            None => unreachable!("package command was preflighted for update candidates"),
1658        }
1659    }
1660    let label = match scope {
1661        UpdateScope::Native => "native package update",
1662        UpdateScope::Pi => "Pi package update",
1663    };
1664    println!("{label} complete: {updated} updated, {skipped} skipped");
1665    i32::from(failed > 0)
1666}
1667
1668fn is_npm_store_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1669    npm_install_root_for_path(path, cwd, scope).is_some()
1670}
1671
1672fn npm_install_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1673    if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1674        if let Some(root) = package_manager_root_for_path(path, cwd, Path::new(".pi/npm")) {
1675            return Some(root);
1676        }
1677    }
1678    if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1679        if let Ok(agent) = config::agent_dir() {
1680            if let Some(root) = package_manager_root_for_path(path, &agent, Path::new("npm")) {
1681                return Some(root);
1682            }
1683        }
1684        if let Some(home) = dirs::home_dir() {
1685            if let Some(root) =
1686                package_manager_root_for_path(path, &home, Path::new(".pi/agent/npm"))
1687            {
1688                return Some(root);
1689            }
1690        }
1691    }
1692    None
1693}
1694
1695fn git_store_roots(cwd: &Path, scope: ResolveScope) -> Vec<PathBuf> {
1696    let mut roots = Vec::new();
1697    if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1698        roots.push(cwd.join(".rpi/git"));
1699        roots.push(cwd.join(".pi/git"));
1700    }
1701    if matches!(scope, ResolveScope::Any | ResolveScope::User) {
1702        if let Ok(agent) = config::agent_dir() {
1703            roots.push(agent.join("git"));
1704        }
1705        if let Some(home) = dirs::home_dir() {
1706            roots.push(home.join(".pi/agent/git"));
1707        }
1708    }
1709    roots
1710}
1711
1712/// Return the native Pi git checkout for a URL only when both the store and
1713/// the checkout are real directories (no symlink/junction traversal) and the
1714/// relative host/path matches exactly. This keeps `git pull` authority inside
1715/// the configured store.
1716fn native_git_target_for_spec(cwd: &Path, scope: ResolveScope, git: &GitSpec) -> Option<PathBuf> {
1717    let relative = Path::new(&git.host).join(&git.path);
1718    for lexical_root in git_store_roots(cwd, scope) {
1719        let Ok(canonical_root_raw) = std::fs::canonicalize(&lexical_root) else {
1720            continue;
1721        };
1722        let canonical_root = normalize_resource_path(canonical_root_raw);
1723        if canonical_root != lexical_root {
1724            continue;
1725        }
1726        let target = lexical_root.join(&relative);
1727        let Ok(canonical_target_raw) = std::fs::canonicalize(&target) else {
1728            continue;
1729        };
1730        let canonical_target = normalize_resource_path(canonical_target_raw);
1731        if canonical_target == target
1732            && canonical_target.starts_with(&canonical_root)
1733            && canonical_target
1734                .strip_prefix(&canonical_root)
1735                .ok()
1736                .is_some_and(|value| value.components().count() == relative.components().count())
1737            && is_real_git_metadata(&canonical_target.join(".git"))
1738        {
1739            return Some(canonical_target);
1740        }
1741    }
1742    None
1743}
1744
1745fn is_native_git_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1746    let Ok(canonical_path) = std::fs::canonicalize(path) else {
1747        return false;
1748    };
1749    let canonical_path = normalize_resource_path(canonical_path);
1750    if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1751        return false;
1752    }
1753    git_store_roots(cwd, scope).into_iter().any(|root| {
1754        let Ok(canonical_root_raw) = std::fs::canonicalize(&root) else {
1755            return false;
1756        };
1757        let canonical_root = normalize_resource_path(canonical_root_raw);
1758        canonical_root == root
1759            && canonical_path
1760                .strip_prefix(&canonical_root)
1761                .ok()
1762                .is_some_and(|relative| relative.components().count() >= 2)
1763    })
1764}
1765
1766fn native_git_store_root_for_path(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1767    let Ok(canonical_path_raw) = std::fs::canonicalize(path) else {
1768        return None;
1769    };
1770    let canonical_path = normalize_resource_path(canonical_path_raw);
1771    if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1772        return None;
1773    }
1774    git_store_roots(cwd, scope).into_iter().find_map(|root| {
1775        let canonical_root = normalize_resource_path(std::fs::canonicalize(&root).ok()?);
1776        if canonical_root != root {
1777            return None;
1778        }
1779        let relative = canonical_path.strip_prefix(&canonical_root).ok()?;
1780        (relative.components().count() >= 2).then_some(canonical_root)
1781    })
1782}
1783
1784fn is_direct_managed_package_root(path: &Path, cwd: &Path, scope: ResolveScope) -> Option<PathBuf> {
1785    let canonical_path = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1786    if canonical_path != path || !is_real_git_metadata(&canonical_path.join(".git")) {
1787        return None;
1788    }
1789    let mut stores = Vec::new();
1790    if let Ok(agent) = config::agent_dir() {
1791        stores.push(agent.join("packages"));
1792    }
1793    if let Some(home) = dirs::home_dir() {
1794        stores.push(home.join(".pi/agent/packages"));
1795    }
1796    if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
1797        stores.push(cwd.join(".rpi/packages"));
1798        stores.push(cwd.join(".pi/packages"));
1799    }
1800    let parent = canonical_path.parent()?;
1801    stores
1802        .iter()
1803        .find(|store| {
1804            std::fs::canonicalize(store)
1805                .ok()
1806                .map(normalize_resource_path)
1807                .is_some_and(|canonical| canonical == **store)
1808                && parent == store.as_path()
1809        })
1810        .cloned()
1811}
1812
1813fn is_real_git_metadata(path: &Path) -> bool {
1814    std::fs::symlink_metadata(path)
1815        // A git worktree stores `.git` as a file containing a `gitdir:` pointer.
1816        // Treating that pointer as package metadata could make the update
1817        // command operate on a repository outside the managed store. Only a
1818        // real directory is therefore eligible for automatic updates.
1819        .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
1820        .unwrap_or(false)
1821}
1822
1823/// Recognize exactly one npm package below a Pi-managed install root. Lexical
1824/// shape prevents nested dependencies from gaining update authority, while
1825/// the canonical containment check permits pnpm links only when they resolve
1826/// back inside the same install root.
1827fn package_manager_root_for_path(
1828    path: &Path,
1829    base: &Path,
1830    relative_install_root: &Path,
1831) -> Option<PathBuf> {
1832    let lexical_install_root = base.join(relative_install_root);
1833    let lexical_node_modules = lexical_install_root.join("node_modules");
1834    let relative = path.strip_prefix(&lexical_node_modules).ok()?;
1835    let parts = relative
1836        .components()
1837        .map(|component| match component {
1838            std::path::Component::Normal(part) => part.to_str(),
1839            _ => None,
1840        })
1841        .collect::<Option<Vec<_>>>()?;
1842    let valid_shape = match parts.as_slice() {
1843        [name] => !name.starts_with('@') && !name.is_empty(),
1844        [scope, name] => scope.starts_with('@') && scope.len() > 1 && !name.is_empty(),
1845        _ => false,
1846    };
1847    if !valid_shape {
1848        return None;
1849    }
1850
1851    let base = std::fs::canonicalize(base).ok()?;
1852    let install_root = base.join(relative_install_root);
1853    if normalize_resource_path(std::fs::canonicalize(&install_root).ok()?)
1854        != normalize_resource_path(install_root.clone())
1855    {
1856        return None;
1857    }
1858    let node_modules = install_root.join("node_modules");
1859    if normalize_resource_path(std::fs::canonicalize(&node_modules).ok()?)
1860        != normalize_resource_path(node_modules.clone())
1861    {
1862        return None;
1863    }
1864    let canonical_package = normalize_resource_path(std::fs::canonicalize(path).ok()?);
1865    let install_root_normalized = normalize_resource_path(install_root.clone());
1866    canonical_package
1867        .starts_with(&install_root_normalized)
1868        .then_some(install_root)
1869}
1870
1871fn is_package_below_store(path: &Path, base: &Path, relative_store: &Path) -> bool {
1872    let Ok(path) = std::fs::canonicalize(path) else {
1873        return false;
1874    };
1875    let Ok(base) = std::fs::canonicalize(base) else {
1876        return false;
1877    };
1878    let Ok(store) = std::fs::canonicalize(base.join(relative_store)) else {
1879        return false;
1880    };
1881    if store != base.join(relative_store) || !store.starts_with(&base) {
1882        return false;
1883    }
1884    path.strip_prefix(store)
1885        .ok()
1886        .is_some_and(|relative| relative.components().next().is_some())
1887}
1888
1889fn is_managed_package_path(path: &Path, cwd: &Path, scope: ResolveScope) -> bool {
1890    if let Ok(agent) = config::agent_dir() {
1891        if is_package_below_store(path, &agent, Path::new("packages")) {
1892            return true;
1893        }
1894    }
1895    if let Some(home) = dirs::home_dir() {
1896        if is_package_below_store(path, &home, Path::new(".pi/agent/packages")) {
1897            return true;
1898        }
1899    }
1900    (scope == ResolveScope::Any
1901        && (is_package_below_store(path, cwd, Path::new(".rpi/packages"))
1902            || is_package_below_store(path, cwd, Path::new(".pi/packages"))))
1903        || is_project_managed_package_path(path)
1904}
1905
1906/// Installed project packages are persisted as absolute `file:` entries in
1907/// user settings, so they must remain recognizable after the process changes
1908/// working directory. Canonicalizing first prevents a symlink placed at this
1909/// shape from granting update permission to an arbitrary target directory.
1910fn is_project_managed_package_path(path: &Path) -> bool {
1911    let Ok(path) = std::fs::canonicalize(path) else {
1912        return false;
1913    };
1914    let Some(store) = path.parent() else {
1915        return false;
1916    };
1917    let Some(project_config) = store.parent() else {
1918        return false;
1919    };
1920    store.file_name().is_some_and(|name| name == "packages")
1921        && project_config
1922            .file_name()
1923            .is_some_and(|name| name == ".rpi" || name == ".pi")
1924}
1925
1926impl PackageRoot {
1927    fn npm_store_root_for_update(
1928        &self,
1929        cwd: &Path,
1930        project_trusted: bool,
1931    ) -> Result<Option<PathBuf>, String> {
1932        if let Some(root) = &self.npm_install_root {
1933            return Ok(Some(root.clone()));
1934        }
1935        if self.legacy_npm_root.is_none() {
1936            return Ok(None);
1937        }
1938        if !matches!(self.source, PackageSource::Npm { .. }) {
1939            return Err(
1940                "refusing legacy npm migration without verified npm provenance".to_string(),
1941            );
1942        }
1943
1944        let root = match self.scope {
1945            ResolveScope::Project if project_trusted => cwd.join(".pi/npm"),
1946            ResolveScope::Project => {
1947                return Err(
1948                    "refusing to migrate a legacy npm package for an untrusted project".to_string(),
1949                )
1950            }
1951            ResolveScope::User | ResolveScope::Any => config::agent_dir()
1952                .map_err(|error| error.to_string())?
1953                .join("npm"),
1954        };
1955        if !root.is_absolute() || root.file_name().and_then(|name| name.to_str()) != Some("npm") {
1956            return Err(format!(
1957                "refusing legacy npm migration outside a managed npm root: {}",
1958                root.display()
1959            ));
1960        }
1961        Ok(Some(root))
1962    }
1963
1964    pub(crate) fn updateable_npm_source(&self) -> Option<(&str, &str)> {
1965        match &self.source {
1966            PackageSource::Npm {
1967                name, spec, pinned, ..
1968            } if self.missing_install || !*pinned => Some((name, spec)),
1969            _ => None,
1970        }
1971    }
1972
1973    fn updateable_git_source(&self) -> bool {
1974        // Native Pi treats a Git ref as a configured checkout target. Manual
1975        // update reconciles it as well; only automatic update notifications
1976        // skip pinned sources.
1977        matches!(self.source, PackageSource::Git)
1978    }
1979
1980    fn safe_git_store_root(&self, cwd: &Path) -> Option<PathBuf> {
1981        self.git_store_root.clone().or_else(|| {
1982            // rpi's legacy git clones live as direct children of a managed
1983            // package store. Native stores carry an explicit root above.
1984            is_direct_managed_package_root(&self.root, cwd, self.scope)
1985        })
1986    }
1987
1988    #[cfg(test)]
1989    pub(crate) fn updateable_npm_name(&self) -> Option<&str> {
1990        self.updateable_npm_source().map(|(name, _)| name)
1991    }
1992
1993    fn skill_dirs_for_display(&self) -> Vec<PathBuf> {
1994        self.skills.clone()
1995    }
1996
1997    fn prompt_dirs_for_display(&self) -> Vec<PathBuf> {
1998        self.prompts.clone()
1999    }
2000
2001    fn theme_files_for_display(&self) -> Vec<PathBuf> {
2002        if self.themes.len() == 1 && self.themes[0].is_dir() {
2003            let mut files: Vec<PathBuf> = std::fs::read_dir(&self.themes[0])
2004                .ok()
2005                .into_iter()
2006                .flatten()
2007                .filter_map(Result::ok)
2008                .map(|entry| entry.path())
2009                .filter(|file| {
2010                    file.is_file() && file.extension().and_then(|ext| ext.to_str()) == Some("json")
2011                })
2012                .collect();
2013            files.sort();
2014            files
2015        } else {
2016            self.themes.clone()
2017        }
2018    }
2019}
2020
2021#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2022enum ResolveScope {
2023    Any,
2024    Project,
2025    User,
2026}
2027
2028#[derive(Debug)]
2029struct ResolvedPackagePath {
2030    root: PathBuf,
2031    /// Set only when the path came from a validated package-manager global
2032    /// lookup. Static rpi/Pi stores leave this unset.
2033    legacy_npm_root: Option<PathBuf>,
2034}
2035
2036fn resolve_spec(cwd: &Path, spec: &str, scope: ResolveScope) -> Option<PathBuf> {
2037    resolve_spec_with_legacy_lookup(cwd, spec, scope, |_| None).map(|resolved| resolved.root)
2038}
2039
2040fn resolve_spec_with_command(
2041    cwd: &Path,
2042    spec: &str,
2043    scope: ResolveScope,
2044    global_npm_command: Option<&crate::npm::NpmCommand>,
2045    legacy_npm_names: &[String],
2046    legacy_npm_paths: &mut Option<HashMap<String, PathBuf>>,
2047) -> Option<ResolvedPackagePath> {
2048    resolve_spec_with_legacy_lookup(cwd, spec, scope, |package_name| {
2049        let command = global_npm_command?;
2050        let paths = legacy_npm_paths.get_or_insert_with(|| {
2051            command
2052                .global_package_paths(legacy_npm_names)
2053                .unwrap_or_default()
2054        });
2055        paths.get(package_name).cloned()
2056    })
2057}
2058
2059fn resolve_spec_with_legacy_lookup(
2060    cwd: &Path,
2061    spec: &str,
2062    scope: ResolveScope,
2063    legacy_lookup: impl FnOnce(&str) -> Option<PathBuf>,
2064) -> Option<ResolvedPackagePath> {
2065    let file_spec = spec.strip_prefix("file:");
2066    let raw = file_spec.unwrap_or(spec);
2067    // `npm:` is a package-source prefix, not part of the on-disk package
2068    // name. Keeping it in the candidates makes installed npm packages look
2069    // like directories literally named `npm:...`.
2070    let npm_spec = raw.strip_prefix("npm:");
2071    let npm_name = npm_spec.unwrap_or(raw);
2072    let package_name = match npm_spec {
2073        Some(spec) => parse_npm_package_spec(spec)?.install_name,
2074        None => package_name_without_version(npm_name).to_string(),
2075    };
2076    let package_key = package_name
2077        .strip_prefix('@')
2078        .unwrap_or(&package_name)
2079        .replace('/', "__");
2080    let direct = PathBuf::from(npm_name);
2081    let mut candidates = Vec::new();
2082    if direct.is_absolute() {
2083        candidates.push(direct);
2084    } else {
2085        let explicit_relative_path =
2086            file_spec.is_some() || npm_name.starts_with('.') || npm_name.starts_with("./");
2087        if explicit_relative_path {
2088            if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2089                // Native Pi resolves project-local package paths from the
2090                // project config directory (`.pi`); rpi's preferred `.rpi`
2091                // directory is accepted first for its own settings.
2092                candidates.push(cwd.join(".rpi").join(&direct));
2093                candidates.push(cwd.join(".pi").join(&direct));
2094                // Keep the historical cwd-relative fallback for callers of
2095                // the public `discover` helper and old rpi settings.
2096                candidates.push(cwd.join(&direct));
2097            } else {
2098                if let Ok(agent) = config::agent_dir() {
2099                    candidates.push(agent.join(&direct));
2100                }
2101                if let Some(home) = dirs::home_dir() {
2102                    candidates.push(home.join(".pi/agent").join(&direct));
2103                }
2104            }
2105        }
2106        if let Some(git) = parse_git_source(spec) {
2107            if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2108                for relative_root in [Path::new(".rpi/git"), Path::new(".pi/git")] {
2109                    candidates.push(cwd.join(relative_root).join(&git.host).join(&git.path));
2110                }
2111            }
2112            if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2113                if let Ok(agent) = config::agent_dir() {
2114                    candidates.push(agent.join("git").join(&git.host).join(&git.path));
2115                }
2116                if let Some(home) = dirs::home_dir() {
2117                    candidates.push(home.join(".pi/agent/git").join(&git.host).join(&git.path));
2118                }
2119            }
2120        }
2121        // Prefer rpi-owned package stores over native Pi stores and generic
2122        // node_modules when a bare package name resolves in more than one
2123        // place.
2124        if matches!(scope, ResolveScope::Any | ResolveScope::Project) {
2125            candidates.push(cwd.join(".rpi/packages").join(&package_name));
2126            if package_key != package_name {
2127                candidates.push(cwd.join(".rpi/packages").join(&package_key));
2128            }
2129            candidates.push(cwd.join(".pi/packages").join(&package_name));
2130            if package_key != package_name {
2131                candidates.push(cwd.join(".pi/packages").join(&package_key));
2132            }
2133            if npm_spec.is_some() {
2134                candidates.push(cwd.join(".pi/npm/node_modules").join(&package_name));
2135            } else if scope == ResolveScope::Any {
2136                for ancestor in cwd.ancestors() {
2137                    candidates.push(ancestor.join("node_modules").join(&package_name));
2138                }
2139            }
2140        }
2141        if matches!(scope, ResolveScope::Any | ResolveScope::User) {
2142            if let Ok(agent) = config::agent_dir() {
2143                candidates.push(agent.join("packages").join(&package_name));
2144                if package_key != package_name {
2145                    candidates.push(agent.join("packages").join(&package_key));
2146                }
2147                // Pi's native npm installer keeps packages under
2148                // ~/.pi/agent/npm/node_modules rather than ~/.pi/agent/packages.
2149                // Keep the same layout usable when rpi reads Pi's settings.json.
2150                candidates.push(agent.join("npm/node_modules").join(&package_name));
2151                if package_key != package_name {
2152                    candidates.push(agent.join("npm/node_modules").join(&package_key));
2153                }
2154            }
2155            if let Some(home) = dirs::home_dir() {
2156                // Keep native Pi's installed package store usable when the user
2157                // has not copied it into the rpi-owned config directory yet.
2158                candidates.push(home.join(".pi/agent/packages").join(&package_name));
2159                if package_key != package_name {
2160                    candidates.push(home.join(".pi/agent/packages").join(&package_key));
2161                }
2162                candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_name));
2163                if package_key != package_name {
2164                    candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_key));
2165                }
2166            }
2167        }
2168        if scope == ResolveScope::Any && npm_spec.is_none() && !explicit_relative_path {
2169            candidates.push(cwd.join(&package_name));
2170        }
2171    }
2172    for candidate in candidates {
2173        if candidate.is_file()
2174            && candidate.file_name().and_then(|s| s.to_str()) == Some("package.json")
2175        {
2176            return candidate.parent().map(|root| ResolvedPackagePath {
2177                root: root.to_path_buf(),
2178                legacy_npm_root: None,
2179            });
2180        }
2181        if candidate.is_dir() {
2182            return Some(ResolvedPackagePath {
2183                root: candidate,
2184                legacy_npm_root: None,
2185            });
2186        }
2187    }
2188
2189    // Native Pi can still load a package installed by the user's global
2190    // package manager. This is a read-only compatibility lookup: the command
2191    // validates and canonicalizes the package path, while update code later
2192    // migrates it into the controlled rpi/native npm store.
2193    if npm_spec.is_some() && matches!(scope, ResolveScope::Any | ResolveScope::User) {
2194        let reported = legacy_lookup(&package_name)?;
2195        let reported = std::fs::canonicalize(reported).ok()?;
2196        let install_root = global_node_modules_root(&reported)?;
2197        // Repeat the direct-child/canonical containment check at the package
2198        // boundary. Even a future lookup implementation cannot turn an
2199        // arbitrary command output path into update/delete authority.
2200        let root =
2201            crate::npm::NpmCommand::validate_global_package_path(&install_root, &package_name)?;
2202        if root != reported {
2203            return None;
2204        }
2205        return Some(ResolvedPackagePath {
2206            root,
2207            legacy_npm_root: Some(install_root),
2208        });
2209    }
2210    None
2211}
2212
2213fn global_node_modules_root(package: &Path) -> Option<PathBuf> {
2214    let mut current = package.parent()?;
2215    loop {
2216        if current.file_name().and_then(|name| name.to_str()) == Some("node_modules") {
2217            return std::fs::canonicalize(current).ok().filter(|root| {
2218                root.is_absolute()
2219                    && root.file_name().and_then(|name| name.to_str()) == Some("node_modules")
2220            });
2221        }
2222        current = current.parent()?;
2223    }
2224}
2225
2226/// Strip an npm version suffix while preserving the `@scope/name` portion.
2227fn package_name_without_version(name: &str) -> &str {
2228    if let Some(rest) = name.strip_prefix('@') {
2229        rest.find('@')
2230            .map(|index| &name[..index + 1])
2231            .unwrap_or(name)
2232    } else {
2233        name.split('@').next().unwrap_or(name)
2234    }
2235}
2236
2237/// Build the same collision identity native Pi uses: npm package names ignore
2238/// the requested range/tag, git packages use their normalized repository
2239/// identity, and local packages use their canonical path. Manifest names are
2240/// deliberately not used because two independent packages may publish the
2241/// same display name.
2242fn package_identity(package: &PackageRoot) -> String {
2243    match &package.source {
2244        PackageSource::Npm { name, .. } => format!("npm:{}", name.to_ascii_lowercase()),
2245        PackageSource::Git => parse_git_source(&package.spec)
2246            .map(|git| format!("git:{}/{}", git.host, git.path))
2247            .unwrap_or_else(|| format!("git:path:{}", normalize_key(&package.root))),
2248        PackageSource::Local => format!("local:{}", normalize_key(&package.root)),
2249        PackageSource::Unknown => format!("unknown:{}", normalize_key(&package.root)),
2250    }
2251}
2252
2253#[derive(Debug, Clone, PartialEq, Eq)]
2254pub(crate) struct GitSpec {
2255    pub(crate) host: String,
2256    pub(crate) path: String,
2257    pub(crate) revision: Option<String>,
2258    pub(crate) transport: GitTransport,
2259    pub(crate) port: Option<u16>,
2260    pub(crate) user_info: Option<String>,
2261}
2262
2263#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2264pub(crate) enum GitTransport {
2265    Http,
2266    Https,
2267    Ssh,
2268    Git,
2269}
2270
2271impl GitTransport {
2272    pub(crate) fn default_port(self) -> u16 {
2273        match self {
2274            Self::Http => 80,
2275            Self::Https => 443,
2276            Self::Ssh => 22,
2277            Self::Git => 9418,
2278        }
2279    }
2280}
2281
2282pub(crate) fn parse_git_source(spec: &str) -> Option<GitSpec> {
2283    let trimmed = spec.trim();
2284    if trimmed.is_empty() {
2285        return None;
2286    }
2287
2288    // `git:` is Pi's source prefix, while `git://` is also a valid transport
2289    // URL. Do not strip the latter's scheme accidentally.
2290    let raw = match trimmed.strip_prefix("git:") {
2291        Some(rest) if !rest.starts_with("//") => rest.trim(),
2292        _ => trimmed,
2293    };
2294    if raw.is_empty() {
2295        return None;
2296    }
2297
2298    // Native Pi splits the first `@` in the repository path, not the last
2299    // one. This preserves refs such as `feature/branch` and avoids treating
2300    // URL user-info (`git@host`) as a ref.
2301    let (repo, revision) = split_git_ref(raw);
2302    let (mut host, mut path, transport, port, user_info) =
2303        if let Some(scheme_end) = repo.find("://") {
2304            let scheme = repo[..scheme_end].to_ascii_lowercase();
2305            let transport = match scheme.as_str() {
2306                "http" => GitTransport::Http,
2307                "https" => GitTransport::Https,
2308                "ssh" => GitTransport::Ssh,
2309                "git" => GitTransport::Git,
2310                _ => return None,
2311            };
2312            let authority_and_path = &repo[scheme_end + 3..];
2313            let (authority, path) = authority_and_path.split_once('/')?;
2314            let (host, port, user_info) = parse_git_authority(authority)?;
2315            (host, path.to_string(), transport, port, user_info)
2316        } else if let Some(rest) = repo.strip_prefix("git@") {
2317            let (host, path) = rest.split_once(':')?;
2318            (
2319                normalize_git_host(host)?,
2320                path.to_string(),
2321                GitTransport::Ssh,
2322                None,
2323                Some("git".to_string()),
2324            )
2325        } else {
2326            // Historical `git:github.com/user/repo` shorthand.
2327            let (host, path) = repo.split_once('/')?;
2328            (
2329                normalize_git_host(host)?,
2330                path.to_string(),
2331                GitTransport::Https,
2332                None,
2333                None,
2334            )
2335        };
2336
2337    host.make_ascii_lowercase();
2338    while path.starts_with('/') {
2339        path.remove(0);
2340    }
2341    if path.ends_with(".git") {
2342        path.truncate(path.len() - 4);
2343    }
2344    let path = path.trim_matches('/').to_string();
2345
2346    if !safe_git_install_part(&host, false)
2347        || !safe_git_install_part(&path, true)
2348        || path.split('/').count() < 2
2349    {
2350        return None;
2351    }
2352    if revision
2353        .as_deref()
2354        .is_some_and(|value| !safe_git_revision(value))
2355    {
2356        return None;
2357    }
2358
2359    Some(GitSpec {
2360        host,
2361        path,
2362        revision,
2363        transport,
2364        port,
2365        user_info,
2366    })
2367}
2368
2369/// Split a git URL into its repository and optional ref. The separator is
2370/// searched only after the URL authority, matching the upstream Pi parser.
2371fn split_git_ref(raw: &str) -> (String, Option<String>) {
2372    let path_start = if raw.starts_with("git@") {
2373        raw.find(':').map(|index| index + 1)
2374    } else if let Some(scheme_end) = raw.find("://") {
2375        let authority_start = scheme_end + 3;
2376        raw[authority_start..]
2377            .find('/')
2378            .map(|index| authority_start + index + 1)
2379    } else {
2380        raw.find('/').map(|index| index + 1)
2381    };
2382    let Some(path_start) = path_start else {
2383        return (raw.to_string(), None);
2384    };
2385    let Some(offset) = raw[path_start..].find('@') else {
2386        return (raw.to_string(), None);
2387    };
2388    let separator = path_start + offset;
2389    let repo = &raw[..separator];
2390    let revision = &raw[separator + 1..];
2391    if repo.is_empty() || revision.is_empty() {
2392        return (raw.to_string(), None);
2393    }
2394    (repo.to_string(), Some(revision.to_string()))
2395}
2396
2397fn normalize_git_host(authority: &str) -> Option<String> {
2398    if authority != authority.trim() {
2399        return None;
2400    }
2401    let authority = authority.trim();
2402    if authority.is_empty() {
2403        return None;
2404    }
2405    // URL.hostname excludes user-info and a numeric port. Keep the same
2406    // identity semantics while rejecting ambiguous/malformed authorities.
2407    let host = if authority.starts_with('[') {
2408        let end = authority.find(']')?;
2409        if !authority[end + 1..].is_empty() {
2410            let suffix = &authority[end + 1..];
2411            if !suffix.starts_with(':') || !suffix[1..].bytes().all(|byte| byte.is_ascii_digit()) {
2412                return None;
2413            }
2414        }
2415        &authority[1..end]
2416    } else {
2417        authority
2418            .rsplit_once(':')
2419            .filter(|(_, port)| !port.is_empty() && port.bytes().all(|byte| byte.is_ascii_digit()))
2420            .map_or(authority, |(host, _)| host)
2421    };
2422    Some(host.to_string())
2423}
2424
2425fn parse_git_authority(authority: &str) -> Option<(String, Option<u16>, Option<String>)> {
2426    if authority.is_empty() || authority != authority.trim() {
2427        return None;
2428    }
2429    let authority = authority.trim();
2430    let (user_info, host_and_port) = match authority.rsplit_once('@') {
2431        Some((user_info, host_and_port)) => {
2432            if user_info.is_empty()
2433                || user_info.contains('\\')
2434                || user_info
2435                    .chars()
2436                    .any(|character| character.is_control() || character.is_whitespace())
2437            {
2438                return None;
2439            }
2440            (Some(user_info.to_string()), host_and_port)
2441        }
2442        None => (None, authority),
2443    };
2444    let (host, port) = if host_and_port.starts_with('[') {
2445        let end = host_and_port.find(']')?;
2446        let suffix = &host_and_port[end + 1..];
2447        let port = if suffix.is_empty() {
2448            None
2449        } else {
2450            suffix.strip_prefix(':')?.parse::<u16>().ok()
2451        };
2452        (&host_and_port[..=end], port)
2453    } else if let Some((host, port)) = host_and_port.rsplit_once(':') {
2454        if port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) {
2455            return None;
2456        }
2457        (host, Some(port.parse::<u16>().ok()?))
2458    } else {
2459        (host_and_port, None)
2460    };
2461    Some((normalize_git_host(host)?, port, user_info))
2462}
2463
2464fn safe_git_install_part(value: &str, allow_slash: bool) -> bool {
2465    let Some(decoded) = percent_decode_for_validation(value) else {
2466        return false;
2467    };
2468    for candidate in [value, decoded.as_str()] {
2469        if candidate.is_empty()
2470            || candidate.contains('\0')
2471            || candidate.contains('\\')
2472            || candidate.starts_with('/')
2473            || candidate
2474                .chars()
2475                .any(|ch| ch.is_control() || ch.is_whitespace())
2476            || candidate
2477                .chars()
2478                .any(|ch| matches!(ch, ':' | '?' | '*' | '[' | ']' | '<' | '>' | '|' | '"'))
2479        {
2480            return false;
2481        }
2482        if !allow_slash && candidate.contains('/') {
2483            return false;
2484        }
2485        if candidate
2486            .split('/')
2487            .any(|part| part.is_empty() || part == "." || part == "..")
2488        {
2489            return false;
2490        }
2491    }
2492    true
2493}
2494
2495fn safe_git_revision(value: &str) -> bool {
2496    let Some(decoded) = percent_decode_for_validation(value) else {
2497        return false;
2498    };
2499    for candidate in [value, decoded.as_str()] {
2500        if candidate.is_empty()
2501            || candidate.starts_with('-')
2502            || candidate.starts_with('/')
2503            || candidate.ends_with('/')
2504            || candidate.contains('\0')
2505            || candidate.contains('\\')
2506            || candidate.contains("..")
2507            || candidate.contains("@{")
2508            || candidate.chars().any(|ch| {
2509                ch.is_control()
2510                    || ch.is_whitespace()
2511                    || matches!(ch, '~' | '^' | ':' | '?' | '*' | '[')
2512            })
2513            || candidate
2514                .split('/')
2515                .any(|part| part.is_empty() || part == "." || part == "..")
2516        {
2517            return false;
2518        }
2519    }
2520    true
2521}
2522
2523fn percent_decode_for_validation(value: &str) -> Option<String> {
2524    let bytes = value.as_bytes();
2525    let mut decoded = Vec::with_capacity(bytes.len());
2526    let mut index = 0;
2527    while index < bytes.len() {
2528        if bytes[index] == b'%' {
2529            if index + 2 >= bytes.len() {
2530                return None;
2531            }
2532            let high = hex_value(bytes[index + 1])?;
2533            let low = hex_value(bytes[index + 2])?;
2534            decoded.push((high << 4) | low);
2535            index += 3;
2536        } else {
2537            decoded.push(bytes[index]);
2538            index += 1;
2539        }
2540    }
2541    String::from_utf8(decoded).ok()
2542}
2543
2544fn hex_value(value: u8) -> Option<u8> {
2545    match value {
2546        b'0'..=b'9' => Some(value - b'0'),
2547        b'a'..=b'f' => Some(value - b'a' + 10),
2548        b'A'..=b'F' => Some(value - b'A' + 10),
2549        _ => None,
2550    }
2551}
2552
2553fn safe_resource_path(root: &Path, value: &str) -> Option<PathBuf> {
2554    safe_resource_path_from(root, root, value)
2555}
2556
2557fn safe_resource_path_from(boundary: &Path, base: &Path, value: &str) -> Option<PathBuf> {
2558    let relative = Path::new(value.trim());
2559    if relative.as_os_str().is_empty() || relative.is_absolute() {
2560        return None;
2561    }
2562    let base = normalize_resource_path(std::fs::canonicalize(base).ok()?);
2563    let candidate = normalize_resource_path(base.join(relative));
2564    validated_resource_path(boundary, &candidate).map(|_| candidate)
2565}
2566
2567fn validated_resource_path(boundary: &Path, candidate: &Path) -> Option<PathBuf> {
2568    let boundary = normalize_resource_path(std::fs::canonicalize(boundary).ok()?);
2569    let canonical = normalize_resource_path(std::fs::canonicalize(candidate).ok()?);
2570    resource_path_is_within(&canonical, &boundary).then_some(canonical)
2571}
2572
2573fn resource_path_is_within(path: &Path, root: &Path) -> bool {
2574    #[cfg(not(windows))]
2575    {
2576        path.starts_with(root)
2577    }
2578    #[cfg(windows)]
2579    {
2580        let path: Vec<String> = path
2581            .components()
2582            .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2583            .collect();
2584        let root: Vec<String> = root
2585            .components()
2586            .map(|part| part.as_os_str().to_string_lossy().to_lowercase())
2587            .collect();
2588        path.len() >= root.len() && path[..root.len()] == root
2589    }
2590}
2591
2592fn normalize_resource_path(path: PathBuf) -> PathBuf {
2593    #[cfg(windows)]
2594    {
2595        let text = path.to_string_lossy();
2596        if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") {
2597            return PathBuf::from(format!(r"\\{stripped}"));
2598        }
2599        if let Some(stripped) = text.strip_prefix(r"\\?\") {
2600            return PathBuf::from(stripped);
2601        }
2602    }
2603    path
2604}
2605
2606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2607enum FilterResourceKind {
2608    Extensions,
2609    Skills,
2610    Prompts,
2611    Themes,
2612}
2613
2614fn apply_package_filter(
2615    root: &Path,
2616    extensions: &mut Vec<PathBuf>,
2617    skills: &mut Vec<PathBuf>,
2618    prompts: &mut Vec<PathBuf>,
2619    themes: &mut Vec<PathBuf>,
2620    filter: &crate::settings::PackageFilter,
2621) {
2622    *extensions = filter_paths(
2623        root,
2624        extensions,
2625        filter.extensions.as_deref(),
2626        filter.autoload,
2627        FilterResourceKind::Extensions,
2628    );
2629    *skills = filter_paths(
2630        root,
2631        skills,
2632        filter.skills.as_deref(),
2633        filter.autoload,
2634        FilterResourceKind::Skills,
2635    );
2636    *prompts = filter_paths(
2637        root,
2638        prompts,
2639        filter.prompts.as_deref(),
2640        filter.autoload,
2641        FilterResourceKind::Prompts,
2642    );
2643    *themes = filter_paths(
2644        root,
2645        themes,
2646        filter.themes.as_deref(),
2647        filter.autoload,
2648        FilterResourceKind::Themes,
2649    );
2650}
2651
2652fn filter_paths(
2653    root: &Path,
2654    defaults: &[PathBuf],
2655    patterns: Option<&[String]>,
2656    autoload: Option<bool>,
2657    kind: FilterResourceKind,
2658) -> Vec<PathBuf> {
2659    let Some(patterns) = patterns else {
2660        return if autoload == Some(false) {
2661            Vec::new()
2662        } else {
2663            defaults.to_vec()
2664        };
2665    };
2666    if patterns.is_empty() && autoload != Some(false) {
2667        // An explicitly empty resource array disables that resource kind in
2668        // native Pi; it is different from an omitted property.
2669        return Vec::new();
2670    }
2671    let pattern_root =
2672        normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2673    let all = resource_inventory(&pattern_root, defaults, kind);
2674    if autoload == Some(false) {
2675        let mut enabled = HashSet::new();
2676        for pattern in patterns {
2677            let (mode, target) = pattern_mode(pattern);
2678            let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2679            for path in &all {
2680                if matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2681                    match mode {
2682                        PatternMode::Exclude | PatternMode::ForceExclude => {
2683                            enabled.remove(path);
2684                        }
2685                        PatternMode::Include | PatternMode::ForceInclude => {
2686                            enabled.insert(path.clone());
2687                        }
2688                    }
2689                }
2690            }
2691        }
2692        return sorted_paths(enabled.into_iter().collect());
2693    }
2694    apply_resource_patterns(&all, patterns, &pattern_root, kind)
2695}
2696
2697fn apply_autoload_delta_to_package(
2698    package: &mut PackageRoot,
2699    filter: &crate::settings::PackageFilter,
2700) {
2701    if filter.autoload != Some(false) {
2702        return;
2703    }
2704    if let Some(patterns) = filter.extensions.as_deref() {
2705        package.extensions = apply_delta_paths(
2706            &package.root,
2707            &package.extensions,
2708            patterns,
2709            FilterResourceKind::Extensions,
2710        );
2711    }
2712    if let Some(patterns) = filter.skills.as_deref() {
2713        package.skills = apply_delta_paths(
2714            &package.root,
2715            &package.skills,
2716            patterns,
2717            FilterResourceKind::Skills,
2718        );
2719    }
2720    if let Some(patterns) = filter.prompts.as_deref() {
2721        package.prompts = apply_delta_paths(
2722            &package.root,
2723            &package.prompts,
2724            patterns,
2725            FilterResourceKind::Prompts,
2726        );
2727    }
2728    if let Some(patterns) = filter.themes.as_deref() {
2729        package.themes = apply_delta_paths(
2730            &package.root,
2731            &package.themes,
2732            patterns,
2733            FilterResourceKind::Themes,
2734        );
2735    }
2736}
2737
2738fn apply_delta_paths(
2739    root: &Path,
2740    current: &[PathBuf],
2741    patterns: &[String],
2742    kind: FilterResourceKind,
2743) -> Vec<PathBuf> {
2744    if patterns.is_empty() {
2745        return current.to_vec();
2746    }
2747    let pattern_root =
2748        normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
2749    let all = resource_inventory(&pattern_root, current, kind);
2750    let mut selected: HashSet<PathBuf> = current
2751        .iter()
2752        .filter_map(|path| {
2753            if path.is_file() {
2754                Some(normalize_resource_path(path.clone()))
2755            } else {
2756                None
2757            }
2758        })
2759        .collect();
2760    // Directory defaults need to expand to individual files before a delta
2761    // can remove one member. If no explicit files were present, start with
2762    // every discovered file, matching the default autoload state.
2763    if selected.is_empty() && current.iter().any(|path| path.is_dir()) {
2764        selected.extend(all.iter().cloned());
2765    }
2766    for pattern in patterns {
2767        let (mode, target) = pattern_mode(pattern);
2768        let exact = matches!(mode, PatternMode::ForceInclude | PatternMode::ForceExclude);
2769        for path in &all {
2770            if !matches_resource_pattern(path, &pattern_root, target, exact, kind) {
2771                continue;
2772            }
2773            match mode {
2774                PatternMode::Exclude | PatternMode::ForceExclude => {
2775                    selected.remove(path);
2776                }
2777                PatternMode::Include | PatternMode::ForceInclude => {
2778                    selected.insert(path.clone());
2779                }
2780            }
2781        }
2782    }
2783    sorted_paths(selected.into_iter().collect())
2784}
2785
2786#[derive(Debug, Clone, Copy)]
2787enum PatternMode {
2788    Include,
2789    Exclude,
2790    ForceInclude,
2791    ForceExclude,
2792}
2793
2794fn pattern_mode(pattern: &str) -> (PatternMode, &str) {
2795    if let Some(value) = pattern.strip_prefix('+') {
2796        (PatternMode::ForceInclude, value)
2797    } else if let Some(value) = pattern.strip_prefix('-') {
2798        (PatternMode::ForceExclude, value)
2799    } else if let Some(value) = pattern.strip_prefix('!') {
2800        (PatternMode::Exclude, value)
2801    } else {
2802        (PatternMode::Include, pattern)
2803    }
2804}
2805
2806fn resource_inventory(root: &Path, defaults: &[PathBuf], kind: FilterResourceKind) -> Vec<PathBuf> {
2807    let Ok(boundary) = std::fs::canonicalize(root).map(normalize_resource_path) else {
2808        return Vec::new();
2809    };
2810    let mut out = HashSet::new();
2811    let mut visited = HashSet::new();
2812    for path in defaults {
2813        collect_resource_files(&boundary, path, kind, &mut out, &mut visited);
2814    }
2815    sorted_paths(out.into_iter().collect())
2816}
2817
2818fn collect_resource_files(
2819    boundary: &Path,
2820    path: &Path,
2821    kind: FilterResourceKind,
2822    out: &mut HashSet<PathBuf>,
2823    visited: &mut HashSet<PathBuf>,
2824) {
2825    let Some(canonical) = validated_resource_path(boundary, path) else {
2826        return;
2827    };
2828    let Ok(metadata) = std::fs::metadata(path) else {
2829        return;
2830    };
2831    if metadata.is_file() {
2832        if valid_resource_file(path, kind) {
2833            out.insert(canonical);
2834        }
2835        return;
2836    }
2837    if !metadata.is_dir() || !visited.insert(canonical) {
2838        return;
2839    }
2840    match kind {
2841        FilterResourceKind::Extensions => collect_extension_directory(boundary, path, out, visited),
2842        FilterResourceKind::Skills => collect_skill_directory(boundary, path, path, out, visited),
2843        FilterResourceKind::Prompts | FilterResourceKind::Themes => {
2844            collect_recursive_resource_directory(boundary, path, kind, out, visited)
2845        }
2846    }
2847}
2848
2849fn valid_resource_file(path: &Path, kind: FilterResourceKind) -> bool {
2850    match kind {
2851        FilterResourceKind::Extensions => matches!(
2852            path.extension().and_then(|ext| ext.to_str()),
2853            Some("js" | "ts")
2854        ),
2855        FilterResourceKind::Skills | FilterResourceKind::Prompts => {
2856            path.extension().and_then(|ext| ext.to_str()) == Some("md")
2857        }
2858        FilterResourceKind::Themes => path.extension().and_then(|ext| ext.to_str()) == Some("json"),
2859    }
2860}
2861
2862fn collect_extension_directory(
2863    boundary: &Path,
2864    dir: &Path,
2865    out: &mut HashSet<PathBuf>,
2866    visited: &mut HashSet<PathBuf>,
2867) {
2868    if let Some(entries) = extension_manifest_entries(dir) {
2869        let resolved = resolve_manifest_resources(
2870            boundary,
2871            dir,
2872            &entries,
2873            FilterResourceKind::Extensions,
2874            visited,
2875        );
2876        if resolved.had_source {
2877            out.extend(resolved.paths);
2878            return;
2879        }
2880    }
2881
2882    for index in ["index.ts", "index.js"] {
2883        let path = dir.join(index);
2884        if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2885        {
2886            out.insert(canonical);
2887            return;
2888        }
2889    }
2890
2891    for entry in visible_directory_entries(dir) {
2892        let path = entry.path();
2893        let Ok(metadata) = std::fs::metadata(&path) else {
2894            continue;
2895        };
2896        if metadata.is_file() {
2897            if valid_resource_file(&path, FilterResourceKind::Extensions) {
2898                if let Some(canonical) = validated_resource_path(boundary, &path) {
2899                    out.insert(canonical);
2900                }
2901            }
2902        } else if metadata.is_dir() {
2903            let Some(canonical) = validated_resource_path(boundary, &path) else {
2904                continue;
2905            };
2906            if !visited.insert(canonical) {
2907                continue;
2908            }
2909            collect_extension_entry_directory(boundary, &path, out, visited);
2910        }
2911    }
2912}
2913
2914fn collect_extension_entry_directory(
2915    boundary: &Path,
2916    dir: &Path,
2917    out: &mut HashSet<PathBuf>,
2918    visited: &mut HashSet<PathBuf>,
2919) {
2920    if let Some(entries) = extension_manifest_entries(dir) {
2921        let resolved = resolve_manifest_resources(
2922            boundary,
2923            dir,
2924            &entries,
2925            FilterResourceKind::Extensions,
2926            visited,
2927        );
2928        if resolved.had_source {
2929            out.extend(resolved.paths);
2930            return;
2931        }
2932    }
2933    for index in ["index.ts", "index.js"] {
2934        let path = dir.join(index);
2935        if let Some(canonical) = validated_resource_path(boundary, &path).filter(|_| path.is_file())
2936        {
2937            out.insert(canonical);
2938            return;
2939        }
2940    }
2941}
2942
2943fn extension_manifest_entries(dir: &Path) -> Option<Vec<String>> {
2944    let manifest = std::fs::read_to_string(dir.join("package.json")).ok()?;
2945    let manifest = parse_json_with_comments(&manifest).ok()?;
2946    let rpi = manifest.get("rpi").unwrap_or(&Value::Null);
2947    let pi = manifest.get("pi").unwrap_or(&Value::Null);
2948    let entries = rpi
2949        .get("extensions")
2950        .or_else(|| pi.get("extensions"))
2951        .or_else(|| manifest.get("extensions"))
2952        .map(string_values)?;
2953    (!entries.is_empty()).then_some(entries)
2954}
2955
2956fn collect_skill_directory(
2957    boundary: &Path,
2958    dir: &Path,
2959    discovery_root: &Path,
2960    out: &mut HashSet<PathBuf>,
2961    visited: &mut HashSet<PathBuf>,
2962) {
2963    let skill_file = dir.join("SKILL.md");
2964    if let Some(canonical) =
2965        validated_resource_path(boundary, &skill_file).filter(|_| skill_file.is_file())
2966    {
2967        out.insert(canonical);
2968        return;
2969    }
2970
2971    for entry in visible_directory_entries(dir) {
2972        let path = entry.path();
2973        let Ok(metadata) = std::fs::metadata(&path) else {
2974            continue;
2975        };
2976        if metadata.is_file() {
2977            if dir == discovery_root && valid_resource_file(&path, FilterResourceKind::Skills) {
2978                if let Some(canonical) = validated_resource_path(boundary, &path) {
2979                    out.insert(canonical);
2980                }
2981            }
2982            continue;
2983        }
2984        if !metadata.is_dir() {
2985            continue;
2986        }
2987        let Some(canonical) = validated_resource_path(boundary, &path) else {
2988            continue;
2989        };
2990        if visited.insert(canonical) {
2991            collect_skill_directory(boundary, &path, discovery_root, out, visited);
2992        }
2993    }
2994}
2995
2996fn collect_recursive_resource_directory(
2997    boundary: &Path,
2998    dir: &Path,
2999    kind: FilterResourceKind,
3000    out: &mut HashSet<PathBuf>,
3001    visited: &mut HashSet<PathBuf>,
3002) {
3003    for entry in visible_directory_entries(dir) {
3004        collect_resource_files(boundary, &entry.path(), kind, out, visited);
3005    }
3006}
3007
3008fn visible_directory_entries(dir: &Path) -> Vec<std::fs::DirEntry> {
3009    let mut entries: Vec<_> = std::fs::read_dir(dir)
3010        .ok()
3011        .into_iter()
3012        .flatten()
3013        .filter_map(Result::ok)
3014        .filter(|entry| {
3015            entry
3016                .file_name()
3017                .to_str()
3018                .is_some_and(|name| !name.starts_with('.') && name != "node_modules")
3019        })
3020        .collect();
3021    entries.sort_by_key(std::fs::DirEntry::file_name);
3022    entries
3023}
3024
3025#[derive(Debug)]
3026struct ManifestResourceResolution {
3027    paths: Vec<PathBuf>,
3028    had_source: bool,
3029}
3030
3031fn resolve_manifest_resources(
3032    boundary: &Path,
3033    base: &Path,
3034    entries: &[String],
3035    kind: FilterResourceKind,
3036    visited: &mut HashSet<PathBuf>,
3037) -> ManifestResourceResolution {
3038    let mut discovered = HashSet::new();
3039    let mut had_source = false;
3040    for entry in entries.iter().filter(|entry| !is_override_pattern(entry)) {
3041        let sources = if has_glob_pattern(entry) {
3042            expand_resource_glob(boundary, base, entry)
3043        } else {
3044            safe_resource_path_from(boundary, base, entry)
3045                .into_iter()
3046                .collect()
3047        };
3048        had_source |= !sources.is_empty();
3049        for source in sources {
3050            collect_resource_files(boundary, &source, kind, &mut discovered, visited);
3051        }
3052    }
3053    let all = sorted_paths(discovered.into_iter().collect());
3054    let patterns: Vec<String> = entries
3055        .iter()
3056        .filter(|entry| is_override_pattern(entry))
3057        .cloned()
3058        .collect();
3059    let base =
3060        normalize_resource_path(std::fs::canonicalize(base).unwrap_or_else(|_| base.to_path_buf()));
3061    let paths = apply_resource_patterns(&all, &patterns, &base, kind);
3062    ManifestResourceResolution { paths, had_source }
3063}
3064
3065fn is_override_pattern(pattern: &str) -> bool {
3066    pattern.starts_with(['!', '+', '-'])
3067}
3068
3069fn has_glob_pattern(pattern: &str) -> bool {
3070    pattern.contains(['*', '?'])
3071}
3072
3073fn expand_resource_glob(boundary: &Path, base: &Path, pattern: &str) -> Vec<PathBuf> {
3074    let pattern = normalize_pattern(pattern);
3075    if pattern.is_empty()
3076        || Path::new(&pattern).is_absolute()
3077        || Path::new(&pattern)
3078            .components()
3079            .any(|part| matches!(part, std::path::Component::ParentDir))
3080    {
3081        return Vec::new();
3082    }
3083    let Some(matcher) = compile_resource_glob(&pattern) else {
3084        return Vec::new();
3085    };
3086    let Some(canonical_base) = validated_resource_path(boundary, base) else {
3087        return Vec::new();
3088    };
3089    if !canonical_base.is_dir() {
3090        return Vec::new();
3091    }
3092    let mut matches = Vec::new();
3093    let mut visited = HashSet::from([canonical_base]);
3094    walk_resource_glob(boundary, base, base, &matcher, &mut matches, &mut visited);
3095    sorted_paths(matches)
3096}
3097
3098fn walk_resource_glob(
3099    boundary: &Path,
3100    base: &Path,
3101    dir: &Path,
3102    matcher: &globset::GlobMatcher,
3103    out: &mut Vec<PathBuf>,
3104    visited: &mut HashSet<PathBuf>,
3105) {
3106    for entry in visible_directory_entries_including_node_modules(dir) {
3107        let path = normalize_resource_path(entry.path());
3108        let Some(canonical) = validated_resource_path(boundary, &path) else {
3109            continue;
3110        };
3111        let Ok(metadata) = std::fs::metadata(&path) else {
3112            continue;
3113        };
3114        let Some(relative) = path.strip_prefix(base).ok().map(path_to_pattern) else {
3115            continue;
3116        };
3117        if matcher.is_match(&relative)
3118            || (metadata.is_dir() && matcher.is_match(format!("{relative}/")))
3119        {
3120            out.push(path.clone());
3121        }
3122        if metadata.is_dir() && visited.insert(canonical) {
3123            walk_resource_glob(boundary, base, &path, matcher, out, visited);
3124        }
3125    }
3126}
3127
3128fn visible_directory_entries_including_node_modules(dir: &Path) -> Vec<std::fs::DirEntry> {
3129    let mut entries: Vec<_> = std::fs::read_dir(dir)
3130        .ok()
3131        .into_iter()
3132        .flatten()
3133        .filter_map(Result::ok)
3134        .filter(|entry| {
3135            entry
3136                .file_name()
3137                .to_str()
3138                .is_some_and(|name| !name.starts_with('.'))
3139        })
3140        .collect();
3141    entries.sort_by_key(std::fs::DirEntry::file_name);
3142    entries
3143}
3144
3145fn compile_resource_glob(pattern: &str) -> Option<globset::GlobMatcher> {
3146    let mut builder = globset::GlobBuilder::new(pattern);
3147    builder.literal_separator(true).backslash_escape(false);
3148    builder.build().ok().map(|glob| glob.compile_matcher())
3149}
3150
3151fn apply_resource_patterns(
3152    all: &[PathBuf],
3153    patterns: &[String],
3154    base: &Path,
3155    kind: FilterResourceKind,
3156) -> Vec<PathBuf> {
3157    let includes: Vec<&str> = patterns
3158        .iter()
3159        .filter(|pattern| !is_override_pattern(pattern))
3160        .map(String::as_str)
3161        .collect();
3162    let excludes: Vec<&str> = patterns
3163        .iter()
3164        .filter_map(|pattern| pattern.strip_prefix('!'))
3165        .collect();
3166    let force_includes: Vec<&str> = patterns
3167        .iter()
3168        .filter_map(|pattern| pattern.strip_prefix('+'))
3169        .collect();
3170    let force_excludes: Vec<&str> = patterns
3171        .iter()
3172        .filter_map(|pattern| pattern.strip_prefix('-'))
3173        .collect();
3174
3175    let mut selected: HashSet<PathBuf> = all
3176        .iter()
3177        .filter(|path| {
3178            includes.is_empty()
3179                || includes
3180                    .iter()
3181                    .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3182        })
3183        .cloned()
3184        .collect();
3185    if !excludes.is_empty() {
3186        selected.retain(|path| {
3187            !excludes
3188                .iter()
3189                .any(|pattern| matches_resource_pattern(path, base, pattern, false, kind))
3190        });
3191    }
3192    for path in all {
3193        if force_includes
3194            .iter()
3195            .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3196        {
3197            selected.insert(path.clone());
3198        }
3199    }
3200    if !force_excludes.is_empty() {
3201        selected.retain(|path| {
3202            !force_excludes
3203                .iter()
3204                .any(|pattern| matches_resource_pattern(path, base, pattern, true, kind))
3205        });
3206    }
3207    sorted_paths(selected.into_iter().collect())
3208}
3209
3210fn sorted_paths(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
3211    paths.sort();
3212    paths.dedup();
3213    paths
3214}
3215
3216fn matches_resource_pattern(
3217    path: &Path,
3218    root: &Path,
3219    pattern: &str,
3220    exact: bool,
3221    kind: FilterResourceKind,
3222) -> bool {
3223    let pattern = normalize_pattern(pattern);
3224    if pattern.is_empty() {
3225        return false;
3226    }
3227    let root =
3228        normalize_resource_path(std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()));
3229    let rel = path
3230        .strip_prefix(&root)
3231        .ok()
3232        .map(path_to_pattern)
3233        .unwrap_or_default();
3234    let name = path
3235        .file_name()
3236        .and_then(|value| value.to_str())
3237        .unwrap_or("");
3238    let absolute = path_to_pattern(path);
3239    let parent_rel = path
3240        .parent()
3241        .and_then(|parent| parent.strip_prefix(&root).ok())
3242        .map(path_to_pattern);
3243    let parent_absolute = path.parent().map(path_to_pattern);
3244    let exact_match =
3245        |candidate: &str| candidate == pattern || normalize_pattern(candidate) == pattern;
3246    if exact {
3247        return exact_match(&rel)
3248            || exact_match(&absolute)
3249            || (matches!(kind, FilterResourceKind::Skills)
3250                && (parent_rel.as_deref().is_some_and(exact_match)
3251                    || parent_absolute.as_deref().is_some_and(exact_match)));
3252    }
3253    let matcher = compile_resource_glob(&pattern);
3254    let matches = |candidate: &str| {
3255        matcher
3256            .as_ref()
3257            .is_some_and(|matcher| matcher.is_match(candidate))
3258            || exact_match(candidate)
3259    };
3260    matches(&rel)
3261        || matches(name)
3262        || matches(&absolute)
3263        || (matches!(kind, FilterResourceKind::Skills)
3264            && (parent_rel.as_deref().is_some_and(matches)
3265                || parent_absolute.as_deref().is_some_and(matches)))
3266}
3267
3268fn normalize_pattern(pattern: &str) -> String {
3269    let normalized = pattern.trim().replace('\\', "/");
3270    normalized
3271        .strip_prefix("./")
3272        .unwrap_or(&normalized)
3273        .to_string()
3274}
3275
3276fn path_to_pattern(path: &Path) -> String {
3277    path.to_string_lossy().replace('\\', "/")
3278}
3279
3280fn load_package(
3281    root: PathBuf,
3282    spec: &str,
3283    cwd: &Path,
3284    scope: ResolveScope,
3285    filter: Option<&crate::settings::PackageFilter>,
3286) -> Result<PackageRoot, String> {
3287    load_package_with_legacy_root(root, spec, cwd, scope, filter, None)
3288}
3289
3290fn load_package_with_legacy_root(
3291    root: PathBuf,
3292    spec: &str,
3293    cwd: &Path,
3294    scope: ResolveScope,
3295    filter: Option<&crate::settings::PackageFilter>,
3296    legacy_npm_root: Option<PathBuf>,
3297) -> Result<PackageRoot, String> {
3298    let manifest_path = root.join("package.json");
3299    let raw =
3300        match std::fs::read_to_string(&manifest_path) {
3301            Ok(text) => Some(parse_json_with_comments(&text).map_err(|e| {
3302                format!("invalid package manifest {}: {e}", manifest_path.display())
3303            })?),
3304            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
3305            Err(e) => return Err(format!("could not read {}: {e}", manifest_path.display())),
3306        };
3307    let manifest_name = raw
3308        .as_ref()
3309        .and_then(|v| v.get("name"))
3310        .and_then(Value::as_str);
3311    let explicit_npm_source = if spec.trim_start().starts_with("npm:") {
3312        Some(
3313            npm_source_from_spec(spec)
3314                .ok_or_else(|| format!("invalid npm package source `{spec}`"))?,
3315        )
3316    } else {
3317        None
3318    };
3319    if legacy_npm_root.is_some() || explicit_npm_source.is_some() {
3320        let source = explicit_npm_source
3321            .as_ref()
3322            .ok_or_else(|| format!("legacy npm package has invalid source `{spec}`"))?;
3323        let expected = parse_npm_package_spec(spec)
3324            .map(|parsed| parsed.manifest_name)
3325            .ok_or_else(|| format!("invalid npm package source `{spec}`"))?;
3326        let actual = manifest_name.ok_or_else(|| {
3327            format!(
3328                "npm package manifest {} has no string package name; expected `{expected}`",
3329                manifest_path.display()
3330            )
3331        })?;
3332        if !npm_source_matches_manifest(source, actual) {
3333            return Err(format!(
3334                "npm package manifest name `{actual}` does not match configured package `{expected}`"
3335            ));
3336        }
3337    }
3338    let name = manifest_name
3339        .map(str::to_owned)
3340        .or_else(|| root.file_name().and_then(|s| s.to_str()).map(str::to_owned))
3341        .unwrap_or_else(|| spec.to_string());
3342    let version = raw
3343        .as_ref()
3344        .and_then(|v| v.get("version"))
3345        .and_then(Value::as_str)
3346        .map(str::to_owned);
3347    let manifest = raw.as_ref().map(|_| manifest_path);
3348    let source = if legacy_npm_root.is_some() {
3349        // A legacy lookup grants read-only provenance only after the manifest
3350        // identity check above. Updates still migrate into a managed store.
3351        explicit_npm_source
3352            .clone()
3353            .expect("legacy npm sources were validated above")
3354    } else {
3355        classify_package_source(&root, spec, &name, cwd, scope)
3356    };
3357    if explicit_npm_source.is_some()
3358        && is_managed_package_path(&root, cwd, scope)
3359        && source == PackageSource::Unknown
3360    {
3361        return Err(format!(
3362            "managed npm package provenance does not match configured source `{spec}`"
3363        ));
3364    }
3365    let npm_install_root = matches!(source, PackageSource::Npm { .. })
3366        .then(|| npm_install_root_for_path(&root, cwd, scope))
3367        .flatten();
3368    let git_store_root = matches!(source, PackageSource::Git)
3369        .then(|| native_git_store_root_for_path(&root, cwd, scope))
3370        .flatten();
3371    let git_revision = matches!(source, PackageSource::Git)
3372        .then(|| parse_git_source(spec).and_then(|git| git.revision))
3373        .flatten();
3374    // rpi-specific manifest settings win per resource key; a missing rpi key
3375    // falls back to the original Pi key so partial migrations stay compatible.
3376    let rpi = raw
3377        .as_ref()
3378        .and_then(|v| v.get("rpi"))
3379        .unwrap_or(&Value::Null);
3380    let pi = raw
3381        .as_ref()
3382        .and_then(|v| v.get("pi"))
3383        .unwrap_or(&Value::Null);
3384
3385    let mut skills = resource_paths(
3386        &root,
3387        raw.as_ref(),
3388        rpi,
3389        pi,
3390        "skills",
3391        "skills",
3392        FilterResourceKind::Skills,
3393    );
3394    let mut prompts = resource_paths(
3395        &root,
3396        raw.as_ref(),
3397        rpi,
3398        pi,
3399        "prompts",
3400        "prompts",
3401        FilterResourceKind::Prompts,
3402    );
3403    let mut themes = resource_paths(
3404        &root,
3405        raw.as_ref(),
3406        rpi,
3407        pi,
3408        "themes",
3409        "themes",
3410        FilterResourceKind::Themes,
3411    );
3412    let mut extensions = resource_paths(
3413        &root,
3414        raw.as_ref(),
3415        rpi,
3416        pi,
3417        "extensions",
3418        "extensions",
3419        FilterResourceKind::Extensions,
3420    );
3421    let system_prompts = file_paths(
3422        &root,
3423        raw.as_ref(),
3424        rpi,
3425        pi,
3426        &["systemPrompt", "system_prompt", "system"],
3427        "SYSTEM.md",
3428    );
3429    let append_system_prompts = file_paths(
3430        &root,
3431        raw.as_ref(),
3432        rpi,
3433        pi,
3434        &["appendSystemPrompt", "append_system_prompt", "appendSystem"],
3435        "APPEND_SYSTEM.md",
3436    );
3437    let autoload_delta = filter.is_some_and(|filter| filter.autoload == Some(false));
3438    if let Some(filter) = filter {
3439        apply_package_filter(
3440            &root,
3441            &mut extensions,
3442            &mut skills,
3443            &mut prompts,
3444            &mut themes,
3445            filter,
3446        );
3447    }
3448
3449    Ok(PackageRoot {
3450        skills,
3451        prompts,
3452        themes,
3453        system_prompts,
3454        append_system_prompts,
3455        extensions,
3456        root,
3457        name,
3458        version,
3459        manifest,
3460        spec: spec.to_string(),
3461        source,
3462        npm_install_root,
3463        legacy_npm_root,
3464        autoload_delta,
3465        scope,
3466        git_store_root,
3467        git_revision,
3468        missing_install: false,
3469        filter: filter.cloned(),
3470    })
3471}
3472
3473#[derive(Debug, serde::Serialize, serde::Deserialize)]
3474struct PackageSourceMarker {
3475    kind: String,
3476    spec: String,
3477}
3478
3479pub(crate) fn write_npm_source_marker(root: &Path, spec: &str) -> Result<(), String> {
3480    let source = npm_source_from_spec(spec)
3481        .ok_or_else(|| format!("invalid npm package source marker spec `{spec}`"))?;
3482    let PackageSource::Npm { spec, .. } = source else {
3483        unreachable!();
3484    };
3485    let marker = PackageSourceMarker {
3486        kind: "npm".to_string(),
3487        spec,
3488    };
3489    let data = serde_json::to_vec_pretty(&marker).map_err(|error| error.to_string())?;
3490    std::fs::write(root.join(PACKAGE_SOURCE_MARKER), data)
3491        .map_err(|error| format!("could not write package source marker: {error}"))
3492}
3493
3494pub(crate) fn remove_package_source_marker(root: &Path) -> Result<(), String> {
3495    match std::fs::remove_file(root.join(PACKAGE_SOURCE_MARKER)) {
3496        Ok(()) => Ok(()),
3497        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
3498        Err(error) => Err(format!("could not remove package source marker: {error}")),
3499    }
3500}
3501
3502fn read_npm_source_marker(root: &Path, manifest_name: &str) -> Option<PackageSource> {
3503    let marker: PackageSourceMarker =
3504        serde_json::from_str(&std::fs::read_to_string(root.join(PACKAGE_SOURCE_MARKER)).ok()?)
3505            .ok()?;
3506    if marker.kind != "npm" {
3507        return None;
3508    }
3509    let source = npm_source_from_spec(&marker.spec)?;
3510    npm_source_matches_manifest(&source, manifest_name).then_some(source)
3511}
3512
3513fn classify_package_source(
3514    root: &Path,
3515    spec: &str,
3516    manifest_name: &str,
3517    cwd: &Path,
3518    scope: ResolveScope,
3519) -> PackageSource {
3520    if let Some(raw) = spec.strip_prefix("npm:") {
3521        let Some(explicit_source) = npm_source_from_spec(&format!("npm:{raw}")) else {
3522            return PackageSource::Unknown;
3523        };
3524        if !npm_source_matches_manifest(&explicit_source, manifest_name) {
3525            return PackageSource::Unknown;
3526        }
3527        if is_npm_store_package_path(root, cwd, scope) {
3528            return explicit_source;
3529        }
3530        if is_managed_package_path(root, cwd, scope) {
3531            return read_npm_source_marker(root, manifest_name)
3532                .filter(|marker_source| marker_source == &explicit_source)
3533                .unwrap_or(PackageSource::Unknown);
3534        }
3535        return PackageSource::Unknown;
3536    }
3537    if let Some(git) = parse_git_source(spec) {
3538        return native_git_target_for_spec(cwd, scope, &git)
3539            .filter(|target| target == root)
3540            .map_or(PackageSource::Unknown, |_| PackageSource::Git);
3541    }
3542    if spec.starts_with("file:") || Path::new(spec).is_absolute() || spec.starts_with('.') {
3543        if is_native_git_package_path(root, cwd, scope) {
3544            return PackageSource::Git;
3545        }
3546        if is_direct_managed_package_root(root, cwd, scope).is_some() {
3547            return PackageSource::Git;
3548        }
3549        if is_managed_package_path(root, cwd, scope) || is_npm_store_package_path(root, cwd, scope)
3550        {
3551            if let Some(source) = read_npm_source_marker(root, manifest_name) {
3552                return source;
3553            }
3554        }
3555        if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3556            return PackageSource::Npm {
3557                name: manifest_name.to_string(),
3558                spec: format!("npm:{manifest_name}"),
3559                requested: None,
3560                pinned: false,
3561            };
3562        }
3563        return PackageSource::Local;
3564    }
3565    if is_npm_store_package_path(root, cwd, scope) && valid_npm_name(manifest_name) {
3566        return PackageSource::Npm {
3567            name: manifest_name.to_string(),
3568            spec: format!("npm:{manifest_name}"),
3569            requested: None,
3570            pinned: false,
3571        };
3572    }
3573    PackageSource::Unknown
3574}
3575
3576fn npm_source_from_spec(spec: &str) -> Option<PackageSource> {
3577    let raw = spec.strip_prefix("npm:")?;
3578    let parsed = parse_npm_package_spec(raw)?;
3579    let pinned = parsed
3580        .target_selector
3581        .as_deref()
3582        .is_some_and(is_exact_npm_version);
3583    Some(PackageSource::Npm {
3584        name: parsed.install_name,
3585        spec: format!("npm:{}", raw.trim()),
3586        requested: parsed.requested,
3587        pinned,
3588    })
3589}
3590
3591fn npm_source_matches_manifest(source: &PackageSource, manifest_name: &str) -> bool {
3592    let PackageSource::Npm { spec, .. } = source else {
3593        return false;
3594    };
3595    parse_npm_package_spec(spec).is_some_and(|parsed| parsed.manifest_name == manifest_name)
3596}
3597
3598fn is_exact_npm_version(value: &str) -> bool {
3599    let value = value.trim().strip_prefix('v').unwrap_or(value.trim());
3600    let mut build_parts = value.split('+');
3601    let core_and_pre = build_parts.next().unwrap_or_default();
3602    if build_parts
3603        .next()
3604        .is_some_and(|build| !valid_semver_identifiers(build, false))
3605        || build_parts.next().is_some()
3606    {
3607        return false;
3608    }
3609    let (core, prerelease) = core_and_pre
3610        .split_once('-')
3611        .map_or((core_and_pre, None), |(core, pre)| (core, Some(pre)));
3612    if prerelease.is_some_and(|pre| !valid_semver_identifiers(pre, true)) {
3613        return false;
3614    }
3615    let mut parts = core.split('.');
3616    let Some(major) = parts.next() else {
3617        return false;
3618    };
3619    let Some(minor) = parts.next() else {
3620        return false;
3621    };
3622    let Some(patch) = parts.next() else {
3623        return false;
3624    };
3625    parts.next().is_none()
3626        && [major, minor, patch].iter().all(|part| {
3627            !part.is_empty()
3628                && part.chars().all(|ch| ch.is_ascii_digit())
3629                && (*part == "0" || !part.starts_with('0'))
3630        })
3631}
3632
3633fn valid_semver_identifiers(value: &str, reject_numeric_leading_zero: bool) -> bool {
3634    !value.is_empty()
3635        && value.split('.').all(|identifier| {
3636            !identifier.is_empty()
3637                && identifier
3638                    .chars()
3639                    .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
3640                && (!reject_numeric_leading_zero
3641                    || !identifier.chars().all(|ch| ch.is_ascii_digit())
3642                    || identifier == "0"
3643                    || !identifier.starts_with('0'))
3644        })
3645}
3646
3647pub(crate) fn parse_npm_package_spec(spec: &str) -> Option<ParsedNpmPackageSpec> {
3648    let raw = spec.strip_prefix("npm:").unwrap_or(spec).trim();
3649    let (install_name, requested) = split_npm_name_and_selector(raw)?;
3650    if !valid_npm_name(install_name) {
3651        return None;
3652    }
3653
3654    let Some(requested) = requested else {
3655        return Some(ParsedNpmPackageSpec {
3656            install_name: install_name.to_string(),
3657            manifest_name: install_name.to_string(),
3658            requested: None,
3659            target_selector: None,
3660            is_alias: false,
3661        });
3662    };
3663    let requested = requested.trim();
3664    if !safe_npm_registry_selector(requested, true) {
3665        return None;
3666    }
3667
3668    let Some(alias_target) = requested.strip_prefix("npm:") else {
3669        return Some(ParsedNpmPackageSpec {
3670            install_name: install_name.to_string(),
3671            manifest_name: install_name.to_string(),
3672            requested: Some(requested.to_string()),
3673            target_selector: Some(requested.to_string()),
3674            is_alias: false,
3675        });
3676    };
3677    let (manifest_name, target_selector) = split_npm_name_and_selector(alias_target)?;
3678    if !valid_npm_name(manifest_name)
3679        || target_selector.is_some_and(|selector| !safe_npm_registry_selector(selector, false))
3680    {
3681        return None;
3682    }
3683    Some(ParsedNpmPackageSpec {
3684        install_name: install_name.to_string(),
3685        manifest_name: manifest_name.to_string(),
3686        requested: Some(requested.to_string()),
3687        target_selector: target_selector.map(str::to_string),
3688        is_alias: true,
3689    })
3690}
3691
3692fn split_npm_name_and_selector(spec: &str) -> Option<(&str, Option<&str>)> {
3693    let spec = spec.trim();
3694    if spec.is_empty() || spec.chars().any(char::is_control) {
3695        return None;
3696    }
3697    let separator = if spec.starts_with('@') {
3698        let slash = spec.find('/')?;
3699        spec[slash + 1..].find('@').map(|index| slash + 1 + index)
3700    } else {
3701        spec.find('@')
3702    };
3703    match separator {
3704        Some(index) => {
3705            let selector = &spec[index + 1..];
3706            (!selector.is_empty()).then_some((&spec[..index], Some(selector)))
3707        }
3708        None => Some((spec, None)),
3709    }
3710}
3711
3712fn safe_npm_registry_selector(selector: &str, allow_alias: bool) -> bool {
3713    let selector = selector.trim();
3714    if selector.is_empty()
3715        || selector.starts_with('-')
3716        || selector.starts_with('.')
3717        || selector.starts_with('/')
3718        || selector.contains('\\')
3719        || selector.chars().any(char::is_control)
3720    {
3721        return false;
3722    }
3723    if let Some(target) = selector.strip_prefix("npm:") {
3724        return allow_alias && !target.is_empty();
3725    }
3726    let lower = selector.to_ascii_lowercase();
3727    ![
3728        "file:",
3729        "link:",
3730        "workspace:",
3731        "git:",
3732        "git+",
3733        "http:",
3734        "https:",
3735        "ssh:",
3736        "github:",
3737        "gitlab:",
3738        "bitbucket:",
3739    ]
3740    .iter()
3741    .any(|prefix| lower.starts_with(prefix))
3742}
3743
3744fn valid_npm_name(name: &str) -> bool {
3745    if name.starts_with('-') {
3746        return false;
3747    }
3748    if let Some(scoped) = name.strip_prefix('@') {
3749        let mut parts = scoped.split('/');
3750        return parts.next().is_some_and(valid_npm_name_part)
3751            && parts.next().is_some_and(valid_npm_name_part)
3752            && parts.next().is_none();
3753    }
3754    valid_npm_name_part(name)
3755}
3756
3757fn valid_npm_name_part(part: &str) -> bool {
3758    !matches!(part, "" | "." | "..")
3759        && !part.starts_with('.')
3760        && !part.starts_with('-')
3761        && part
3762            .chars()
3763            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~'))
3764}
3765
3766fn parse_json_with_comments(text: &str) -> Result<Value, serde_json::Error> {
3767    match serde_json::from_str(text) {
3768        Ok(value) => Ok(value),
3769        Err(first) => serde_json::from_str(&config::strip_line_comments(text)).map_err(|_| first),
3770    }
3771}
3772
3773fn resource_paths(
3774    root: &Path,
3775    top: Option<&Value>,
3776    rpi: &Value,
3777    pi: &Value,
3778    key: &str,
3779    default_dir: &str,
3780    kind: FilterResourceKind,
3781) -> Vec<PathBuf> {
3782    let values = rpi
3783        .get(key)
3784        .or_else(|| pi.get(key))
3785        .or_else(|| top.and_then(|v| v.get(key)));
3786    if let Some(values) = values {
3787        return resolve_manifest_resources(
3788            root,
3789            root,
3790            &string_values(values),
3791            kind,
3792            &mut HashSet::new(),
3793        )
3794        .paths;
3795    }
3796    resource_inventory(root, &[root.join(default_dir)], kind)
3797}
3798
3799fn file_paths(
3800    root: &Path,
3801    top: Option<&Value>,
3802    rpi: &Value,
3803    pi: &Value,
3804    keys: &[&str],
3805    default_file: &str,
3806) -> Vec<PathBuf> {
3807    let value = keys.iter().find_map(|key| {
3808        rpi.get(*key)
3809            .or_else(|| pi.get(*key))
3810            .or_else(|| top.and_then(|v| v.get(*key)))
3811    });
3812    let mut paths = value
3813        .map(|v| {
3814            string_values(v)
3815                .into_iter()
3816                .filter_map(|path| safe_resource_path(root, &path))
3817                .collect()
3818        })
3819        .unwrap_or_else(|| vec![root.join(default_file)]);
3820    paths.retain(|p: &PathBuf| p.is_file());
3821    paths
3822}
3823
3824fn string_values(value: &Value) -> Vec<String> {
3825    match value {
3826        Value::String(s) => vec![s.clone()],
3827        Value::Array(values) => values
3828            .iter()
3829            .filter_map(Value::as_str)
3830            .map(str::to_owned)
3831            .collect(),
3832        _ => Vec::new(),
3833    }
3834}
3835
3836fn normalize_key(path: &Path) -> String {
3837    std::fs::canonicalize(path)
3838        .unwrap_or_else(|_| path.to_path_buf())
3839        .to_string_lossy()
3840        .to_ascii_lowercase()
3841}
3842
3843#[cfg(test)]
3844mod tests {
3845    use super::*;
3846
3847    struct RestoreEnv {
3848        name: &'static str,
3849        value: Option<std::ffi::OsString>,
3850    }
3851
3852    impl RestoreEnv {
3853        fn capture(name: &'static str) -> Self {
3854            Self {
3855                name,
3856                value: std::env::var_os(name),
3857            }
3858        }
3859    }
3860
3861    impl Drop for RestoreEnv {
3862        fn drop(&mut self) {
3863            match self.value.take() {
3864                Some(value) => std::env::set_var(self.name, value),
3865                None => std::env::remove_var(self.name),
3866            }
3867        }
3868    }
3869
3870    #[test]
3871    fn package_command_trust_overrides_are_explicit_and_conflict_safe() {
3872        let cwd = Path::new(".");
3873        assert!(package_command_project_trusted(cwd, &["--approve".into()]).unwrap());
3874        assert!(!package_command_project_trusted(cwd, &["--no-approve".into()]).unwrap());
3875        assert!(
3876            package_command_project_trusted(cwd, &["--approve".into(), "--no-approve".into()])
3877                .is_err()
3878        );
3879        assert!(package_command_project_trusted(cwd, &["--unexpected".into()]).is_err());
3880    }
3881
3882    #[test]
3883    fn package_update_accepts_offline_flag_and_skips_all_preflight() {
3884        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3885        let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
3886        let _restore_offline = RestoreEnv::capture(crate::args::PI_OFFLINE_ENV);
3887        let tmp = tempfile::tempdir().unwrap();
3888        let agent = tmp.path().join("agent");
3889        std::fs::create_dir_all(&agent).unwrap();
3890        std::fs::write(agent.join("native-packages.json"), "{ malformed").unwrap();
3891        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
3892        std::env::remove_var(crate::args::PI_OFFLINE_ENV);
3893
3894        assert_eq!(run_cli(&["update".into(), "--offline".into()]), 0);
3895        assert_eq!(
3896            std::env::var(crate::args::PI_OFFLINE_ENV).as_deref(),
3897            Ok("1")
3898        );
3899
3900        // A non-truthy value must not silently suppress the same invalid
3901        // registry preflight.
3902        std::env::set_var(crate::args::PI_OFFLINE_ENV, "0");
3903        assert_eq!(
3904            update_packages_with_scope(tmp.path(), false, UpdateScope::Native),
3905            1
3906        );
3907    }
3908
3909    #[test]
3910    fn top_level_update_help_is_handled_by_package_updater() {
3911        assert_eq!(run_cli(&["update".into(), "--help".into()]), 0);
3912    }
3913
3914    #[test]
3915    fn native_and_pi_update_scopes_validate_only_their_own_metadata() {
3916        let _guard = crate::config::test_support::env_lock().lock().unwrap();
3917        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
3918        let tmp = tempfile::tempdir().unwrap();
3919        let agent = tmp.path().join("agent");
3920        std::fs::create_dir_all(&agent).unwrap();
3921        std::fs::write(agent.join("native-packages.json"), "[{broken").unwrap();
3922        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
3923
3924        assert_eq!(
3925            update_packages_with_scope(tmp.path(), false, UpdateScope::Native),
3926            1
3927        );
3928        assert_eq!(
3929            update_packages_with_scope(tmp.path(), false, UpdateScope::Pi),
3930            0
3931        );
3932
3933        std::fs::remove_file(agent.join("native-packages.json")).unwrap();
3934        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
3935        assert_eq!(
3936            update_packages_with_scope(tmp.path(), false, UpdateScope::Native),
3937            0
3938        );
3939        assert_eq!(
3940            update_packages_with_scope(tmp.path(), false, UpdateScope::Pi),
3941            1
3942        );
3943
3944        match previous {
3945            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
3946            None => std::env::remove_var(config::CONFIG_DIR_ENV),
3947        }
3948    }
3949
3950    #[test]
3951    fn discovers_conventional_and_manifest_resources() {
3952        let tmp = tempfile::tempdir().unwrap();
3953        let root = tmp.path().join("pkg");
3954        std::fs::create_dir_all(root.join("custom-skills")).unwrap();
3955        std::fs::create_dir_all(root.join("rpi-skills")).unwrap();
3956        std::fs::create_dir_all(root.join("prompts")).unwrap();
3957        std::fs::create_dir_all(root.join("legacy-prompts")).unwrap();
3958        std::fs::create_dir_all(root.join("themes")).unwrap();
3959        std::fs::write(root.join("custom-skills/a.md"), "---\nname: a\n---\nbody").unwrap();
3960        std::fs::write(root.join("rpi-skills/rpi.md"), "---\nname: rpi\n---\nbody").unwrap();
3961        std::fs::write(root.join("prompts/explain.md"), "explain").unwrap();
3962        std::fs::write(root.join("legacy-prompts/legacy.md"), "legacy").unwrap();
3963        std::fs::write(root.join("themes/ocean.json"), "{}").unwrap();
3964        std::fs::write(
3965            root.join("package.json"),
3966            r#"{"name":"demo","version":"1.0.0","pi":{"skills":["custom-skills"],"prompts":["legacy-prompts"]},"rpi":{"skills":["rpi-skills"]}}"#,
3967        )
3968        .unwrap();
3969
3970        let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
3971        assert_eq!(resources.packages.len(), 1);
3972        assert_eq!(resources.packages[0].name, "demo");
3973        assert_eq!(resources.skill_dirs(), vec![root.join("rpi-skills/rpi.md")]);
3974        assert_eq!(
3975            resources.prompt_dirs(),
3976            vec![root.join("legacy-prompts/legacy.md")]
3977        );
3978        assert_eq!(
3979            resources.theme_files(),
3980            vec![root.join("themes/ocean.json")]
3981        );
3982    }
3983
3984    #[test]
3985    fn manifest_globs_and_overrides_follow_native_precedence() {
3986        let tmp = tempfile::tempdir().unwrap();
3987        let root = tmp.path().join("pkg");
3988        for dir in [
3989            root.join("extensions"),
3990            root.join("plugins/one/skills/alpha"),
3991            root.join("plugins/two/skills/beta"),
3992        ] {
3993            std::fs::create_dir_all(dir).unwrap();
3994        }
3995        for path in ["extensions/a.ts", "extensions/z.ts"] {
3996            std::fs::write(root.join(path), "export default () => {};").unwrap();
3997        }
3998        for path in [
3999            "plugins/one/skills/alpha/SKILL.md",
4000            "plugins/two/skills/beta/SKILL.md",
4001        ] {
4002            std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
4003        }
4004        std::fs::write(
4005            root.join("package.json"),
4006            r#"{
4007                "name":"glob-demo",
4008                "pi":{
4009                    "extensions":[
4010                        "extensions/*.ts",
4011                        "!**/*.ts",
4012                        "+extensions/a.ts",
4013                        "-extensions/z.ts",
4014                        "+extensions/z.ts"
4015                    ],
4016                    "skills":["plugins/*/skills"]
4017                }
4018            }"#,
4019        )
4020        .unwrap();
4021
4022        let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
4023        assert_eq!(
4024            resources.extension_paths(),
4025            vec![root.join("extensions/a.ts")]
4026        );
4027        assert_eq!(
4028            resources.skill_dirs(),
4029            vec![
4030                root.join("plugins/one/skills/alpha/SKILL.md"),
4031                root.join("plugins/two/skills/beta/SKILL.md"),
4032            ]
4033        );
4034    }
4035
4036    #[test]
4037    fn extension_directories_use_smart_entry_discovery() {
4038        let tmp = tempfile::tempdir().unwrap();
4039        let root = tmp.path().join("pkg");
4040        for dir in [
4041            root.join("extensions/group"),
4042            root.join("extensions/custom"),
4043            root.join("extensions/broken"),
4044        ] {
4045            std::fs::create_dir_all(dir).unwrap();
4046        }
4047        for (path, body) in [
4048            ("extensions/standalone.ts", "export default () => {};"),
4049            ("extensions/group/index.ts", "export default () => {};"),
4050            ("extensions/group/helper.ts", "export const helper = 1;"),
4051            ("extensions/custom/main.js", "export default () => {};"),
4052            ("extensions/custom/utils.js", "export const util = 1;"),
4053            ("extensions/broken/helper.ts", "export const helper = 1;"),
4054        ] {
4055            std::fs::write(root.join(path), body).unwrap();
4056        }
4057        std::fs::write(
4058            root.join("extensions/custom/package.json"),
4059            r#"{"pi":{"extensions":["main.js"]}}"#,
4060        )
4061        .unwrap();
4062        std::fs::write(
4063            root.join("package.json"),
4064            r#"{"name":"smart-demo","pi":{"extensions":["extensions"]}}"#,
4065        )
4066        .unwrap();
4067
4068        let package = load_package(
4069            root.clone(),
4070            &root.to_string_lossy(),
4071            tmp.path(),
4072            ResolveScope::Any,
4073            None,
4074        )
4075        .unwrap();
4076        assert_eq!(
4077            package.extensions,
4078            vec![
4079                root.join("extensions/custom/main.js"),
4080                root.join("extensions/group/index.ts"),
4081                root.join("extensions/standalone.ts"),
4082            ]
4083        );
4084
4085        std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4086        let package = load_package(
4087            root.clone(),
4088            &root.to_string_lossy(),
4089            tmp.path(),
4090            ResolveScope::Any,
4091            None,
4092        )
4093        .unwrap();
4094        assert_eq!(package.extensions, vec![root.join("extensions/index.js")]);
4095    }
4096
4097    #[test]
4098    fn skill_directory_discovery_ignores_nested_markdown_helpers() {
4099        let tmp = tempfile::tempdir().unwrap();
4100        let root = tmp.path().join("pkg");
4101        for dir in [
4102            root.join("skills/group/nested"),
4103            root.join("skills/docs"),
4104            root.join("skills/deep/alpha"),
4105        ] {
4106            std::fs::create_dir_all(dir).unwrap();
4107        }
4108        for path in [
4109            "skills/root.md",
4110            "skills/group/SKILL.md",
4111            "skills/group/README.md",
4112            "skills/group/nested/SKILL.md",
4113            "skills/docs/README.md",
4114            "skills/deep/alpha/SKILL.md",
4115        ] {
4116            std::fs::write(root.join(path), "---\nname: demo\n---\n").unwrap();
4117        }
4118        std::fs::write(root.join("package.json"), r#"{"name":"skill-demo"}"#).unwrap();
4119
4120        let package = load_package(
4121            root.clone(),
4122            &root.to_string_lossy(),
4123            tmp.path(),
4124            ResolveScope::Any,
4125            None,
4126        )
4127        .unwrap();
4128        assert_eq!(
4129            package.skills,
4130            vec![
4131                root.join("skills/deep/alpha/SKILL.md"),
4132                root.join("skills/group/SKILL.md"),
4133                root.join("skills/root.md"),
4134            ]
4135        );
4136    }
4137
4138    #[test]
4139    fn manifest_prompt_and_theme_directories_are_recursive() {
4140        let tmp = tempfile::tempdir().unwrap();
4141        let root = tmp.path().join("pkg");
4142        std::fs::create_dir_all(root.join("prompt-pack/nested")).unwrap();
4143        std::fs::create_dir_all(root.join("theme-pack/nested")).unwrap();
4144        std::fs::write(root.join("prompt-pack/root.md"), "root").unwrap();
4145        std::fs::write(root.join("prompt-pack/nested/deep.md"), "deep").unwrap();
4146        std::fs::write(root.join("theme-pack/root.json"), "{}").unwrap();
4147        std::fs::write(root.join("theme-pack/nested/deep.json"), "{}").unwrap();
4148        std::fs::write(root.join("theme-pack/nested/not-theme.md"), "ignored").unwrap();
4149        std::fs::write(
4150            root.join("package.json"),
4151            r#"{
4152                "name":"recursive-demo",
4153                "pi":{"prompts":["prompt-pack"],"themes":["theme-pack"]}
4154            }"#,
4155        )
4156        .unwrap();
4157
4158        let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
4159        assert_eq!(
4160            resources.prompt_dirs(),
4161            vec![
4162                root.join("prompt-pack/nested/deep.md"),
4163                root.join("prompt-pack/root.md"),
4164            ]
4165        );
4166        assert_eq!(
4167            resources.theme_files(),
4168            vec![
4169                root.join("theme-pack/nested/deep.json"),
4170                root.join("theme-pack/root.json"),
4171            ]
4172        );
4173    }
4174
4175    #[test]
4176    fn manifest_resource_paths_cannot_escape_the_package() {
4177        let tmp = tempfile::tempdir().unwrap();
4178        let root = tmp.path().join("pkg");
4179        let outside = tmp.path().join("outside");
4180        std::fs::create_dir_all(&root).unwrap();
4181        std::fs::create_dir_all(&outside).unwrap();
4182        std::fs::write(outside.join("outside.ts"), "export default () => {};").unwrap();
4183        std::fs::write(
4184            root.join("package.json"),
4185            r#"{"name":"escape-demo","pi":{"extensions":["../outside/outside.ts","../outside/*.ts"]}}"#,
4186        )
4187        .unwrap();
4188
4189        let package = load_package(
4190            root.clone(),
4191            &root.to_string_lossy(),
4192            tmp.path(),
4193            ResolveScope::Any,
4194            None,
4195        )
4196        .unwrap();
4197        assert!(package.extensions.is_empty());
4198    }
4199
4200    #[test]
4201    fn manifest_resource_symlink_escape_is_rejected() {
4202        let tmp = tempfile::tempdir().unwrap();
4203        let root = tmp.path().join("pkg");
4204        let outside = tmp.path().join("outside.ts");
4205        std::fs::create_dir_all(&root).unwrap();
4206        std::fs::write(&outside, "export default () => {};").unwrap();
4207        let link = root.join("linked.ts");
4208        #[cfg(unix)]
4209        std::os::unix::fs::symlink(&outside, &link).unwrap();
4210        #[cfg(windows)]
4211        if std::os::windows::fs::symlink_file(&outside, &link).is_err() {
4212            return;
4213        }
4214        std::fs::write(
4215            root.join("package.json"),
4216            r#"{"name":"symlink-demo","pi":{"extensions":["linked.ts"]}}"#,
4217        )
4218        .unwrap();
4219
4220        let package = load_package(
4221            root.clone(),
4222            &root.to_string_lossy(),
4223            tmp.path(),
4224            ResolveScope::Any,
4225            None,
4226        )
4227        .unwrap();
4228        assert!(package.extensions.is_empty());
4229    }
4230
4231    #[test]
4232    fn resolves_package_json_spec_and_deduplicates() {
4233        let tmp = tempfile::tempdir().unwrap();
4234        let root = tmp.path().join("pkg");
4235        std::fs::create_dir_all(&root).unwrap();
4236        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4237        let manifest = root.join("package.json").to_string_lossy().into_owned();
4238        let resources = discover(
4239            tmp.path(),
4240            &[manifest.clone(), root.to_string_lossy().into_owned()],
4241        );
4242        assert_eq!(resources.packages.len(), 1);
4243        assert!(resources.diagnostics.is_empty());
4244    }
4245
4246    #[test]
4247    fn bare_package_name_prefers_project_rpi_store_over_legacy_pi_store() {
4248        let tmp = tempfile::tempdir().unwrap();
4249        let rpi_root = tmp.path().join(".rpi/packages/demo");
4250        let pi_root = tmp.path().join(".pi/packages/demo");
4251        std::fs::create_dir_all(rpi_root.join("skills")).unwrap();
4252        std::fs::create_dir_all(pi_root.join("skills")).unwrap();
4253        std::fs::write(
4254            rpi_root.join("package.json"),
4255            r#"{"name":"rpi-demo","version":"rpi"}"#,
4256        )
4257        .unwrap();
4258        std::fs::write(
4259            pi_root.join("package.json"),
4260            r#"{"name":"pi-demo","version":"pi"}"#,
4261        )
4262        .unwrap();
4263
4264        let resources = discover(tmp.path(), &["demo".to_string()]);
4265        assert_eq!(resources.packages.len(), 1);
4266        assert_eq!(resources.packages[0].root, rpi_root);
4267        assert_eq!(resources.packages[0].version.as_deref(), Some("rpi"));
4268    }
4269
4270    #[test]
4271    fn npm_scoped_spec_resolves_installed_safe_name() {
4272        let tmp = tempfile::tempdir().unwrap();
4273        let root = tmp.path().join(".rpi/packages/narumitw__pi-btw");
4274        std::fs::create_dir_all(&root).unwrap();
4275        std::fs::write(
4276            root.join("package.json"),
4277            r#"{"name":"@narumitw/pi-btw","version":"0.58.1"}"#,
4278        )
4279        .unwrap();
4280        write_npm_source_marker(&root, "npm:@narumitw/pi-btw").unwrap();
4281
4282        let resources = discover(tmp.path(), &["npm:@narumitw/pi-btw".to_string()]);
4283        assert_eq!(resources.packages.len(), 1);
4284        assert!(resources.diagnostics.is_empty());
4285        assert_eq!(resources.packages[0].name, "@narumitw/pi-btw");
4286    }
4287
4288    #[test]
4289    fn npm_scoped_spec_resolves_project_store_and_versioned_spec() {
4290        let tmp = tempfile::tempdir().unwrap();
4291        let root = tmp.path().join(".pi/npm/node_modules/@scope/demo");
4292        std::fs::create_dir_all(&root).unwrap();
4293        std::fs::write(
4294            root.join("package.json"),
4295            r#"{"name":"@scope/demo","version":"1.2.3"}"#,
4296        )
4297        .unwrap();
4298
4299        for spec in ["npm:@scope/demo", "npm:@scope/demo@1.2.3"] {
4300            let resources = discover(tmp.path(), &[spec.to_string()]);
4301            assert!(resources.diagnostics.is_empty(), "spec={spec}");
4302            assert_eq!(resources.packages[0].root, root, "spec={spec}");
4303        }
4304    }
4305
4306    #[test]
4307    fn npm_store_detection_is_bounded_to_the_configured_agent_root() {
4308        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4309        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4310        let tmp = tempfile::tempdir().unwrap();
4311        let agent = tmp.path().join("real-agent");
4312        let package = agent.join("npm/node_modules/@scope/demo");
4313        let impostor = tmp
4314            .path()
4315            .join("workspace/agent/npm/node_modules/@scope/demo");
4316        std::fs::create_dir_all(&package).unwrap();
4317        std::fs::create_dir_all(&impostor).unwrap();
4318        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4319
4320        assert!(is_npm_store_package_path(
4321            &package,
4322            tmp.path(),
4323            ResolveScope::Any
4324        ));
4325        assert_eq!(
4326            npm_install_root_for_path(&package, tmp.path(), ResolveScope::Any),
4327            std::fs::canonicalize(agent.join("npm")).ok()
4328        );
4329        assert!(!is_npm_store_package_path(
4330            &impostor,
4331            tmp.path(),
4332            ResolveScope::Any
4333        ));
4334        assert!(!is_npm_store_package_path(
4335            &agent.join("npm/node_modules"),
4336            tmp.path(),
4337            ResolveScope::Any
4338        ));
4339        let nested = package.join("node_modules/dependency");
4340        std::fs::create_dir_all(&nested).unwrap();
4341        assert!(!is_npm_store_package_path(
4342            &nested,
4343            tmp.path(),
4344            ResolveScope::Any
4345        ));
4346
4347        match previous {
4348            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4349            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4350        }
4351    }
4352
4353    #[test]
4354    fn legacy_global_npm_is_discovered_but_updates_only_in_managed_store() {
4355        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4356        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4357        let tmp = tempfile::tempdir().unwrap();
4358        let agent = tmp.path().join("agent");
4359        let cwd = tmp.path().join("project");
4360        let global_root = tmp.path().join("legacy-global/node_modules");
4361        let global_package = global_root.join("demo");
4362        std::fs::create_dir_all(&agent).unwrap();
4363        std::fs::create_dir_all(&cwd).unwrap();
4364        std::fs::create_dir_all(global_package.join("extensions")).unwrap();
4365        std::fs::write(
4366            global_package.join("package.json"),
4367            r#"{"name":"demo","version":"1.0.0"}"#,
4368        )
4369        .unwrap();
4370        std::fs::write(
4371            global_package.join("extensions/index.js"),
4372            "export default () => {};",
4373        )
4374        .unwrap();
4375        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4376
4377        let resolved =
4378            resolve_spec_with_legacy_lookup(&cwd, "npm:demo", ResolveScope::User, |name| {
4379                assert_eq!(name, "demo");
4380                std::fs::canonicalize(&global_package).ok()
4381            })
4382            .unwrap();
4383        let canonical_global_root = std::fs::canonicalize(&global_root).unwrap();
4384        assert_eq!(
4385            resolved.root,
4386            std::fs::canonicalize(&global_package).unwrap()
4387        );
4388        assert_eq!(
4389            resolved.legacy_npm_root.as_deref(),
4390            Some(canonical_global_root.as_path())
4391        );
4392
4393        let package = load_package_with_legacy_root(
4394            resolved.root,
4395            "npm:demo",
4396            &cwd,
4397            ResolveScope::User,
4398            None,
4399            resolved.legacy_npm_root,
4400        )
4401        .unwrap();
4402        assert_eq!(package.updateable_npm_name(), Some("demo"));
4403        assert!(package.npm_install_root.is_none());
4404        let update_root = package
4405            .npm_store_root_for_update(&cwd, false)
4406            .unwrap()
4407            .unwrap();
4408        assert_eq!(update_root, agent.join("npm"));
4409        assert_ne!(update_root, global_root);
4410        assert!(!is_managed_package_path(
4411            &global_package,
4412            &cwd,
4413            ResolveScope::User
4414        ));
4415
4416        match previous {
4417            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4418            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4419        }
4420    }
4421
4422    #[test]
4423    fn static_managed_npm_precedes_legacy_lookup() {
4424        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4425        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4426        let tmp = tempfile::tempdir().unwrap();
4427        let agent = tmp.path().join("agent");
4428        let package = agent.join("npm/node_modules/demo");
4429        std::fs::create_dir_all(&package).unwrap();
4430        std::fs::write(package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4431        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4432
4433        let resolved =
4434            resolve_spec_with_legacy_lookup(tmp.path(), "npm:demo", ResolveScope::User, |_| {
4435                panic!("legacy global lookup must not run for a managed package")
4436            })
4437            .unwrap();
4438        assert_eq!(resolved.root, package);
4439        assert!(resolved.legacy_npm_root.is_none());
4440
4441        match previous {
4442            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4443            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4444        }
4445    }
4446
4447    #[test]
4448    fn legacy_global_lookup_is_never_used_for_project_scope() {
4449        let tmp = tempfile::tempdir().unwrap();
4450        let global_root = tmp.path().join("legacy/node_modules/demo");
4451        std::fs::create_dir_all(&global_root).unwrap();
4452        std::fs::write(global_root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4453        let resolved = resolve_spec_with_legacy_lookup(
4454            &tmp.path().join("project"),
4455            "npm:demo",
4456            ResolveScope::Project,
4457            |_| panic!("project scope must not consult a global package manager"),
4458        );
4459        assert!(resolved.is_none());
4460    }
4461
4462    #[test]
4463    fn legacy_manifest_name_mismatch_blocks_package_loading() {
4464        let tmp = tempfile::tempdir().unwrap();
4465        let global_root = tmp.path().join("legacy/node_modules");
4466        let package_root = global_root.join("demo");
4467        std::fs::create_dir_all(&package_root).unwrap();
4468        std::fs::write(
4469            package_root.join("package.json"),
4470            r#"{"name":"other","version":"1.0.0"}"#,
4471        )
4472        .unwrap();
4473        let error = load_package_with_legacy_root(
4474            std::fs::canonicalize(&package_root).unwrap(),
4475            "npm:demo",
4476            tmp.path(),
4477            ResolveScope::User,
4478            None,
4479            std::fs::canonicalize(&global_root).ok(),
4480        )
4481        .unwrap_err();
4482        assert!(error.contains("manifest name `other`"), "{error}");
4483        assert!(error.contains("configured package `demo`"), "{error}");
4484    }
4485
4486    #[test]
4487    fn legacy_npm_without_manifest_identity_blocks_package_loading() {
4488        let tmp = tempfile::tempdir().unwrap();
4489        let global_root = tmp.path().join("legacy/node_modules");
4490        let package_root = global_root.join("demo");
4491        std::fs::create_dir_all(&package_root).unwrap();
4492        std::fs::write(package_root.join("package.json"), r#"{"version":"1.0.0"}"#).unwrap();
4493
4494        let error = load_package_with_legacy_root(
4495            std::fs::canonicalize(&package_root).unwrap(),
4496            "npm:demo",
4497            tmp.path(),
4498            ResolveScope::User,
4499            None,
4500            std::fs::canonicalize(&global_root).ok(),
4501        )
4502        .unwrap_err();
4503
4504        assert!(error.contains("has no string package name"), "{error}");
4505        assert!(error.contains("expected `demo`"), "{error}");
4506    }
4507
4508    #[test]
4509    fn filtered_package_entries_apply_only_the_requested_resources() {
4510        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4511        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4512        let tmp = tempfile::tempdir().unwrap();
4513        let agent = tmp.path().join("agent");
4514        let root = agent.join("packages/demo");
4515        std::fs::create_dir_all(root.join("extensions")).unwrap();
4516        std::fs::create_dir_all(root.join("skills")).unwrap();
4517        std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
4518        std::fs::write(root.join("skills/review.md"), "review").unwrap();
4519        std::fs::write(
4520            root.join("package.json"),
4521            r#"{"name":"demo","version":"1.0.0"}"#,
4522        )
4523        .unwrap();
4524        write_npm_source_marker(&root, "npm:demo@beta").unwrap();
4525        std::fs::write(
4526            agent.join("settings.json"),
4527            r#"{"packages":[{"source":"npm:demo@beta","autoload":false,"extensions":["+extensions/index.js"]}]}"#,
4528        )
4529        .unwrap();
4530        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4531
4532        let resources = discover_from_global_settings(tmp.path());
4533
4534        match previous {
4535            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4536            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4537        }
4538        assert_eq!(resources.packages.len(), 1);
4539        assert_eq!(
4540            resources.packages[0].updateable_npm_source(),
4541            Some(("demo", "npm:demo@beta"))
4542        );
4543        assert_eq!(
4544            resources.extension_paths(),
4545            vec![root.join("extensions/index.js")]
4546        );
4547        assert!(resources.skill_dirs().is_empty());
4548        assert!(resources.diagnostics.is_empty());
4549    }
4550
4551    #[test]
4552    fn filtered_package_entries_do_not_disable_other_resource_kinds() {
4553        let tmp = tempfile::tempdir().unwrap();
4554        let root = tmp.path().join("package");
4555        std::fs::create_dir_all(root.join("extensions")).unwrap();
4556        std::fs::create_dir_all(root.join("skills")).unwrap();
4557        std::fs::create_dir_all(root.join("prompts")).unwrap();
4558        std::fs::create_dir_all(root.join("themes")).unwrap();
4559        for (path, body) in [
4560            ("extensions/a.js", "export default () => {};"),
4561            ("skills/keep.md", "keep"),
4562            ("skills/drop.md", "drop"),
4563            ("prompts/one.md", "one"),
4564            ("themes/one.json", "{}"),
4565        ] {
4566            std::fs::write(root.join(path), body).unwrap();
4567        }
4568        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4569        let filter = crate::settings::PackageFilter {
4570            source: root.to_string_lossy().into_owned(),
4571            autoload: None,
4572            extensions: Some(Vec::new()),
4573            skills: Some(vec!["skills/keep.md".to_string()]),
4574            prompts: None,
4575            themes: None,
4576            unknown: serde_json::Map::new(),
4577        };
4578        let package = load_package(
4579            root.clone(),
4580            &filter.source,
4581            tmp.path(),
4582            ResolveScope::Any,
4583            Some(&filter),
4584        )
4585        .unwrap();
4586        assert!(package.extensions.is_empty());
4587        assert_eq!(package.skills, vec![root.join("skills/keep.md")]);
4588        assert_eq!(package.prompts, vec![root.join("prompts/one.md")]);
4589        assert_eq!(package.themes, vec![root.join("themes/one.json")]);
4590    }
4591
4592    #[test]
4593    fn project_autoload_delta_keeps_matching_global_package_resources() {
4594        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4595        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4596        let tmp = tempfile::tempdir().unwrap();
4597        let agent = tmp.path().join("agent");
4598        let cwd = tmp.path().join("project");
4599        let root = tmp.path().join("shared-package");
4600        std::fs::create_dir_all(root.join("extensions")).unwrap();
4601        std::fs::write(root.join("extensions/a.js"), "export default () => {}; ").unwrap();
4602        std::fs::write(root.join("extensions/b.js"), "export default () => {}; ").unwrap();
4603        std::fs::write(root.join("package.json"), r#"{"name":"shared"}"#).unwrap();
4604        let spec = format!("file:{}", root.display());
4605        std::fs::create_dir_all(&agent).unwrap();
4606        std::fs::write(
4607            agent.join("settings.json"),
4608            serde_json::to_vec(&serde_json::json!({"packages":[spec]})).unwrap(),
4609        )
4610        .unwrap();
4611        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4612        std::fs::write(
4613            cwd.join(".rpi/settings.json"),
4614            serde_json::to_vec(&serde_json::json!({
4615                "packages":[{"source":spec,"autoload":false,"extensions":["+extensions/a.js"]}]
4616            }))
4617            .unwrap(),
4618        )
4619        .unwrap();
4620        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4621        let resources = discover_from_settings(&cwd);
4622        match previous {
4623            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4624            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4625        }
4626        assert_eq!(resources.packages.len(), 1);
4627        assert!(!resources.packages[0].autoload_delta);
4628        assert_eq!(resources.extension_paths().len(), 2);
4629    }
4630
4631    #[test]
4632    fn configured_packages_with_same_manifest_name_keep_distinct_local_roots() {
4633        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4634        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4635        let tmp = tempfile::tempdir().unwrap();
4636        let agent = tmp.path().join("agent");
4637        let cwd = tmp.path().join("project");
4638        let first = tmp.path().join("first");
4639        let second = tmp.path().join("second");
4640        for root in [&first, &second] {
4641            std::fs::create_dir_all(root.join("skills")).unwrap();
4642            std::fs::write(root.join("skills/item.md"), "item").unwrap();
4643            std::fs::write(root.join("package.json"), r#"{"name":"same"}"#).unwrap();
4644        }
4645        std::fs::create_dir_all(&agent).unwrap();
4646        std::fs::write(
4647            agent.join("settings.json"),
4648            serde_json::to_vec(
4649                &serde_json::json!({"packages":[format!("file:{}", second.display())]}),
4650            )
4651            .unwrap(),
4652        )
4653        .unwrap();
4654        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
4655        std::fs::write(
4656            cwd.join(".rpi/settings.json"),
4657            serde_json::to_vec(
4658                &serde_json::json!({"packages":[format!("file:{}", first.display())]}),
4659            )
4660            .unwrap(),
4661        )
4662        .unwrap();
4663        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4664        let resources = discover_from_settings(&cwd);
4665        match previous {
4666            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4667            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4668        }
4669        assert_eq!(resources.packages.len(), 2);
4670        assert_eq!(resources.packages[0].root, first);
4671        assert_eq!(resources.packages[1].root, second);
4672    }
4673
4674    #[test]
4675    fn project_relative_package_paths_resolve_from_pi_config_directory() {
4676        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4677        let tmp = tempfile::tempdir().unwrap();
4678        let previous_config = std::env::var_os(config::CONFIG_DIR_ENV);
4679        let isolated_agent = tmp.path().join("agent");
4680        std::fs::create_dir_all(&isolated_agent).unwrap();
4681        std::env::set_var(config::CONFIG_DIR_ENV, &isolated_agent);
4682        let cwd = tmp.path().join("project");
4683        let root = cwd.join(".pi/packages/demo");
4684        std::fs::create_dir_all(root.join("skills")).unwrap();
4685        std::fs::write(root.join("skills/item.md"), "item").unwrap();
4686        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
4687        std::fs::write(
4688            cwd.join(".pi/settings.json"),
4689            r#"{"packages":["./packages/demo"]}"#,
4690        )
4691        .unwrap();
4692        let resources = discover_from_settings(&cwd);
4693        match previous_config {
4694            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4695            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4696        }
4697        assert_eq!(resources.packages.len(), 1);
4698        assert_eq!(resources.packages[0].root, root);
4699    }
4700
4701    #[test]
4702    fn native_git_sources_resolve_only_inside_pi_git_store() {
4703        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4704        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4705        let tmp = tempfile::tempdir().unwrap();
4706        let agent = tmp.path().join("agent");
4707        std::fs::create_dir_all(&agent).unwrap();
4708        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4709        let cwd = tmp.path().join("project");
4710        let root = cwd.join(".pi/git/github.com/example/repo");
4711        std::fs::create_dir_all(root.join(".git")).unwrap();
4712        std::fs::create_dir_all(root.join("skills")).unwrap();
4713        std::fs::write(root.join("skills/item.md"), "item").unwrap();
4714        std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4715        std::fs::create_dir_all(cwd.join(".pi")).unwrap();
4716        std::fs::write(
4717            cwd.join(".pi/settings.json"),
4718            r#"{"packages":["git:https://github.com/example/repo.git@main"]}"#,
4719        )
4720        .unwrap();
4721        let resources = discover_from_settings(&cwd);
4722        match previous {
4723            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4724            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4725        }
4726        assert_eq!(resources.packages.len(), 1);
4727        assert_eq!(resources.packages[0].source, PackageSource::Git);
4728        assert_eq!(resources.packages[0].git_revision.as_deref(), Some("main"));
4729    }
4730
4731    #[test]
4732    fn git_sources_preserve_slash_refs_across_supported_transports() {
4733        let cases = [
4734            (
4735                "git:github.com/example/repo@feature/branch",
4736                "github.com",
4737                "example/repo",
4738                Some("feature/branch"),
4739            ),
4740            (
4741                "https://github.com/example/repo.git@feature/branch",
4742                "github.com",
4743                "example/repo",
4744                Some("feature/branch"),
4745            ),
4746            (
4747                "ssh://git@github.com/example/repo@release/v2",
4748                "github.com",
4749                "example/repo",
4750                Some("release/v2"),
4751            ),
4752            (
4753                "git://github.com/example/repo.git@refs/heads/main",
4754                "github.com",
4755                "example/repo",
4756                Some("refs/heads/main"),
4757            ),
4758            (
4759                "git:git@github.com:example/repo@hotfix/security",
4760                "github.com",
4761                "example/repo",
4762                Some("hotfix/security"),
4763            ),
4764        ];
4765        for (spec, host, path, revision) in cases {
4766            let parsed = parse_git_source(spec).unwrap_or_else(|| panic!("spec={spec}"));
4767            assert_eq!(parsed.host, host, "spec={spec}");
4768            assert_eq!(parsed.path, path, "spec={spec}");
4769            assert_eq!(parsed.revision.as_deref(), revision, "spec={spec}");
4770        }
4771    }
4772
4773    #[test]
4774    fn git_source_parser_rejects_encoded_traversal_and_unsafe_refs() {
4775        for spec in [
4776            "git:git@evil.example:../../victim/repo",
4777            "https://evil.example/..%2F..%2Fvictim/repo",
4778            "git:github.com/example/repo@../escape",
4779            "git:github.com/example/repo@-upload-pack=evil",
4780            "git:github.com/example/repo@feature\\branch",
4781            "git:github.com/example/repo@feature%2F..%2Fescape",
4782        ] {
4783            assert!(parse_git_source(spec).is_none(), "spec={spec}");
4784        }
4785    }
4786
4787    #[test]
4788    fn git_source_parser_preserves_remote_transport_authority() {
4789        let shorthand = parse_git_source("git:github.com/example/repo").unwrap();
4790        assert_eq!(shorthand.transport, GitTransport::Https);
4791        assert_eq!(shorthand.port, None);
4792        assert_eq!(shorthand.user_info, None);
4793
4794        let https = parse_git_source("https://token@github.com:8443/example/repo.git").unwrap();
4795        assert_eq!(https.transport, GitTransport::Https);
4796        assert_eq!(https.port, Some(8443));
4797        assert_eq!(https.user_info.as_deref(), Some("token"));
4798
4799        let scp = parse_git_source("git:git@github.com:example/repo").unwrap();
4800        assert_eq!(scp.transport, GitTransport::Ssh);
4801        assert_eq!(scp.port, None);
4802        assert_eq!(scp.user_info.as_deref(), Some("git"));
4803
4804        for invalid in [
4805            "https://github.com:70000/example/repo",
4806            "https://user @github.com/example/repo",
4807        ] {
4808            assert!(parse_git_source(invalid).is_none(), "spec={invalid}");
4809        }
4810    }
4811
4812    #[test]
4813    fn pinned_git_packages_are_selected_for_manual_updates() {
4814        let tmp = tempfile::tempdir().unwrap();
4815        let root = tmp.path().join(".pi/git/github.com/example/repo");
4816        std::fs::create_dir_all(root.join(".git")).unwrap();
4817        std::fs::write(root.join("package.json"), r#"{"name":"repo"}"#).unwrap();
4818        let package = load_package(
4819            root,
4820            "git:github.com/example/repo@feature/branch",
4821            tmp.path(),
4822            ResolveScope::Any,
4823            None,
4824        )
4825        .unwrap();
4826        assert_eq!(package.source, PackageSource::Git);
4827        assert_eq!(package.git_revision.as_deref(), Some("feature/branch"));
4828        assert!(package.updateable_git_source());
4829    }
4830
4831    #[test]
4832    fn missing_npm_update_targets_use_native_managed_roots_and_keep_pins() {
4833        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4834        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4835        let tmp = tempfile::tempdir().unwrap();
4836        let cwd = tmp.path().join("project");
4837        let agent = tmp.path().join("agent");
4838        std::fs::create_dir_all(&cwd).unwrap();
4839        std::fs::create_dir_all(&agent).unwrap();
4840        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4841
4842        let project =
4843            missing_package_for_update(&cwd, "npm:@scope/demo@beta", ResolveScope::Project, None)
4844                .unwrap()
4845                .unwrap();
4846        assert!(project.missing_install);
4847        assert_eq!(project.name, "@scope/demo");
4848        assert_eq!(project.root, cwd.join(".pi/npm/node_modules/@scope/demo"));
4849        assert_eq!(project.npm_install_root, Some(cwd.join(".pi/npm")));
4850        assert_eq!(
4851            project.updateable_npm_source(),
4852            Some(("@scope/demo", "npm:@scope/demo@beta"))
4853        );
4854
4855        let user = missing_package_for_update(&cwd, "npm:demo@1.2.3", ResolveScope::User, None)
4856            .unwrap()
4857            .unwrap();
4858        assert_eq!(user.root, agent.join("npm/node_modules/demo"));
4859        assert_eq!(user.npm_install_root, Some(agent.join("npm")));
4860        assert_eq!(
4861            user.updateable_npm_source(),
4862            Some(("demo", "npm:demo@1.2.3"))
4863        );
4864
4865        match previous {
4866            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4867            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4868        }
4869    }
4870
4871    #[test]
4872    fn missing_git_update_target_preserves_ref_and_scope() {
4873        let _guard = crate::config::test_support::env_lock().lock().unwrap();
4874        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
4875        let tmp = tempfile::tempdir().unwrap();
4876        let cwd = tmp.path().join("project");
4877        let agent = tmp.path().join("agent");
4878        std::fs::create_dir_all(&cwd).unwrap();
4879        std::fs::create_dir_all(&agent).unwrap();
4880        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
4881
4882        let project = missing_package_for_update(
4883            &cwd,
4884            "git:github.com/example/repo@feature/branch",
4885            ResolveScope::Project,
4886            None,
4887        )
4888        .unwrap()
4889        .unwrap();
4890        assert!(project.missing_install);
4891        assert!(project.updateable_git_source());
4892        assert_eq!(project.name, "repo");
4893        assert_eq!(project.root, cwd.join(".pi/git/github.com/example/repo"));
4894        assert_eq!(project.git_store_root, Some(cwd.join(".pi/git")));
4895        assert_eq!(project.git_revision.as_deref(), Some("feature/branch"));
4896        assert_eq!(
4897            update_recovery_targets(
4898                &cwd,
4899                "git:github.com/example/repo@feature/branch",
4900                ResolveScope::Project,
4901            ),
4902            vec![
4903                cwd.join(".rpi/git/github.com/example/repo"),
4904                cwd.join(".pi/git/github.com/example/repo"),
4905            ]
4906        );
4907
4908        let user = missing_package_for_update(
4909            &cwd,
4910            "https://github.com/example/other.git@release/v2",
4911            ResolveScope::User,
4912            None,
4913        )
4914        .unwrap()
4915        .unwrap();
4916        assert_eq!(user.root, agent.join("git/github.com/example/other"));
4917        assert_eq!(user.git_store_root, Some(agent.join("git")));
4918        assert_eq!(user.git_revision.as_deref(), Some("release/v2"));
4919        let mut recovery_targets = vec![agent.join("git/github.com/example/other")];
4920        if let Some(home) = dirs::home_dir() {
4921            recovery_targets.push(home.join(".pi/agent/git/github.com/example/other"));
4922        }
4923        assert_eq!(
4924            update_recovery_targets(
4925                &cwd,
4926                "https://github.com/example/other.git@release/v2",
4927                ResolveScope::User,
4928            ),
4929            recovery_targets
4930        );
4931
4932        match previous {
4933            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
4934            None => std::env::remove_var(config::CONFIG_DIR_ENV),
4935        }
4936    }
4937
4938    #[test]
4939    fn update_discovery_represents_missing_registry_and_git_sources_only() {
4940        let tmp = tempfile::tempdir().unwrap();
4941        let cwd = tmp.path().join("project");
4942        std::fs::create_dir_all(&cwd).unwrap();
4943        let entries = [
4944            "npm:demo@latest",
4945            "npm:fixed@1.2.3",
4946            "git:github.com/example/repo@main",
4947            "file:missing-local",
4948        ]
4949        .into_iter()
4950        .map(|source| crate::settings::PackageSetting::from(source.to_string()))
4951        .collect::<Vec<_>>();
4952
4953        let resources =
4954            discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, true, None);
4955        assert_eq!(resources.packages.len(), 3);
4956        assert!(resources
4957            .packages
4958            .iter()
4959            .all(|package| package.missing_install));
4960        assert!(resources.diagnostics.is_empty());
4961
4962        let ordinary =
4963            discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
4964        assert!(ordinary.packages.is_empty());
4965        assert_eq!(ordinary.diagnostics.len(), entries.len());
4966    }
4967
4968    #[test]
4969    fn git_metadata_requires_a_real_directory() {
4970        let tmp = tempfile::tempdir().unwrap();
4971        let git_file = tmp.path().join(".git");
4972        std::fs::write(&git_file, "gitdir: ../outside/.git\n").unwrap();
4973        assert!(!is_real_git_metadata(&git_file));
4974        std::fs::remove_file(&git_file).unwrap();
4975        std::fs::create_dir(&git_file).unwrap();
4976        assert!(is_real_git_metadata(&git_file));
4977    }
4978
4979    #[test]
4980    fn marker_source_updates_ranges_and_tags_but_skips_exact_versions() {
4981        let tmp = tempfile::tempdir().unwrap();
4982        let root = tmp.path().join(".rpi/packages/demo");
4983        std::fs::create_dir_all(&root).unwrap();
4984        std::fs::write(
4985            root.join("package.json"),
4986            r#"{"name":"demo","version":"1.0.0"}"#,
4987        )
4988        .unwrap();
4989
4990        let file_spec = format!("file:{}", root.display());
4991        for (source_spec, updateable) in [
4992            ("npm:demo@1.0.0", false),
4993            ("npm:demo@1.0.0-beta.1", false),
4994            ("npm:demo@^1", true),
4995            ("npm:demo@latest", true),
4996            ("npm:demo@beta", true),
4997            ("npm:demo", true),
4998        ] {
4999            write_npm_source_marker(&root, source_spec).unwrap();
5000            let package = load_package(
5001                root.clone(),
5002                &file_spec,
5003                tmp.path(),
5004                ResolveScope::Any,
5005                None,
5006            )
5007            .unwrap();
5008            assert_eq!(
5009                package.updateable_npm_name().is_some(),
5010                updateable,
5011                "source_spec={source_spec}"
5012            );
5013        }
5014    }
5015
5016    #[test]
5017    fn explicit_npm_in_ordinary_node_modules_is_never_updateable() {
5018        let tmp = tempfile::tempdir().unwrap();
5019        let root = tmp.path().join("node_modules/demo");
5020        std::fs::create_dir_all(&root).unwrap();
5021        std::fs::write(
5022            root.join("package.json"),
5023            r#"{"name":"demo","version":"1.0.0"}"#,
5024        )
5025        .unwrap();
5026        write_npm_source_marker(&root, "npm:demo").unwrap();
5027        let package = load_package(root, "npm:demo", tmp.path(), ResolveScope::Any, None).unwrap();
5028        assert_eq!(package.source, PackageSource::Unknown);
5029        assert_eq!(package.updateable_npm_name(), None);
5030    }
5031
5032    #[test]
5033    fn managed_npm_marker_must_match_manifest_name() {
5034        let tmp = tempfile::tempdir().unwrap();
5035        let root = tmp.path().join(".rpi/packages/demo");
5036        std::fs::create_dir_all(&root).unwrap();
5037        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5038        write_npm_source_marker(&root, "npm:other").unwrap();
5039        let spec = format!("file:{}", root.display());
5040        let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
5041        assert_eq!(package.source, PackageSource::Local);
5042    }
5043
5044    #[test]
5045    fn explicit_npm_source_must_match_marker_and_manifest() {
5046        let tmp = tempfile::tempdir().unwrap();
5047        let root = tmp.path().join(".rpi/packages/demo");
5048        std::fs::create_dir_all(&root).unwrap();
5049        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5050        write_npm_source_marker(&root, "npm:demo@beta").unwrap();
5051
5052        let matching = load_package(
5053            root.clone(),
5054            "npm:demo@beta",
5055            tmp.path(),
5056            ResolveScope::Any,
5057            None,
5058        )
5059        .unwrap();
5060        assert_eq!(
5061            matching.updateable_npm_source(),
5062            Some(("demo", "npm:demo@beta"))
5063        );
5064
5065        let wrong_name = load_package(
5066            root.clone(),
5067            "npm:other@beta",
5068            tmp.path(),
5069            ResolveScope::Any,
5070            None,
5071        )
5072        .unwrap_err();
5073        assert!(wrong_name.contains("manifest name `demo`"), "{wrong_name}");
5074
5075        let wrong_selector =
5076            load_package(root, "npm:demo@^1", tmp.path(), ResolveScope::Any, None).unwrap_err();
5077        assert!(wrong_selector.contains("provenance"), "{wrong_selector}");
5078    }
5079
5080    #[test]
5081    fn managed_npm_alias_marker_target_mismatch_blocks_loading() {
5082        let tmp = tempfile::tempdir().unwrap();
5083        let root = tmp.path().join(".rpi/packages/alias");
5084        std::fs::create_dir_all(&root).unwrap();
5085        std::fs::write(
5086            root.join("package.json"),
5087            r#"{"name":"real","version":"1.2.3"}"#,
5088        )
5089        .unwrap();
5090        write_npm_source_marker(&root, "npm:alias@npm:other@^1").unwrap();
5091
5092        let error = load_package(
5093            root,
5094            "npm:alias@npm:real@^1",
5095            tmp.path(),
5096            ResolveScope::Any,
5097            None,
5098        )
5099        .unwrap_err();
5100
5101        assert!(error.contains("provenance"), "{error}");
5102    }
5103
5104    #[test]
5105    fn explicit_native_npm_without_manifest_identity_is_not_loadable() {
5106        let tmp = tempfile::tempdir().unwrap();
5107        let cwd = tmp.path().join("project");
5108        let root = cwd.join(".pi/npm/node_modules/demo");
5109        std::fs::create_dir_all(root.join("extensions")).unwrap();
5110        std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
5111        let entries = [crate::settings::PackageSetting::from(
5112            "npm:demo".to_string(),
5113        )];
5114
5115        for manifest in [None, Some(r#"{"version":"1.0.0"}"#)] {
5116            if let Some(manifest) = manifest {
5117                std::fs::write(root.join("package.json"), manifest).unwrap();
5118            }
5119            let resources =
5120                discover_with_scope_and_command(&cwd, &entries, ResolveScope::Project, false, None);
5121            assert!(resources.packages.is_empty(), "manifest={manifest:?}");
5122            assert!(
5123                resources.extension_paths().is_empty(),
5124                "manifest={manifest:?}"
5125            );
5126            assert_eq!(resources.diagnostics.len(), 1, "manifest={manifest:?}");
5127            assert!(
5128                resources.diagnostics[0]
5129                    .message
5130                    .contains("has no string package name"),
5131                "{}",
5132                resources.diagnostics[0].message
5133            );
5134        }
5135    }
5136
5137    #[test]
5138    fn managed_file_entry_remains_updateable_after_changing_cwd() {
5139        let tmp = tempfile::tempdir().unwrap();
5140        let root = tmp.path().join("project-a/.rpi/packages/demo");
5141        let other_cwd = tmp.path().join("project-b");
5142        std::fs::create_dir_all(&root).unwrap();
5143        std::fs::create_dir_all(&other_cwd).unwrap();
5144        std::fs::write(
5145            root.join("package.json"),
5146            r#"{"name":"demo","version":"1.0.0"}"#,
5147        )
5148        .unwrap();
5149        write_npm_source_marker(&root, "npm:demo@beta").unwrap();
5150        let spec = format!("file:{}", root.display());
5151
5152        let package = load_package(root, &spec, &other_cwd, ResolveScope::User, None).unwrap();
5153        assert_eq!(
5154            package.updateable_npm_source(),
5155            Some(("demo", "npm:demo@beta"))
5156        );
5157    }
5158
5159    #[test]
5160    fn legacy_file_entry_for_managed_git_clone_keeps_git_provenance() {
5161        let tmp = tempfile::tempdir().unwrap();
5162        let root = tmp.path().join(".rpi/packages/demo");
5163        std::fs::create_dir_all(root.join(".git")).unwrap();
5164        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5165        let spec = format!("file:{}", root.display());
5166        let package = load_package(root, &spec, tmp.path(), ResolveScope::Any, None).unwrap();
5167        assert_eq!(package.source, PackageSource::Git);
5168    }
5169
5170    #[test]
5171    fn native_scoped_npm_root_has_registry_provenance() {
5172        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5173        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5174        let tmp = tempfile::tempdir().unwrap();
5175        let agent = tmp.path().join(".pi/agent");
5176        let root = agent.join("npm/node_modules/@scope/demo");
5177        std::fs::create_dir_all(&root).unwrap();
5178        std::fs::write(
5179            root.join("package.json"),
5180            r#"{"name":"@scope/demo","version":"1.0.0"}"#,
5181        )
5182        .unwrap();
5183        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5184        let package =
5185            load_package(root, "@scope/demo", tmp.path(), ResolveScope::Any, None).unwrap();
5186        match previous {
5187            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5188            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5189        }
5190        assert_eq!(package.updateable_npm_name(), Some("@scope/demo"));
5191    }
5192
5193    #[test]
5194    fn exact_semver_and_npm_name_validation_are_conservative() {
5195        for version in ["1.2.3", "v1.2.3", "1.2.3-beta.1", "1.2.3+build"] {
5196            assert!(is_exact_npm_version(version), "version={version}");
5197        }
5198        for version in ["1", "1.2", "^1.2.3", "latest", "1.2.3-", "1.02.3"] {
5199            assert!(!is_exact_npm_version(version), "version={version}");
5200        }
5201        for name in [
5202            "-rf",
5203            "--workspace",
5204            "@scope/..",
5205            "@scope/a\\b",
5206            "@scope/a?b",
5207            "a b",
5208            "a#b",
5209        ] {
5210            assert!(!valid_npm_name(name), "name={name}");
5211        }
5212    }
5213
5214    #[test]
5215    fn npm_alias_parser_separates_install_slot_from_manifest_name() {
5216        for (spec, install_name, manifest_name, requested, target_selector) in [
5217            ("alias@npm:real", "alias", "real", "npm:real", None),
5218            (
5219                "npm:@scope/alias@npm:real@^1",
5220                "@scope/alias",
5221                "real",
5222                "npm:real@^1",
5223                Some("^1"),
5224            ),
5225            (
5226                "alias@npm:@target/real@beta",
5227                "alias",
5228                "@target/real",
5229                "npm:@target/real@beta",
5230                Some("beta"),
5231            ),
5232            (
5233                "@scope/alias@npm:@target/real@1.2.3",
5234                "@scope/alias",
5235                "@target/real",
5236                "npm:@target/real@1.2.3",
5237                Some("1.2.3"),
5238            ),
5239        ] {
5240            let parsed = parse_npm_package_spec(spec).unwrap_or_else(|| panic!("spec={spec}"));
5241            assert_eq!(parsed.install_name, install_name, "spec={spec}");
5242            assert_eq!(parsed.manifest_name, manifest_name, "spec={spec}");
5243            assert_eq!(parsed.requested.as_deref(), Some(requested), "spec={spec}");
5244            assert_eq!(
5245                parsed.target_selector.as_deref(),
5246                target_selector,
5247                "spec={spec}"
5248            );
5249            assert!(parsed.is_alias, "spec={spec}");
5250        }
5251
5252        for spec in [
5253            "alias@npm:real@npm:other",
5254            "alias@file:../real",
5255            "alias@npm:real@file:../other",
5256            "alias@git:https://example.com/repo.git",
5257            "alias@npm:",
5258            "@scope/alias@npm:@target/real@npm:other",
5259            "alias@npm:real\nlatest",
5260        ] {
5261            assert!(parse_npm_package_spec(spec).is_none(), "spec={spec}");
5262        }
5263    }
5264
5265    #[test]
5266    fn managed_npm_alias_keeps_provenance_and_rejects_manifest_mismatch() {
5267        let tmp = tempfile::tempdir().unwrap();
5268        let root = tmp.path().join(".pi/npm/node_modules/@scope/alias");
5269        std::fs::create_dir_all(&root).unwrap();
5270        std::fs::write(
5271            root.join("package.json"),
5272            r#"{"name":"@target/real","version":"1.2.3"}"#,
5273        )
5274        .unwrap();
5275        let spec = "npm:@scope/alias@npm:@target/real@^1";
5276
5277        let package =
5278            load_package(root.clone(), spec, tmp.path(), ResolveScope::Project, None).unwrap();
5279        assert_eq!(package.name, "@target/real");
5280        assert_eq!(package_identity(&package), "npm:@scope/alias");
5281        assert_eq!(
5282            package.updateable_npm_source(),
5283            Some(("@scope/alias", spec))
5284        );
5285
5286        std::fs::write(
5287            root.join("package.json"),
5288            r#"{"name":"@target/wrong","version":"1.2.3"}"#,
5289        )
5290        .unwrap();
5291        let mismatched =
5292            load_package(root, spec, tmp.path(), ResolveScope::Project, None).unwrap_err();
5293        assert!(
5294            mismatched.contains("manifest name `@target/wrong`"),
5295            "{mismatched}"
5296        );
5297    }
5298
5299    #[test]
5300    fn runtime_npm_alias_matches_target_selector_and_pinning() {
5301        let tmp = tempfile::tempdir().unwrap();
5302        for (slot, target, version, selector, needs_install, pinned) in [
5303            (
5304                "exact-match",
5305                "real-exact-match",
5306                "1.2.3",
5307                "1.2.3",
5308                false,
5309                true,
5310            ),
5311            (
5312                "exact-stale",
5313                "real-exact-stale",
5314                "1.2.4",
5315                "1.2.3",
5316                true,
5317                true,
5318            ),
5319            (
5320                "range-match",
5321                "real-range-match",
5322                "1.9.0",
5323                "^1.2.3",
5324                false,
5325                false,
5326            ),
5327            (
5328                "range-stale",
5329                "real-range-stale",
5330                "2.0.0",
5331                "^1.2.3",
5332                true,
5333                false,
5334            ),
5335            ("tag", "real-tag", "1.0.0", "beta", false, false),
5336        ] {
5337            let root = tmp.path().join(".pi/npm/node_modules").join(slot);
5338            std::fs::create_dir_all(&root).unwrap();
5339            std::fs::write(
5340                root.join("package.json"),
5341                serde_json::to_vec(&serde_json::json!({
5342                    "name": target,
5343                    "version": version
5344                }))
5345                .unwrap(),
5346            )
5347            .unwrap();
5348            let spec = format!("npm:{slot}@npm:{target}@{selector}");
5349            let package =
5350                load_package(root, &spec, tmp.path(), ResolveScope::Project, None).unwrap();
5351
5352            assert_eq!(
5353                runtime_npm_needs_install(&package),
5354                needs_install,
5355                "spec={spec}"
5356            );
5357            assert_eq!(
5358                matches!(package.source, PackageSource::Npm { pinned: true, .. }),
5359                pinned,
5360                "spec={spec}"
5361            );
5362            assert_eq!(
5363                package.updateable_npm_source().is_some(),
5364                !pinned,
5365                "spec={spec}"
5366            );
5367        }
5368    }
5369
5370    #[test]
5371    fn runtime_npm_version_matching_covers_native_common_ranges() {
5372        for (installed, requested, expected) in [
5373            (Some("1.2.3"), "1.2.3", Some(true)),
5374            (Some("1.2.4"), "1.2.3", Some(false)),
5375            (Some("1.9.0"), "^1.2.3", Some(true)),
5376            (Some("2.0.0"), "^1.2.3", Some(false)),
5377            (Some("1.2.9"), "~1.2.3", Some(true)),
5378            (Some("1.3.0"), "~1.2.3", Some(false)),
5379            (Some("1.2.9"), "1.2", Some(true)),
5380            (Some("1.3.0"), "1.2", Some(false)),
5381            (Some("1.5.0"), ">=1.2.0 <2.0.0", Some(true)),
5382            (Some("1.9.9"), ">= 2.0.0", Some(false)),
5383            (Some("2.0.0"), ">= 2.0.0", Some(true)),
5384            (Some("2.5.0"), ">= 2.0.0 < 3.0.0", Some(true)),
5385            (Some("3.0.0"), ">= 2.0.0 < 3.0.0", Some(false)),
5386            (Some("2.1.0"), "^1 || ^2", Some(true)),
5387            (Some("3.0.0"), "^1 || ^2", Some(false)),
5388            (Some("1.3.9"), "1.2 - 1.3", Some(true)),
5389            (Some("1.4.0"), "1.2 - 1.3", Some(false)),
5390            (Some("2.9.0"), "1 - 2", Some(true)),
5391            (Some("3.0.0"), "1 - 2", Some(false)),
5392            (Some("1.0.0"), "latest", None),
5393            (None, "1.2.3", Some(false)),
5394        ] {
5395            assert_eq!(
5396                npm_version_matches_requirement(installed, requested),
5397                expected,
5398                "installed={installed:?}, requested={requested}"
5399            );
5400        }
5401    }
5402
5403    #[test]
5404    fn runtime_missing_exact_npm_fails_closed_for_invalid_command() {
5405        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5406        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5407        let tmp = tempfile::tempdir().unwrap();
5408        let agent = tmp.path().join("agent");
5409        let cwd = tmp.path().join("project");
5410        std::fs::create_dir_all(&agent).unwrap();
5411        std::fs::create_dir_all(&cwd).unwrap();
5412        std::fs::write(
5413            agent.join("settings.json"),
5414            r#"{"packages":["npm:demo@1.2.3"],"npmCommand":[""]}"#,
5415        )
5416        .unwrap();
5417        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5418
5419        let resources = resolve_from_global_settings(&cwd);
5420
5421        match previous {
5422            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5423            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5424        }
5425        assert!(resources.packages.is_empty());
5426        assert_eq!(resources.diagnostics.len(), 1);
5427        assert!(resources.diagnostics[0]
5428            .message
5429            .contains("invalid npmCommand"));
5430        assert!(!agent.join("npm/node_modules/demo").exists());
5431    }
5432
5433    #[test]
5434    fn offline_runtime_quarantines_mismatched_npm_but_keeps_matching_range() {
5435        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5436        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5437        let tmp = tempfile::tempdir().unwrap();
5438        let agent = tmp.path().join("agent");
5439        let cwd = tmp.path().join("project");
5440        for (name, version) in [("stale", "1.0.0"), ("matching", "1.5.0")] {
5441            let root = agent.join("npm/node_modules").join(name);
5442            std::fs::create_dir_all(root.join("extensions")).unwrap();
5443            std::fs::write(
5444                root.join("package.json"),
5445                serde_json::to_vec(&serde_json::json!({
5446                    "name": name,
5447                    "version": version
5448                }))
5449                .unwrap(),
5450            )
5451            .unwrap();
5452            std::fs::write(root.join("extensions/index.js"), "export default () => {};").unwrap();
5453        }
5454        std::fs::create_dir_all(&agent).unwrap();
5455        std::fs::create_dir_all(&cwd).unwrap();
5456        std::fs::write(
5457            agent.join("settings.json"),
5458            r#"{
5459                "npmCommand":[""],
5460                "packages":["npm:stale@2.0.0","npm:matching@^1.0.0"]
5461            }"#,
5462        )
5463        .unwrap();
5464        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5465
5466        let resources = resolve_offline_from_global_settings(&cwd);
5467
5468        match previous {
5469            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5470            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5471        }
5472        assert_eq!(resources.packages.len(), 1);
5473        assert_eq!(resources.packages[0].name, "matching");
5474        assert_eq!(resources.diagnostics.len(), 1);
5475        assert_eq!(resources.diagnostics[0].spec, "npm:stale@2.0.0");
5476        assert!(resources.diagnostics[0].message.contains("offline"));
5477    }
5478
5479    #[test]
5480    fn offline_runtime_never_invokes_configured_npm_for_legacy_lookup() {
5481        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5482        let _restore_config = RestoreEnv::capture(config::CONFIG_DIR_ENV);
5483        let tmp = tempfile::tempdir().unwrap();
5484        let agent = tmp.path().join("agent");
5485        let cwd = tmp.path().join("project");
5486        let marker = tmp.path().join("npm-command-ran");
5487        let script = tmp
5488            .path()
5489            .join(if cfg!(windows) { "npm.ps1" } else { "npm.sh" });
5490        let script_body = if cfg!(windows) {
5491            format!(
5492                "Set-Content -LiteralPath '{}' -Value invoked\nexit 0\n",
5493                marker.to_string_lossy().replace('\'', "''")
5494            )
5495        } else {
5496            format!(
5497                "printf invoked > '{}'\nexit 0\n",
5498                marker.to_string_lossy().replace('\'', "'\\''")
5499            )
5500        };
5501        std::fs::create_dir_all(&agent).unwrap();
5502        std::fs::create_dir_all(&cwd).unwrap();
5503        std::fs::write(&script, script_body).unwrap();
5504        let npm_command = if cfg!(windows) {
5505            vec![
5506                "powershell.exe".to_string(),
5507                "-NoProfile".to_string(),
5508                "-NonInteractive".to_string(),
5509                "-File".to_string(),
5510                script.to_string_lossy().into_owned(),
5511            ]
5512        } else {
5513            vec!["sh".to_string(), script.to_string_lossy().into_owned()]
5514        };
5515        std::fs::write(
5516            agent.join("settings.json"),
5517            serde_json::to_vec(&serde_json::json!({
5518                "npmCommand": npm_command,
5519                "packages": ["npm:missing-legacy-package"]
5520            }))
5521            .unwrap(),
5522        )
5523        .unwrap();
5524        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5525
5526        let resources = resolve_offline_from_global_settings(&cwd);
5527
5528        assert!(resources.packages.is_empty());
5529        assert_eq!(resources.diagnostics.len(), 1);
5530        assert!(
5531            !marker.exists(),
5532            "offline package discovery unexpectedly launched npmCommand"
5533        );
5534    }
5535
5536    #[test]
5537    fn runtime_rechecks_version_after_a_noop_package_manager_success() {
5538        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5539        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5540        let tmp = tempfile::tempdir().unwrap();
5541        let agent = tmp.path().join("agent");
5542        let cwd = tmp.path().join("project");
5543        let root = agent.join("npm/node_modules/demo");
5544        std::fs::create_dir_all(&root).unwrap();
5545        std::fs::create_dir_all(&cwd).unwrap();
5546        std::fs::write(
5547            root.join("package.json"),
5548            r#"{"name":"demo","version":"1.0.0"}"#,
5549        )
5550        .unwrap();
5551        let noop_script = tmp
5552            .path()
5553            .join(if cfg!(windows) { "noop.ps1" } else { "noop.sh" });
5554        std::fs::write(&noop_script, "exit 0\n").unwrap();
5555        let command = if cfg!(windows) {
5556            vec![
5557                "powershell.exe",
5558                "-NoProfile",
5559                "-NonInteractive",
5560                "-File",
5561                noop_script.to_str().unwrap(),
5562            ]
5563        } else {
5564            vec!["sh", noop_script.to_str().unwrap()]
5565        };
5566        std::fs::create_dir_all(&agent).unwrap();
5567        std::fs::write(
5568            agent.join("settings.json"),
5569            serde_json::to_vec(&serde_json::json!({
5570                "npmCommand": command,
5571                "packages": ["npm:demo@2.0.0"]
5572            }))
5573            .unwrap(),
5574        )
5575        .unwrap();
5576        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5577
5578        let resources = resolve_from_global_settings(&cwd);
5579
5580        match previous {
5581            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5582            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5583        }
5584        assert!(resources.packages.is_empty());
5585        assert_eq!(resources.diagnostics.len(), 1);
5586        assert!(resources.diagnostics[0]
5587            .message
5588            .contains("still does not satisfy"));
5589        assert_eq!(
5590            serde_json::from_str::<serde_json::Value>(
5591                &std::fs::read_to_string(root.join("package.json")).unwrap()
5592            )
5593            .unwrap()["version"],
5594            "1.0.0"
5595        );
5596    }
5597
5598    #[test]
5599    fn global_npm_spec_cannot_be_shadowed_by_project_store_or_node_modules() {
5600        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5601        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5602        let tmp = tempfile::tempdir().unwrap();
5603        let agent = tmp.path().join("user-agent");
5604        let cwd = tmp.path().join("workspace/project");
5605        let user = agent.join("npm/node_modules/demo");
5606        for root in [&user, &cwd.join(".rpi/packages/demo")] {
5607            std::fs::create_dir_all(root).unwrap();
5608            std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5609        }
5610        let workspace_package = tmp.path().join("workspace/node_modules/demo");
5611        std::fs::create_dir_all(&workspace_package).unwrap();
5612        std::fs::write(workspace_package.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5613        std::fs::create_dir_all(&agent).unwrap();
5614        std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5615        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5616
5617        let resources = discover_from_global_settings(&cwd);
5618        match previous {
5619            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5620            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5621        }
5622        assert_eq!(resources.packages.len(), 1);
5623        assert_eq!(resources.packages[0].root, user);
5624    }
5625
5626    #[test]
5627    fn configured_project_and_user_packages_resolve_in_separate_scopes() {
5628        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5629        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5630        let tmp = tempfile::tempdir().unwrap();
5631        let agent = tmp.path().join("user-agent");
5632        let cwd = tmp.path().join("project");
5633        let project_root = cwd.join(".rpi/packages/demo");
5634        let user_root = agent.join("packages/demo");
5635        for root in [&project_root, &user_root] {
5636            std::fs::create_dir_all(root).unwrap();
5637            std::fs::write(
5638                root.join("package.json"),
5639                r#"{"name":"demo","version":"1.0.0"}"#,
5640            )
5641            .unwrap();
5642            write_npm_source_marker(root, "npm:demo").unwrap();
5643        }
5644        std::fs::create_dir_all(&agent).unwrap();
5645        std::fs::write(agent.join("settings.json"), r#"{"packages":["npm:demo"]}"#).unwrap();
5646        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5647
5648        let user_only = discover_from_settings(&cwd);
5649        assert_eq!(user_only.packages.len(), 1);
5650        assert_eq!(user_only.packages[0].root, user_root);
5651
5652        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5653        std::fs::write(
5654            cwd.join(".rpi/settings.json"),
5655            r#"{"packages":["npm:demo"]}"#,
5656        )
5657        .unwrap();
5658        let combined = discover_from_settings(&cwd);
5659        assert_eq!(combined.packages.len(), 1);
5660        assert_eq!(combined.packages[0].root, project_root);
5661
5662        match previous {
5663            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5664            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5665        }
5666    }
5667
5668    #[test]
5669    fn update_discovery_keeps_same_identity_in_both_scopes() {
5670        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5671        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5672        let tmp = tempfile::tempdir().unwrap();
5673        let agent = tmp.path().join("agent");
5674        let cwd = tmp.path().join("project");
5675        let project_root = cwd.join(".pi/npm/node_modules/demo");
5676        let user_root = agent.join("npm/node_modules/demo");
5677        for root in [&project_root, &user_root] {
5678            std::fs::create_dir_all(root).unwrap();
5679            std::fs::write(
5680                root.join("package.json"),
5681                r#"{"name":"demo","version":"1.0.0"}"#,
5682            )
5683            .unwrap();
5684        }
5685        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5686        std::fs::write(
5687            cwd.join(".rpi/settings.json"),
5688            r#"{"packages":["npm:demo@latest"]}"#,
5689        )
5690        .unwrap();
5691        std::fs::create_dir_all(&agent).unwrap();
5692        std::fs::write(
5693            agent.join("settings.json"),
5694            r#"{"packages":["npm:demo@latest"]}"#,
5695        )
5696        .unwrap();
5697        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5698
5699        let resources = discover_from_settings_for_update(&cwd, true).unwrap().0;
5700
5701        match previous {
5702            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5703            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5704        }
5705        assert_eq!(resources.packages.len(), 2);
5706        assert!(resources
5707            .packages
5708            .iter()
5709            .any(|package| package.root == project_root));
5710        assert!(resources
5711            .packages
5712            .iter()
5713            .any(|package| package.root == user_root));
5714    }
5715
5716    #[test]
5717    fn update_discovery_ignores_untrusted_project_settings_but_keeps_user_packages() {
5718        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5719        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5720        let tmp = tempfile::tempdir().unwrap();
5721        let agent = tmp.path().join("agent");
5722        let cwd = tmp.path().join("project");
5723        let user_root = agent.join("packages/user-demo");
5724        let project_root = cwd.join(".rpi/packages/project-demo");
5725        for (root, name) in [(&user_root, "user-demo"), (&project_root, "project-demo")] {
5726            std::fs::create_dir_all(root).unwrap();
5727            std::fs::write(
5728                root.join("package.json"),
5729                serde_json::to_vec(&serde_json::json!({"name": name, "version": "1.0.0"})).unwrap(),
5730            )
5731            .unwrap();
5732            write_npm_source_marker(root, &format!("npm:{name}")).unwrap();
5733        }
5734        std::fs::write(
5735            agent.join("settings.json"),
5736            r#"{"packages":["npm:user-demo"]}"#,
5737        )
5738        .unwrap();
5739        std::fs::write(
5740            cwd.join(".rpi/settings.json"),
5741            r#"{"packages":["npm:project-demo"]}"#,
5742        )
5743        .unwrap();
5744        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5745
5746        let untrusted = discover_from_settings_for_update(&cwd, false).unwrap().0;
5747        let trusted = discover_from_settings_for_update(&cwd, true).unwrap().0;
5748
5749        match previous {
5750            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5751            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5752        }
5753        assert_eq!(untrusted.packages.len(), 1);
5754        assert_eq!(untrusted.packages[0].name, "user-demo");
5755        assert_eq!(trusted.packages.len(), 2);
5756        assert_eq!(trusted.packages[0].name, "project-demo");
5757        assert_eq!(trusted.packages[1].name, "user-demo");
5758    }
5759
5760    #[test]
5761    fn update_discovery_recovers_missing_configured_target_and_cleans_stale_backup() {
5762        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5763        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5764        let tmp = tempfile::tempdir().unwrap();
5765        let agent = tmp.path().join("user-agent");
5766        let cwd = tmp.path().join("project");
5767        let target = cwd.join(".rpi/packages/demo");
5768        let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000001");
5769        std::fs::create_dir_all(&agent).unwrap();
5770        std::fs::create_dir_all(&backup).unwrap();
5771        std::fs::write(
5772            backup.join("package.json"),
5773            r#"{"name":"demo","version":"1.0.0"}"#,
5774        )
5775        .unwrap();
5776        write_npm_source_marker(&backup, "npm:demo@beta").unwrap();
5777        let spec = format!("file:{}", target.display());
5778        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5779        std::fs::write(
5780            cwd.join(".rpi/settings.json"),
5781            serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5782        )
5783        .unwrap();
5784        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5785
5786        let ordinary = discover_from_settings(&cwd);
5787        assert!(ordinary.packages.is_empty());
5788        assert!(backup.is_dir());
5789        assert!(!target.exists());
5790
5791        let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5792        assert_eq!(recovered.packages.len(), 1);
5793        assert_eq!(recovered.packages[0].root, target);
5794        assert!(!backup.exists());
5795
5796        let stale = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000002");
5797        std::fs::create_dir_all(&stale).unwrap();
5798        let visible = discover_from_settings_for_update(&cwd, true).unwrap().0;
5799        assert_eq!(visible.packages.len(), 1);
5800        assert!(!stale.exists());
5801
5802        let next_backup =
5803            cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000003");
5804        std::fs::rename(&target, &next_backup).unwrap();
5805        let recovered_again = discover_from_settings_for_update(&cwd, true).unwrap().0;
5806        assert_eq!(recovered_again.packages.len(), 1);
5807        assert!(target.is_dir());
5808        assert!(!next_backup.exists());
5809
5810        match previous {
5811            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5812            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5813        }
5814    }
5815
5816    #[test]
5817    fn update_discovery_recovers_native_project_and_user_npm_targets() {
5818        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5819        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5820        let tmp = tempfile::tempdir().unwrap();
5821        let agent = tmp.path().join("user-agent");
5822        let cwd = tmp.path().join("project");
5823        let project_target = cwd.join(".pi/npm/node_modules/demo");
5824        let project_backup =
5825            cwd.join(".pi/npm/node_modules/.demo.rpi-backup-00000000000000000000000000000011");
5826        let user_target = agent.join("npm/node_modules/@scope/demo");
5827        let user_backup =
5828            agent.join("npm/node_modules/@scope/.demo.rpi-backup-00000000000000000000000000000012");
5829        for (backup, name) in [(&project_backup, "demo"), (&user_backup, "@scope/demo")] {
5830            std::fs::create_dir_all(backup).unwrap();
5831            std::fs::write(
5832                backup.join("package.json"),
5833                serde_json::to_vec(&serde_json::json!({
5834                    "name": name,
5835                    "version": "1.0.0"
5836                }))
5837                .unwrap(),
5838            )
5839            .unwrap();
5840        }
5841        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5842        std::fs::write(
5843            cwd.join(".rpi/settings.json"),
5844            r#"{"packages":["npm:demo"]}"#,
5845        )
5846        .unwrap();
5847        std::fs::create_dir_all(&agent).unwrap();
5848        std::fs::write(
5849            agent.join("settings.json"),
5850            r#"{"packages":["npm:@scope/demo"]}"#,
5851        )
5852        .unwrap();
5853        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5854
5855        let ordinary = discover_from_settings(&cwd);
5856        assert!(ordinary.packages.is_empty());
5857        assert!(project_backup.is_dir());
5858        assert!(user_backup.is_dir());
5859
5860        let recovered = discover_from_settings_for_update(&cwd, true).unwrap().0;
5861        assert_eq!(recovered.packages.len(), 2);
5862        assert!(recovered
5863            .packages
5864            .iter()
5865            .any(|package| package.root == project_target));
5866        assert!(recovered
5867            .packages
5868            .iter()
5869            .any(|package| package.root == user_target));
5870        assert!(!project_backup.exists());
5871        assert!(!user_backup.exists());
5872
5873        match previous {
5874            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5875            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5876        }
5877    }
5878
5879    #[test]
5880    fn update_returns_failure_when_recovery_is_ambiguous() {
5881        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5882        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5883        let tmp = tempfile::tempdir().unwrap();
5884        let agent = tmp.path().join("user-agent");
5885        let cwd = tmp.path().join("project");
5886        let target = cwd.join(".rpi/packages/demo");
5887        for suffix in [1_u8, 2] {
5888            let backup = cwd.join(format!(".rpi/packages/.demo.rpi-backup-{suffix:032x}"));
5889            std::fs::create_dir_all(&backup).unwrap();
5890            std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5891        }
5892        let spec = format!("file:{}", target.display());
5893        std::fs::create_dir_all(cwd.join(".rpi")).unwrap();
5894        std::fs::write(
5895            cwd.join(".rpi/settings.json"),
5896            serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5897        )
5898        .unwrap();
5899        std::fs::create_dir_all(&agent).unwrap();
5900        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5901
5902        assert_eq!(update_packages_with_scope(&cwd, true, UpdateScope::Pi), 1);
5903        assert!(!target.exists());
5904
5905        match previous {
5906            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5907            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5908        }
5909    }
5910
5911    #[test]
5912    fn update_with_malformed_project_settings_performs_no_recovery() {
5913        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5914        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5915        let tmp = tempfile::tempdir().unwrap();
5916        let agent = tmp.path().join("agent");
5917        let cwd = tmp.path().join("project");
5918        let target = cwd.join(".rpi/packages/demo");
5919        let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000031");
5920        std::fs::create_dir_all(&agent).unwrap();
5921        std::fs::create_dir_all(&backup).unwrap();
5922        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5923        std::fs::write(cwd.join(".rpi/settings.json"), "{ malformed").unwrap();
5924        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5925
5926        assert_eq!(update_packages_with_scope(&cwd, true, UpdateScope::Pi), 1);
5927
5928        match previous {
5929            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5930            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5931        }
5932        assert!(backup.is_dir());
5933        assert!(!target.exists());
5934    }
5935
5936    #[test]
5937    fn update_with_corrupt_native_registry_performs_no_ts_recovery() {
5938        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5939        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5940        let tmp = tempfile::tempdir().unwrap();
5941        let agent = tmp.path().join("agent");
5942        let cwd = tmp.path().join("project");
5943        let target = cwd.join(".rpi/packages/demo");
5944        let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000034");
5945        let metadata = agent.join("native-packages.json");
5946        let original = b"[{broken native metadata";
5947        std::fs::create_dir_all(&agent).unwrap();
5948        std::fs::write(&metadata, original).unwrap();
5949        std::fs::create_dir_all(&backup).unwrap();
5950        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5951        let spec = format!("file:{}", target.display());
5952        std::fs::write(
5953            cwd.join(".rpi/settings.json"),
5954            serde_json::to_vec(&serde_json::json!({ "packages": [spec] })).unwrap(),
5955        )
5956        .unwrap();
5957        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5958
5959        assert_eq!(
5960            update_packages_with_scope(&cwd, true, UpdateScope::Native),
5961            1
5962        );
5963
5964        match previous {
5965            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
5966            None => std::env::remove_var(config::CONFIG_DIR_ENV),
5967        }
5968        assert_eq!(std::fs::read(metadata).unwrap(), original);
5969        assert!(backup.is_dir());
5970        assert!(!target.exists());
5971    }
5972
5973    #[test]
5974    fn update_with_malformed_global_settings_performs_no_project_recovery() {
5975        let _guard = crate::config::test_support::env_lock().lock().unwrap();
5976        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
5977        let tmp = tempfile::tempdir().unwrap();
5978        let agent = tmp.path().join("agent");
5979        let cwd = tmp.path().join("project");
5980        let target = cwd.join(".rpi/packages/demo");
5981        let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000032");
5982        std::fs::create_dir_all(&agent).unwrap();
5983        std::fs::write(agent.join("settings.json"), "{ malformed").unwrap();
5984        std::fs::create_dir_all(&backup).unwrap();
5985        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
5986        std::fs::write(
5987            cwd.join(".rpi/settings.json"),
5988            serde_json::to_vec(&serde_json::json!({
5989                "packages": [format!("file:{}", target.display())]
5990            }))
5991            .unwrap(),
5992        )
5993        .unwrap();
5994        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
5995
5996        assert_eq!(update_packages_with_scope(&cwd, true, UpdateScope::Pi), 1);
5997
5998        match previous {
5999            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
6000            None => std::env::remove_var(config::CONFIG_DIR_ENV),
6001        }
6002        assert!(backup.is_dir());
6003        assert!(!target.exists());
6004    }
6005
6006    #[test]
6007    fn update_with_invalid_npm_command_performs_no_recovery() {
6008        let _guard = crate::config::test_support::env_lock().lock().unwrap();
6009        let previous = std::env::var_os(config::CONFIG_DIR_ENV);
6010        let tmp = tempfile::tempdir().unwrap();
6011        let agent = tmp.path().join("agent");
6012        let cwd = tmp.path().join("project");
6013        let target = cwd.join(".rpi/packages/demo");
6014        let backup = cwd.join(".rpi/packages/.demo.rpi-backup-00000000000000000000000000000033");
6015        std::fs::create_dir_all(&agent).unwrap();
6016        std::fs::create_dir_all(&backup).unwrap();
6017        std::fs::write(backup.join("package.json"), r#"{"name":"demo"}"#).unwrap();
6018        std::fs::write(
6019            cwd.join(".rpi/settings.json"),
6020            serde_json::to_vec(&serde_json::json!({
6021                "npmCommand": [""],
6022                "packages": [format!("file:{}", target.display())]
6023            }))
6024            .unwrap(),
6025        )
6026        .unwrap();
6027        std::env::set_var(config::CONFIG_DIR_ENV, &agent);
6028
6029        assert_eq!(update_packages_with_scope(&cwd, true, UpdateScope::Pi), 1);
6030
6031        match previous {
6032            Some(value) => std::env::set_var(config::CONFIG_DIR_ENV, value),
6033            None => std::env::remove_var(config::CONFIG_DIR_ENV),
6034        }
6035        assert!(backup.is_dir());
6036        assert!(!target.exists());
6037    }
6038}