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::HashSet;
10use std::path::{Path, PathBuf};
11
12use serde_json::Value;
13
14use crate::config;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct PackageRoot {
18    pub root: PathBuf,
19    pub name: String,
20    pub version: Option<String>,
21    pub manifest: Option<PathBuf>,
22    skills: Vec<PathBuf>,
23    prompts: Vec<PathBuf>,
24    themes: Vec<PathBuf>,
25    system_prompts: Vec<PathBuf>,
26    append_system_prompts: Vec<PathBuf>,
27    /// JavaScript/TypeScript extension entry paths declared by `pi.extensions`
28    /// or `rpi.extensions`. A directory is expanded by the JS host.
29    pub extensions: Vec<PathBuf>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct PackageDiagnostic {
34    pub spec: String,
35    pub message: String,
36}
37
38#[derive(Debug, Clone, Default)]
39pub struct PackageResources {
40    pub packages: Vec<PackageRoot>,
41    pub diagnostics: Vec<PackageDiagnostic>,
42}
43
44impl PackageResources {
45    pub fn extension_paths(&self) -> Vec<PathBuf> {
46        self.packages
47            .iter()
48            .flat_map(|p| p.extensions.iter().cloned())
49            .collect()
50    }
51    pub fn skill_dirs(&self) -> Vec<PathBuf> {
52        self.packages
53            .iter()
54            .flat_map(|p| p.skills.iter().cloned())
55            .collect()
56    }
57
58    pub fn prompt_dirs(&self) -> Vec<PathBuf> {
59        self.packages
60            .iter()
61            .flat_map(|p| p.prompts.iter().cloned())
62            .collect()
63    }
64
65    pub fn theme_files(&self) -> Vec<PathBuf> {
66        self.packages
67            .iter()
68            .flat_map(|p| {
69                p.themes.iter().flat_map(|path| {
70                    if path.is_dir() {
71                        let mut files: Vec<PathBuf> = std::fs::read_dir(path)
72                            .ok()
73                            .into_iter()
74                            .flatten()
75                            .filter_map(Result::ok)
76                            .map(|entry| entry.path())
77                            .filter(|file| {
78                                file.is_file()
79                                    && file.extension().and_then(|ext| ext.to_str()) == Some("json")
80                            })
81                            .collect();
82                        files.sort();
83                        files
84                    } else {
85                        vec![path.clone()]
86                    }
87                })
88            })
89            .collect()
90    }
91
92    pub fn system_prompt_files(&self) -> Vec<PathBuf> {
93        self.packages
94            .iter()
95            .flat_map(|p| p.system_prompts.iter().cloned())
96            .collect()
97    }
98
99    pub fn append_system_prompt_files(&self) -> Vec<PathBuf> {
100        self.packages
101            .iter()
102            .flat_map(|p| p.append_system_prompts.iter().cloned())
103            .collect()
104    }
105
106    pub fn find_theme(&self, name: &str) -> Option<PathBuf> {
107        let wanted = Path::new(name);
108        self.theme_files().into_iter().find(|path| {
109            path == wanted
110                || path.file_stem().and_then(|s| s.to_str()) == Some(name)
111                || path.file_name().and_then(|s| s.to_str()) == Some(name)
112        })
113    }
114}
115
116/// Resolve package specs from the settings file and conventional local roots.
117/// Empty or missing `packages` means no packages are enabled, matching Pi's
118/// explicit package list instead of silently executing every directory found
119/// under the user's home directory.
120pub fn discover_from_settings(cwd: &Path) -> PackageResources {
121    let mut specs = Vec::new();
122    for settings in crate::settings::load_project_settings(cwd) {
123        if let Some(packages) = settings.packages {
124            specs.extend(packages);
125        }
126    }
127    if let Ok(settings) = crate::settings::load_settings() {
128        if let Some(packages) = settings.packages {
129            specs.extend(packages);
130        }
131    }
132    discover(cwd, &specs)
133}
134
135/// Resolve only packages declared in the global settings file. Project-local
136/// package declarations are intentionally excluded when the current project
137/// has not been trusted.
138pub fn discover_from_global_settings(cwd: &Path) -> PackageResources {
139    let specs = crate::settings::load_settings()
140        .ok()
141        .and_then(|settings| settings.packages)
142        .unwrap_or_default();
143    discover(cwd, &specs)
144}
145
146/// Discover packages in settings order. Package resources are intentionally
147/// returned after project and global resources; callers append these paths last
148/// so a package cannot shadow a project-local or user-local resource.
149pub fn discover(cwd: &Path, specs: &[String]) -> PackageResources {
150    let mut out = PackageResources::default();
151    let mut seen = HashSet::new();
152    for spec in specs
153        .iter()
154        .map(String::as_str)
155        .filter(|s| !s.trim().is_empty())
156    {
157        let Some(root) = resolve_spec(cwd, spec) else {
158            out.diagnostics.push(PackageDiagnostic {
159                spec: spec.to_string(),
160                message: "package path/name could not be resolved".to_string(),
161            });
162            continue;
163        };
164        let key = normalize_key(&root);
165        if !seen.insert(key) {
166            continue;
167        }
168        match load_package(root, spec) {
169            Ok(package) => out.packages.push(package),
170            Err(message) => out.diagnostics.push(PackageDiagnostic {
171                spec: spec.to_string(),
172                message,
173            }),
174        }
175    }
176    out
177}
178
179/// Validate and load one package spec. Used by `rpi package add` before the
180/// spec is persisted to settings.
181pub fn resolve_package(cwd: &Path, spec: &str) -> Result<PackageRoot, String> {
182    let root = resolve_spec(cwd, spec)
183        .ok_or_else(|| "package path/name could not be resolved".to_string())?;
184    load_package(root, spec)
185}
186
187/// Load a theme from an explicit JSON path without discovering configured Pi
188/// packages. Startup code that has passed the package gate uses
189/// [`load_theme_with_resources`] to resolve package theme names.
190pub fn load_theme(cwd: &Path, name_or_path: &str) -> Result<rpi_tui::Theme, String> {
191    load_theme_with_resources(cwd, name_or_path, &PackageResources::default())
192}
193
194/// Load a package theme from an already-resolved resource set. Startup callers
195/// use this variant so a disabled package configuration cannot be re-discovered
196/// indirectly from a TUI theme selector.
197pub fn load_theme_with_resources(
198    _cwd: &Path,
199    name_or_path: &str,
200    resources: &PackageResources,
201) -> Result<rpi_tui::Theme, String> {
202    let path = {
203        let direct = PathBuf::from(name_or_path);
204        if direct.is_file() {
205            Some(direct)
206        } else {
207            resources.find_theme(name_or_path)
208        }
209    }
210    .ok_or_else(|| format!("theme `{name_or_path}` was not found in enabled packages"))?;
211    let text = std::fs::read_to_string(&path)
212        .map_err(|error| format!("could not read theme {}: {error}", path.display()))?;
213    let value = parse_json_with_comments(&text)
214        .map_err(|error| format!("invalid theme {}: {error}", path.display()))?;
215    let mut theme = rpi_tui::Theme::default();
216    let colors = value.get("colors").unwrap_or(&value);
217    let target = &mut theme.colors;
218    macro_rules! color {
219        ($field:ident, $($key:literal),+ $(,)?) => {
220            if let Some(value) = first_value(colors, &[$($key),+]) {
221                if let Some(parsed) = parse_color(value) {
222                    target.$field = parsed;
223                }
224            }
225        };
226    }
227    color!(text, "text");
228    color!(muted, "muted");
229    color!(dim, "dim");
230    color!(accent, "accent");
231    color!(error, "error");
232    color!(success, "success");
233    color!(warning, "warning");
234    color!(info, "info");
235    color!(background, "background", "bg");
236    color!(surface, "surface", "userMessageBg");
237    color!(border, "border");
238    color!(border_accent, "borderAccent");
239    color!(border_muted, "borderMuted");
240    color!(selection, "selection", "selectedBg");
241    color!(cursor, "cursor");
242    color!(thinking_text, "thinkingText");
243    color!(md_heading, "mdHeading");
244    color!(md_link, "mdLink");
245    color!(md_link_url, "mdLinkUrl");
246    color!(md_code, "mdCode");
247    color!(md_code_bg, "mdCodeBg");
248    color!(md_code_block, "mdCodeBlock");
249    color!(md_code_block_bg, "mdCodeBlockBg");
250    color!(md_code_block_border, "mdCodeBlockBorder");
251    color!(md_quote, "mdQuote");
252    color!(md_quote_border, "mdQuoteBorder");
253    color!(md_hr, "mdHr");
254    color!(md_list_bullet, "mdListBullet");
255    color!(tool_pending_bg, "toolPendingBg");
256    color!(tool_success_bg, "toolSuccessBg");
257    color!(tool_error_bg, "toolErrorBg");
258    color!(tool_title, "toolTitle");
259    color!(tool_output, "toolOutput");
260    color!(bash_mode, "bashMode");
261    color!(tool_diff_added, "toolDiffAdded");
262    color!(tool_diff_removed, "toolDiffRemoved");
263    color!(tool_diff_context, "toolDiffContext");
264    if let Some(border) = value.get("borderStyle").and_then(Value::as_str) {
265        theme.border_style = match border.to_ascii_lowercase().as_str() {
266            "sharp" => rpi_tui::theme::BorderStyle::Sharp,
267            "double" => rpi_tui::theme::BorderStyle::Double,
268            "thick" => rpi_tui::theme::BorderStyle::Thick,
269            "none" => rpi_tui::theme::BorderStyle::None,
270            _ => rpi_tui::theme::BorderStyle::Rounded,
271        };
272    }
273    if let Some(corner) = value.get("cornerStyle").and_then(Value::as_str) {
274        theme.corner_style = if corner.eq_ignore_ascii_case("sharp") {
275            rpi_tui::theme::CornerStyle::Sharp
276        } else {
277            rpi_tui::theme::CornerStyle::Rounded
278        };
279    }
280    Ok(theme)
281}
282
283fn first_value<'a>(value: &'a Value, keys: &[&str]) -> Option<&'a Value> {
284    keys.iter().find_map(|key| value.get(*key))
285}
286
287fn parse_color(value: &Value) -> Option<rpi_tui::Color> {
288    match value {
289        Value::String(raw) => {
290            let value = raw.trim();
291            let hex = value.strip_prefix('#')?;
292            if hex.len() == 6 {
293                let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
294                let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
295                let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
296                Some(rpi_tui::Color::Rgb(r, g, b))
297            } else if let Some(index) = value.strip_prefix("ansi256:") {
298                Some(rpi_tui::Color::Ansi256(index.parse().ok()?))
299            } else {
300                None
301            }
302        }
303        Value::Array(values) if values.len() == 3 => Some(rpi_tui::Color::Rgb(
304            values[0].as_u64()?.try_into().ok()?,
305            values[1].as_u64()?.try_into().ok()?,
306            values[2].as_u64()?.try_into().ok()?,
307        )),
308        Value::Object(map) => {
309            let r = map.get("r")?.as_u64()?.try_into().ok()?;
310            let g = map.get("g")?.as_u64()?.try_into().ok()?;
311            let b = map.get("b")?.as_u64()?.try_into().ok()?;
312            Some(rpi_tui::Color::Rgb(r, g, b))
313        }
314        _ => None,
315    }
316}
317
318/// `rpi package ...` command for managing the enabled Pi package list. This is
319/// a local package manager; use `install-pi` when the package must be fetched.
320pub fn run_cli(args: &[String]) -> i32 {
321    let command = args.first().map(String::as_str).unwrap_or("list");
322    let cwd = match std::env::current_dir() {
323        Ok(path) => path,
324        Err(error) => {
325            eprintln!("error: could not determine current directory: {error}");
326            return 1;
327        }
328    };
329    match command {
330        "list" => {
331            let resources = discover_from_settings(&cwd);
332            let native = crate::install::installed_native_packages();
333            if args.iter().any(|arg| arg == "--json") {
334                let mut values: Vec<_> = resources
335                    .packages
336                    .iter()
337                    .map(|p| {
338                        serde_json::json!({
339                            "name": p.name,
340                            "version": p.version,
341                            "type": "ts",
342                            "root": p.root,
343                            "manifest": p.manifest,
344                            "skills": p.skill_dirs_for_display(),
345                            "prompts": p.prompt_dirs_for_display(),
346                            "themes": p.theme_files_for_display(),
347                        })
348                    })
349                    .collect();
350                values.extend(native.iter().map(|package| {
351                    serde_json::json!({
352                        "name": package.name,
353                        "version": package.version,
354                        "type": "rust",
355                        "source": package.source,
356                    })
357                }));
358                println!(
359                    "{}",
360                    serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".into())
361                );
362            } else if resources.packages.is_empty() && native.is_empty() {
363                println!("no Pi packages enabled");
364            } else {
365                for package in &resources.packages {
366                    let version = package.version.as_deref().unwrap_or("-");
367                    println!("{}@{} {}", package.name, version, package.root.display());
368                }
369                for package in native {
370                    let source = package.source.as_deref().unwrap_or("crates.io");
371                    println!("{}@{} [rust] {}", package.name, package.version, source);
372                }
373            }
374            for diagnostic in resources.diagnostics {
375                eprintln!(
376                    "warning: package {}: {}",
377                    diagnostic.spec, diagnostic.message
378                );
379            }
380            0
381        }
382        "add" => {
383            let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
384                eprintln!("error: missing package path or name");
385                print_help();
386                return 2;
387            };
388            if let Err(error) = resolve_package(&cwd, spec) {
389                eprintln!("error: {error}");
390                return 1;
391            }
392            let mut settings = crate::settings::load_settings().unwrap_or_default();
393            let packages = settings.packages.get_or_insert_with(Vec::new);
394            if !packages.iter().any(|existing| existing == spec) {
395                packages.push(spec.clone());
396                if let Err(error) = crate::settings::save_settings(&settings) {
397                    eprintln!("error: could not save package settings: {error}");
398                    return 1;
399                }
400                println!("enabled Pi package {spec}");
401            } else {
402                println!("Pi package already enabled: {spec}");
403            }
404            0
405        }
406        "remove" | "rm" => {
407            let Some(spec) = args.get(1).filter(|s| !s.starts_with('-')) else {
408                eprintln!("error: missing package path or name");
409                print_help();
410                return 2;
411            };
412            let mut settings = crate::settings::load_settings().unwrap_or_default();
413            let Some(packages) = settings.packages.as_mut() else {
414                println!("Pi package is not enabled: {spec}");
415                return 0;
416            };
417            let before = packages.len();
418            packages.retain(|existing| existing != spec);
419            if packages.len() == before {
420                println!("Pi package is not enabled: {spec}");
421                return 0;
422            }
423            if packages.is_empty() {
424                settings.packages = None;
425            }
426            if let Err(error) = crate::settings::save_settings(&settings) {
427                eprintln!("error: could not save package settings: {error}");
428                return 1;
429            }
430            println!("disabled Pi package {spec}");
431            0
432        }
433        "update" => update_packages(&cwd),
434        "help" | "--help" | "-h" => {
435            print_help();
436            0
437        }
438        other => {
439            eprintln!("error: unknown package command `{other}`");
440            print_help();
441            2
442        }
443    }
444}
445
446fn print_help() {
447    println!(
448        "Usage: rpi package <command>\n\nCommands:\n  list [--json]      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             Update TS npm/git packages and Rust crates.io extensions\n\nTS package resources are loaded from skills/, prompts/, themes/, SYSTEM.md, APPEND_SYSTEM.md, and extensions. Rust-native extensions are installed with `rpi install`."
449    );
450}
451
452fn update_packages(cwd: &Path) -> i32 {
453    let native = crate::install::installed_native_packages();
454    let resources = discover_from_settings(cwd);
455    if resources.packages.is_empty() && native.is_empty() {
456        println!("no Pi packages enabled");
457        return 0;
458    }
459    let mut updated = 0;
460    let mut skipped = 0;
461    for package in native {
462        if package.source.is_some() {
463            println!(
464                "skipped local Rust package {} (no registry source)",
465                package.name
466            );
467            skipped += 1;
468            continue;
469        }
470        let args = vec![package.name.clone(), "--force".to_string()];
471        if crate::install::run(&args) == 0 {
472            updated += 1;
473        } else {
474            eprintln!("warning: could not update Rust package {}", package.name);
475        }
476    }
477    for package in resources.packages {
478        let root = package.root;
479        if root.join(".git").is_dir() {
480            match std::process::Command::new("git")
481                .args(["-C"])
482                .arg(&root)
483                .args(["pull", "--ff-only"])
484                .status()
485            {
486                Ok(status) if status.success() => {
487                    println!("updated git package {}", package.name);
488                    updated += 1;
489                }
490                Ok(status) => eprintln!(
491                    "warning: could not update {} (git exited with {status})",
492                    package.name
493                ),
494                Err(error) => eprintln!("warning: could not update {}: {error}", package.name),
495            }
496            continue;
497        }
498        if is_registry_package_path(&root) {
499            match crate::install_pi::update_npm_package(&root, &package.name) {
500                Ok(_) => {
501                    println!("updated npm package {}", package.name);
502                    updated += 1;
503                }
504                Err(error) => eprintln!("warning: could not update {}: {error}", package.name),
505            }
506        } else {
507            println!(
508                "skipped local package {} (no registry source)",
509                package.name
510            );
511            skipped += 1;
512        }
513    }
514    println!("package update complete: {updated} updated, {skipped} skipped");
515    0
516}
517
518fn is_registry_package_path(path: &Path) -> bool {
519    let text = path
520        .to_string_lossy()
521        .replace('\\', "/")
522        .to_ascii_lowercase();
523    text.contains("/.rpi/packages/")
524        || text.contains("/.pi/packages/")
525        || text.contains("/agent/packages/")
526}
527
528impl PackageRoot {
529    fn skill_dirs_for_display(&self) -> Vec<PathBuf> {
530        self.skills.clone()
531    }
532
533    fn prompt_dirs_for_display(&self) -> Vec<PathBuf> {
534        self.prompts.clone()
535    }
536
537    fn theme_files_for_display(&self) -> Vec<PathBuf> {
538        if self.themes.len() == 1 && self.themes[0].is_dir() {
539            let mut files: Vec<PathBuf> = std::fs::read_dir(&self.themes[0])
540                .ok()
541                .into_iter()
542                .flatten()
543                .filter_map(Result::ok)
544                .map(|entry| entry.path())
545                .filter(|file| {
546                    file.is_file() && file.extension().and_then(|ext| ext.to_str()) == Some("json")
547                })
548                .collect();
549            files.sort();
550            files
551        } else {
552            self.themes.clone()
553        }
554    }
555}
556
557fn resolve_spec(cwd: &Path, spec: &str) -> Option<PathBuf> {
558    let file_spec = spec.strip_prefix("file:");
559    let raw = file_spec.unwrap_or(spec);
560    // `npm:` is a package-source prefix, not part of the on-disk package
561    // name. Keeping it in the candidates makes installed npm packages look
562    // like directories literally named `npm:...`.
563    let npm_name = raw.strip_prefix("npm:").unwrap_or(raw);
564    let package_name = package_name_without_version(npm_name);
565    let package_key = package_name
566        .strip_prefix('@')
567        .unwrap_or(package_name)
568        .replace('/', "__");
569    let direct = PathBuf::from(npm_name);
570    let mut candidates = Vec::new();
571    if direct.is_absolute() {
572        candidates.push(direct);
573    } else {
574        let explicit_relative_path =
575            file_spec.is_some() || npm_name.starts_with('.') || npm_name.starts_with("./");
576        if explicit_relative_path {
577            candidates.push(cwd.join(&direct));
578        }
579        // Prefer rpi-owned package stores over native Pi stores and generic
580        // node_modules when a bare package name resolves in more than one
581        // place.
582        candidates.push(cwd.join(".rpi/packages").join(package_name));
583        if package_key != package_name {
584            candidates.push(cwd.join(".rpi/packages").join(&package_key));
585        }
586        candidates.push(cwd.join(".pi/packages").join(package_name));
587        if package_key != package_name {
588            candidates.push(cwd.join(".pi/packages").join(&package_key));
589        }
590        for ancestor in cwd.ancestors() {
591            candidates.push(ancestor.join("node_modules").join(package_name));
592        }
593        if let Ok(agent) = config::agent_dir() {
594            candidates.push(agent.join("packages").join(package_name));
595            if package_key != package_name {
596                candidates.push(agent.join("packages").join(&package_key));
597            }
598            // Pi's native npm installer keeps packages under
599            // ~/.pi/agent/npm/node_modules rather than ~/.pi/agent/packages.
600            // Keep the same layout usable when rpi reads Pi's settings.json.
601            candidates.push(agent.join("npm/node_modules").join(package_name));
602            if package_key != package_name {
603                candidates.push(agent.join("npm/node_modules").join(&package_key));
604            }
605        }
606        if let Some(home) = dirs::home_dir() {
607            // Keep native Pi's installed package store usable when the user
608            // has not copied it into the rpi-owned config directory yet.
609            candidates.push(home.join(".pi/agent/packages").join(package_name));
610            if package_key != package_name {
611                candidates.push(home.join(".pi/agent/packages").join(&package_key));
612            }
613            candidates.push(home.join(".pi/agent/npm/node_modules").join(package_name));
614            if package_key != package_name {
615                candidates.push(home.join(".pi/agent/npm/node_modules").join(&package_key));
616            }
617        }
618        if !explicit_relative_path {
619            candidates.push(cwd.join(package_name));
620        }
621    }
622    for candidate in candidates {
623        if candidate.is_file()
624            && candidate.file_name().and_then(|s| s.to_str()) == Some("package.json")
625        {
626            return candidate.parent().map(Path::to_path_buf);
627        }
628        if candidate.is_dir() {
629            return Some(candidate);
630        }
631    }
632    None
633}
634
635/// Strip an npm version suffix while preserving the `@scope/name` portion.
636fn package_name_without_version(name: &str) -> &str {
637    if let Some(rest) = name.strip_prefix('@') {
638        rest.find('@')
639            .map(|index| &name[..index + 1])
640            .unwrap_or(name)
641    } else {
642        name.split('@').next().unwrap_or(name)
643    }
644}
645
646fn load_package(root: PathBuf, spec: &str) -> Result<PackageRoot, String> {
647    let manifest_path = root.join("package.json");
648    let raw =
649        match std::fs::read_to_string(&manifest_path) {
650            Ok(text) => Some(parse_json_with_comments(&text).map_err(|e| {
651                format!("invalid package manifest {}: {e}", manifest_path.display())
652            })?),
653            Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
654            Err(e) => return Err(format!("could not read {}: {e}", manifest_path.display())),
655        };
656    let name = raw
657        .as_ref()
658        .and_then(|v| v.get("name"))
659        .and_then(Value::as_str)
660        .map(str::to_owned)
661        .or_else(|| root.file_name().and_then(|s| s.to_str()).map(str::to_owned))
662        .unwrap_or_else(|| spec.to_string());
663    let version = raw
664        .as_ref()
665        .and_then(|v| v.get("version"))
666        .and_then(Value::as_str)
667        .map(str::to_owned);
668    let manifest = raw.as_ref().map(|_| manifest_path);
669    // rpi-specific manifest settings win per resource key; a missing rpi key
670    // falls back to the original Pi key so partial migrations stay compatible.
671    let rpi = raw
672        .as_ref()
673        .and_then(|v| v.get("rpi"))
674        .unwrap_or(&Value::Null);
675    let pi = raw
676        .as_ref()
677        .and_then(|v| v.get("pi"))
678        .unwrap_or(&Value::Null);
679
680    Ok(PackageRoot {
681        skills: resource_paths(&root, raw.as_ref(), rpi, pi, "skills", "skills"),
682        prompts: resource_paths(&root, raw.as_ref(), rpi, pi, "prompts", "prompts"),
683        themes: resource_paths(&root, raw.as_ref(), rpi, pi, "themes", "themes"),
684        system_prompts: file_paths(
685            &root,
686            raw.as_ref(),
687            rpi,
688            pi,
689            &["systemPrompt", "system_prompt", "system"],
690            "SYSTEM.md",
691        ),
692        append_system_prompts: file_paths(
693            &root,
694            raw.as_ref(),
695            rpi,
696            pi,
697            &["appendSystemPrompt", "append_system_prompt", "appendSystem"],
698            "APPEND_SYSTEM.md",
699        ),
700        extensions: resource_paths(&root, raw.as_ref(), rpi, pi, "extensions", "extensions"),
701        root,
702        name,
703        version,
704        manifest,
705    })
706}
707
708fn parse_json_with_comments(text: &str) -> Result<Value, serde_json::Error> {
709    match serde_json::from_str(text) {
710        Ok(value) => Ok(value),
711        Err(first) => serde_json::from_str(&config::strip_line_comments(text)).map_err(|_| first),
712    }
713}
714
715fn resource_paths(
716    root: &Path,
717    top: Option<&Value>,
718    rpi: &Value,
719    pi: &Value,
720    key: &str,
721    default_dir: &str,
722) -> Vec<PathBuf> {
723    let values = rpi
724        .get(key)
725        .or_else(|| pi.get(key))
726        .or_else(|| top.and_then(|v| v.get(key)));
727    let mut paths = values
728        .map(|v| string_values(v).into_iter().map(|p| root.join(p)).collect())
729        .unwrap_or_else(|| vec![root.join(default_dir)]);
730    paths.retain(|p: &PathBuf| p.exists());
731    paths
732}
733
734fn file_paths(
735    root: &Path,
736    top: Option<&Value>,
737    rpi: &Value,
738    pi: &Value,
739    keys: &[&str],
740    default_file: &str,
741) -> Vec<PathBuf> {
742    let value = keys.iter().find_map(|key| {
743        rpi.get(*key)
744            .or_else(|| pi.get(*key))
745            .or_else(|| top.and_then(|v| v.get(*key)))
746    });
747    let mut paths = value
748        .map(|v| string_values(v).into_iter().map(|p| root.join(p)).collect())
749        .unwrap_or_else(|| vec![root.join(default_file)]);
750    paths.retain(|p: &PathBuf| p.is_file());
751    paths
752}
753
754fn string_values(value: &Value) -> Vec<String> {
755    match value {
756        Value::String(s) => vec![s.clone()],
757        Value::Array(values) => values
758            .iter()
759            .filter_map(Value::as_str)
760            .map(str::to_owned)
761            .collect(),
762        _ => Vec::new(),
763    }
764}
765
766fn normalize_key(path: &Path) -> String {
767    std::fs::canonicalize(path)
768        .unwrap_or_else(|_| path.to_path_buf())
769        .to_string_lossy()
770        .to_ascii_lowercase()
771}
772
773#[cfg(test)]
774mod tests {
775    use super::*;
776
777    #[test]
778    fn discovers_conventional_and_manifest_resources() {
779        let tmp = tempfile::tempdir().unwrap();
780        let root = tmp.path().join("pkg");
781        std::fs::create_dir_all(root.join("custom-skills")).unwrap();
782        std::fs::create_dir_all(root.join("rpi-skills")).unwrap();
783        std::fs::create_dir_all(root.join("prompts")).unwrap();
784        std::fs::create_dir_all(root.join("legacy-prompts")).unwrap();
785        std::fs::create_dir_all(root.join("themes")).unwrap();
786        std::fs::write(root.join("custom-skills/a.md"), "---\nname: a\n---\nbody").unwrap();
787        std::fs::write(root.join("prompts/explain.md"), "explain").unwrap();
788        std::fs::write(root.join("themes/ocean.json"), "{}").unwrap();
789        std::fs::write(
790            root.join("package.json"),
791            r#"{"name":"demo","version":"1.0.0","pi":{"skills":["custom-skills"],"prompts":["legacy-prompts"]},"rpi":{"skills":["rpi-skills"]}}"#,
792        )
793        .unwrap();
794
795        let resources = discover(tmp.path(), &[root.to_string_lossy().into_owned()]);
796        assert_eq!(resources.packages.len(), 1);
797        assert_eq!(resources.packages[0].name, "demo");
798        assert_eq!(resources.skill_dirs(), vec![root.join("rpi-skills")]);
799        assert_eq!(resources.prompt_dirs(), vec![root.join("legacy-prompts")]);
800        assert_eq!(
801            resources.theme_files(),
802            vec![root.join("themes/ocean.json")]
803        );
804    }
805
806    #[test]
807    fn resolves_package_json_spec_and_deduplicates() {
808        let tmp = tempfile::tempdir().unwrap();
809        let root = tmp.path().join("pkg");
810        std::fs::create_dir_all(&root).unwrap();
811        std::fs::write(root.join("package.json"), r#"{"name":"demo"}"#).unwrap();
812        let manifest = root.join("package.json").to_string_lossy().into_owned();
813        let resources = discover(
814            tmp.path(),
815            &[manifest.clone(), root.to_string_lossy().into_owned()],
816        );
817        assert_eq!(resources.packages.len(), 1);
818        assert!(resources.diagnostics.is_empty());
819    }
820
821    #[test]
822    fn bare_package_name_prefers_project_rpi_store_over_legacy_pi_store() {
823        let tmp = tempfile::tempdir().unwrap();
824        let rpi_root = tmp.path().join(".rpi/packages/demo");
825        let pi_root = tmp.path().join(".pi/packages/demo");
826        std::fs::create_dir_all(rpi_root.join("skills")).unwrap();
827        std::fs::create_dir_all(pi_root.join("skills")).unwrap();
828        std::fs::write(
829            rpi_root.join("package.json"),
830            r#"{"name":"rpi-demo","version":"rpi"}"#,
831        )
832        .unwrap();
833        std::fs::write(
834            pi_root.join("package.json"),
835            r#"{"name":"pi-demo","version":"pi"}"#,
836        )
837        .unwrap();
838
839        let resources = discover(tmp.path(), &["demo".to_string()]);
840        assert_eq!(resources.packages.len(), 1);
841        assert_eq!(resources.packages[0].root, rpi_root);
842        assert_eq!(resources.packages[0].version.as_deref(), Some("rpi"));
843    }
844
845    #[test]
846    fn npm_scoped_spec_resolves_installed_safe_name() {
847        let tmp = tempfile::tempdir().unwrap();
848        let root = tmp.path().join(".rpi/packages/narumitw__pi-btw");
849        std::fs::create_dir_all(&root).unwrap();
850        std::fs::write(
851            root.join("package.json"),
852            r#"{"name":"@narumitw/pi-btw","version":"0.58.1"}"#,
853        )
854        .unwrap();
855
856        let resources = discover(tmp.path(), &["npm:@narumitw/pi-btw".to_string()]);
857        assert_eq!(resources.packages.len(), 1);
858        assert!(resources.diagnostics.is_empty());
859        assert_eq!(resources.packages[0].name, "@narumitw/pi-btw");
860    }
861
862    #[test]
863    fn npm_scoped_spec_resolves_project_store_and_versioned_spec() {
864        let tmp = tempfile::tempdir().unwrap();
865        let root = tmp.path().join(".pi/packages/@scope/demo");
866        std::fs::create_dir_all(&root).unwrap();
867        std::fs::write(
868            root.join("package.json"),
869            r#"{"name":"@scope/demo","version":"1.2.3"}"#,
870        )
871        .unwrap();
872
873        for spec in ["npm:@scope/demo", "npm:@scope/demo@1.2.3"] {
874            let resources = discover(tmp.path(), &[spec.to_string()]);
875            assert!(resources.diagnostics.is_empty(), "spec={spec}");
876            assert_eq!(resources.packages[0].root, root, "spec={spec}");
877        }
878    }
879}