1use 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 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
116pub 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
135pub 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
146pub 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
179pub 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
187pub 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
194pub 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
318pub 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 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 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 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 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
635fn 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 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}