1use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10
11#[derive(Debug, Clone)]
12struct Options {
13 spec: String,
14 global: bool,
15 force: bool,
16}
17
18pub fn run(args: &[String]) -> i32 {
19 let options = match parse_args(args) {
20 Ok(options) => options,
21 Err(message) if message == "help" => {
22 print_help();
23 return 0;
24 }
25 Err(message) => {
26 eprintln!("error: {message}");
27 print_help();
28 return 2;
29 }
30 };
31 let cwd = match std::env::current_dir() {
32 Ok(path) => path,
33 Err(error) => {
34 eprintln!("error: could not determine current directory: {error}");
35 return 1;
36 }
37 };
38 let destination = match destination(&cwd, &options) {
39 Ok(path) => path,
40 Err(error) => {
41 eprintln!("error: {error}");
42 return 1;
43 }
44 };
45 if let Err(error) = std::fs::create_dir_all(&destination) {
46 eprintln!("error: could not create {}: {error}", destination.display());
47 return 1;
48 }
49 let package_root = match install_spec(&cwd, &destination, &options) {
50 Ok(path) => path,
51 Err(error) => {
52 eprintln!("error: {error}");
53 return 1;
54 }
55 };
56
57 let package_root =
58 normalize_config_path(std::fs::canonicalize(&package_root).unwrap_or(package_root));
59 let mut settings = crate::settings::load_settings().unwrap_or_default();
60 let packages = settings.packages.get_or_insert_with(Vec::new);
61 let enabled = format!("file:{}", package_root.display());
62 if !packages.iter().any(|value| value == &enabled) {
63 packages.push(enabled);
64 if let Err(error) = crate::settings::save_settings(&settings) {
65 eprintln!("error: installed package but could not save settings: {error}");
66 return 1;
67 }
68 }
69 println!("installed Pi package {}", package_root.display());
70 println!("JS/TS extensions and static resources will load on the next start.");
71 println!("warning: Pi extensions execute JavaScript with the current user's permissions.");
72 0
73}
74
75fn parse_args(args: &[String]) -> Result<Options, String> {
76 let mut spec = None;
77 let mut global = false;
78 let mut force = false;
79 let mut i = 0;
80 while i < args.len() {
81 match args[i].as_str() {
82 "--help" | "-h" => return Err("help".into()),
83 "--global" | "-g" => global = true,
84 "--force" | "-f" => force = true,
85 value if value.starts_with('-') => {
86 return Err(format!("unknown install-pi option `{value}`"))
87 }
88 value => {
89 if spec.replace(value.to_string()).is_some() {
90 return Err("install-pi accepts exactly one package spec".into());
91 }
92 }
93 }
94 i += 1;
95 }
96 Ok(Options {
97 spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
98 global,
99 force,
100 })
101}
102
103fn destination(cwd: &Path, options: &Options) -> Result<PathBuf, String> {
104 if options.global {
105 return crate::config::agent_dir()
106 .map(|path| path.join("packages"))
107 .map_err(|error| error.to_string());
108 }
109 Ok(cwd.join(".rpi/packages"))
110}
111
112fn install_spec(cwd: &Path, destination: &Path, options: &Options) -> Result<PathBuf, String> {
113 let spec = options.spec.as_str();
114 if spec.starts_with("git:") || spec.starts_with("https://") || spec.starts_with("http://") {
115 return install_git(cwd, destination, spec, options.force);
116 }
117 if spec.starts_with("npm:") || (!Path::new(spec).exists() && looks_like_npm(spec)) {
118 return install_npm(destination, spec, options.force);
119 }
120 install_local(cwd, destination, spec, options.force)
121}
122
123fn package_name(spec: &str) -> String {
124 let raw = spec.strip_prefix("npm:").unwrap_or(spec);
125 let raw = if raw.starts_with('@') {
126 raw.rfind('@')
127 .filter(|index| *index > 0)
128 .map(|index| &raw[..index])
129 .unwrap_or(raw)
130 } else {
131 raw.split('@').next().unwrap_or(raw)
132 };
133 raw.trim_start_matches('@')
134 .replace('/', "__")
135 .replace('\\', "__")
136}
137
138fn safe_name(spec: &str) -> String {
139 package_name(spec)
140 .chars()
141 .map(|ch| {
142 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
143 ch
144 } else {
145 '-'
146 }
147 })
148 .collect()
149}
150
151fn install_npm(destination: &Path, spec: &str, force: bool) -> Result<PathBuf, String> {
152 let name = safe_name(spec);
153 let stage = tempfile::tempdir()
154 .map_err(|error| format!("could not create npm staging dir: {error}"))?;
155 let mut command = Command::new(npm_program());
156 command
157 .args(["install", "--prefix"])
158 .arg(stage.path())
159 .args(["--omit=dev", "--no-package-lock"])
160 .arg(spec.strip_prefix("npm:").unwrap_or(spec))
161 .stdin(Stdio::inherit())
162 .stdout(Stdio::inherit())
163 .stderr(Stdio::inherit());
164 let status = command
165 .status()
166 .map_err(|error| format!("could not execute npm (install Node.js/npm first): {error}"))?;
167 if !status.success() {
168 return Err(format!("npm install exited with {status}"));
169 }
170 let installed = stage
171 .path()
172 .join("node_modules")
173 .join(npm_module_name(spec));
174 if !installed.is_dir() {
175 return Err(format!(
176 "npm installed `{spec}` but no package directory was found"
177 ));
178 }
179 let target = destination.join(&name);
180 replace_dir(&installed, &target, force)?;
181 install_production_dependencies(&target)?;
182 Ok(target)
183}
184
185pub fn update_npm_package(root: &Path, name: &str) -> Result<PathBuf, String> {
189 let destination = root
190 .parent()
191 .ok_or_else(|| format!("package root has no parent: {}", root.display()))?;
192 install_npm(destination, &format!("npm:{name}@latest"), true)
193}
194
195#[derive(Debug, Clone)]
196struct UninstallOptions {
197 spec: String,
198 global: bool,
199}
200
201pub fn uninstall(args: &[String]) -> i32 {
204 let options = match parse_uninstall_args(args) {
205 Ok(options) => options,
206 Err(message) if message == "help" => {
207 print_uninstall_help();
208 return 0;
209 }
210 Err(message) => {
211 eprintln!("error: {message}");
212 print_uninstall_help();
213 return 2;
214 }
215 };
216 let cwd = match std::env::current_dir() {
217 Ok(path) => path,
218 Err(error) => {
219 eprintln!("error: could not determine current directory: {error}");
220 return 1;
221 }
222 };
223 let root = find_installed_root(&cwd, &options);
224 let removed_settings = match remove_settings_entries(&cwd, &options.spec, root.as_deref()) {
225 Ok(count) => count,
226 Err(error) => {
227 eprintln!("error: could not update package settings: {error}");
228 return 1;
229 }
230 };
231 let mut removed_files = false;
232 if let Some(root) = root {
233 if root.exists() && is_managed_package_root(&cwd, &root) {
234 match std::fs::remove_dir_all(&root) {
235 Ok(()) => {
236 println!("removed Pi package {}", root.display());
237 removed_files = true;
238 }
239 Err(error) => {
240 eprintln!("error: could not remove {}: {error}", root.display());
241 return 1;
242 }
243 }
244 } else if root.exists() {
245 println!(
246 "disabled Pi package at {}; source directory was left intact",
247 root.display()
248 );
249 }
250 }
251 if !removed_files && removed_settings == 0 {
252 println!("Pi package is not installed: {}", options.spec);
253 } else if removed_settings > 0 && !removed_files {
254 println!("disabled Pi package {}", options.spec);
255 }
256 0
257}
258
259fn parse_uninstall_args(args: &[String]) -> Result<UninstallOptions, String> {
260 let mut spec = None;
261 let mut global = false;
262 for arg in args {
263 match arg.as_str() {
264 "--help" | "-h" => return Err("help".into()),
265 "--global" | "-g" => global = true,
266 value if value.starts_with('-') => {
267 return Err(format!("unknown uninstall-pi option `{value}`"));
268 }
269 value => {
270 if spec.replace(value.to_string()).is_some() {
271 return Err("uninstall-pi accepts exactly one package spec".into());
272 }
273 }
274 }
275 }
276 Ok(UninstallOptions {
277 spec: spec.ok_or_else(|| "missing npm, git, or local package spec".to_string())?,
278 global,
279 })
280}
281
282fn find_installed_root(cwd: &Path, options: &UninstallOptions) -> Option<PathBuf> {
283 let raw = options.spec.strip_prefix("file:").unwrap_or(&options.spec);
284 let package_key = package_dir_name(&options.spec);
285 let mut candidates = Vec::new();
286 let direct = PathBuf::from(raw);
287 if direct.is_absolute() || raw.starts_with('.') {
288 candidates.push(if direct.is_absolute() {
289 direct
290 } else {
291 cwd.join(direct)
292 });
293 }
294 let add_store = |store: PathBuf, candidates: &mut Vec<PathBuf>| {
295 candidates.push(store.join(&package_key));
296 };
297 if !options.global {
298 add_store(cwd.join(".rpi/packages"), &mut candidates);
299 add_store(cwd.join(".pi/packages"), &mut candidates);
300 }
301 if let Ok(agent) = crate::config::agent_dir() {
302 add_store(agent.join("packages"), &mut candidates);
303 }
304 if let Some(home) = dirs::home_dir() {
305 add_store(home.join(".pi/agent/packages"), &mut candidates);
306 }
307 let wanted_name = package_name(&options.spec);
308 for package in crate::packages::discover_from_settings(cwd).packages {
309 if package.name == wanted_name
310 || safe_name(&package.name) == package_key
311 || package
312 .root
313 .file_name()
314 .and_then(|name| name.to_str())
315 .is_some_and(|name| name == package_key)
316 {
317 candidates.push(package.root);
318 }
319 }
320 candidates
321 .into_iter()
322 .find_map(|path| std::fs::canonicalize(path).ok())
323}
324
325fn package_dir_name(spec: &str) -> String {
326 let raw = spec.strip_prefix("git:").unwrap_or(spec);
327 if raw.starts_with("http://") || raw.starts_with("https://") {
328 return safe_name(
329 raw.trim_end_matches('/')
330 .rsplit('/')
331 .next()
332 .unwrap_or("package"),
333 );
334 }
335 safe_name(raw)
336}
337
338fn is_managed_package_root(cwd: &Path, root: &Path) -> bool {
339 let mut stores = vec![cwd.join(".rpi/packages"), cwd.join(".pi/packages")];
340 if let Ok(agent) = crate::config::agent_dir() {
341 stores.push(agent.join("packages"));
342 }
343 if let Some(home) = dirs::home_dir() {
344 stores.push(home.join(".pi/agent/packages"));
345 }
346 stores.iter().any(|store| {
347 let store = std::fs::canonicalize(store).unwrap_or_else(|_| store.clone());
348 root.starts_with(store)
349 })
350}
351
352fn remove_settings_entries(cwd: &Path, spec: &str, root: Option<&Path>) -> Result<usize, String> {
353 let mut settings = crate::settings::load_settings().unwrap_or_default();
354 let Some(packages) = settings.packages.as_mut() else {
355 return Ok(0);
356 };
357 let wanted_name = package_name(spec);
358 let before = packages.len();
359 packages.retain(|entry| {
360 if entry == spec || package_name(entry) == wanted_name && !entry.starts_with('.') {
361 return false;
362 }
363 let matches_root = root.is_some_and(|root| {
364 crate::packages::resolve_package(cwd, entry)
365 .ok()
366 .and_then(|package| std::fs::canonicalize(package.root).ok())
367 .is_some_and(|candidate| candidate == root)
368 });
369 !matches_root
370 });
371 let removed = before - packages.len();
372 if removed == 0 {
373 return Ok(0);
374 }
375 if packages.is_empty() {
376 settings.packages = None;
377 }
378 crate::settings::save_settings(&settings).map_err(|error| error.to_string())?;
379 Ok(removed)
380}
381
382fn npm_module_name(spec: &str) -> String {
383 let raw = spec.strip_prefix("npm:").unwrap_or(spec);
384 if raw.starts_with('@') {
385 raw.rfind('@')
386 .filter(|index| *index > 0)
387 .map(|index| raw[..index].to_string())
388 .unwrap_or_else(|| raw.to_string())
389 } else {
390 raw.split('@').next().unwrap_or(raw).to_string()
391 }
392}
393
394fn install_git(cwd: &Path, destination: &Path, spec: &str, force: bool) -> Result<PathBuf, String> {
395 let url = spec.strip_prefix("git:").unwrap_or(spec);
396 let (url, revision) = url
397 .split_once('@')
398 .map_or((url, None), |(u, r)| (u, Some(r)));
399 let name = safe_name(
400 url.trim_end_matches('/')
401 .rsplit('/')
402 .next()
403 .unwrap_or("package"),
404 );
405 let target = destination.join(name);
406 if target.exists() {
407 if !force {
408 return Err(format!(
409 "{} already exists; use --force to replace it",
410 target.display()
411 ));
412 }
413 std::fs::remove_dir_all(&target)
414 .map_err(|error| format!("could not replace {}: {error}", target.display()))?;
415 }
416 let status = Command::new("git")
417 .args(["clone", "--depth", "1"])
418 .arg(url)
419 .arg(&target)
420 .current_dir(cwd)
421 .stdin(Stdio::inherit())
422 .stdout(Stdio::inherit())
423 .stderr(Stdio::inherit())
424 .status()
425 .map_err(|error| format!("could not execute git: {error}"))?;
426 if !status.success() {
427 return Err(format!("git clone exited with {status}"));
428 }
429 if let Some(revision) = revision {
430 let status = Command::new("git")
431 .args(["fetch", "--depth", "1", "origin", revision])
432 .current_dir(&target)
433 .stdin(Stdio::inherit())
434 .stdout(Stdio::inherit())
435 .stderr(Stdio::inherit())
436 .status()
437 .map_err(|error| format!("could not fetch git revision: {error}"))?;
438 if !status.success() {
439 return Err(format!("git fetch exited with {status}"));
440 }
441 let status = Command::new("git")
442 .args(["checkout", revision])
443 .current_dir(&target)
444 .stdin(Stdio::inherit())
445 .stdout(Stdio::inherit())
446 .stderr(Stdio::inherit())
447 .status()
448 .map_err(|error| format!("could not checkout git revision: {error}"))?;
449 if !status.success() {
450 return Err(format!("git checkout exited with {status}"));
451 }
452 }
453 install_production_dependencies(&target)?;
454 Ok(target)
455}
456
457fn install_local(
458 cwd: &Path,
459 destination: &Path,
460 spec: &str,
461 force: bool,
462) -> Result<PathBuf, String> {
463 let source = PathBuf::from(spec);
464 let source = if source.is_absolute() {
465 source
466 } else {
467 cwd.join(source)
468 };
469 if !source.is_dir() {
470 return Err(format!(
471 "local package directory not found: {}",
472 source.display()
473 ));
474 }
475 let name = std::fs::read_to_string(source.join("package.json"))
476 .ok()
477 .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
478 .and_then(|value| {
479 value
480 .get("name")
481 .and_then(|name| name.as_str())
482 .map(safe_name)
483 })
484 .unwrap_or_else(|| safe_name(spec));
485 let target = destination.join(name);
486 if std::fs::canonicalize(&source).ok() == std::fs::canonicalize(&target).ok() {
487 return Ok(target);
488 }
489 replace_dir(&source, &target, force)?;
490 install_production_dependencies(&target)?;
491 Ok(target)
492}
493
494fn replace_dir(source: &Path, target: &Path, force: bool) -> Result<(), String> {
495 if target.exists() {
496 if !force {
497 return Err(format!(
498 "{} already exists; use --force to replace it",
499 target.display()
500 ));
501 }
502 std::fs::remove_dir_all(target)
503 .map_err(|error| format!("could not replace {}: {error}", target.display()))?;
504 }
505 std::fs::create_dir_all(target.parent().unwrap_or(target))
506 .map_err(|error| error.to_string())?;
507 copy_dir(source, target).map_err(|error| format!("could not copy package: {error}"))
508}
509
510fn copy_dir(source: &Path, target: &Path) -> std::io::Result<()> {
511 std::fs::create_dir_all(target)?;
512 for entry in std::fs::read_dir(source)? {
513 let entry = entry?;
514 if matches!(entry.file_name().to_str(), Some("node_modules" | ".git")) {
515 continue;
516 }
517 let from = entry.path();
518 let to = target.join(entry.file_name());
519 if from.is_dir() {
520 copy_dir(&from, &to)?;
521 } else {
522 std::fs::copy(from, to)?;
523 }
524 }
525 Ok(())
526}
527
528fn install_production_dependencies(root: &Path) -> Result<(), String> {
529 if !root.join("package.json").is_file() {
530 return Ok(());
531 }
532 let status = Command::new(npm_program())
533 .args(["install", "--omit=dev", "--no-package-lock"])
534 .current_dir(root)
535 .stdin(Stdio::inherit())
536 .stdout(Stdio::inherit())
537 .stderr(Stdio::inherit())
538 .status()
539 .map_err(|error| format!("could not execute npm for package dependencies: {error}"))?;
540 if !status.success() {
541 return Err(format!("npm install exited with {status}"));
542 }
543 let mut host_packages = Vec::new();
544 if let Ok(text) = std::fs::read_to_string(root.join("package.json")) {
545 if let Ok(manifest) = serde_json::from_str::<serde_json::Value>(&text) {
546 if let Some(peers) = manifest
547 .get("peerDependencies")
548 .and_then(|value| value.as_object())
549 {
550 for (name, version) in peers {
551 let version = version.as_str().unwrap_or("*");
552 host_packages.push(if version == "*" {
553 name.to_string()
554 } else {
555 format!("{name}@{version}")
556 });
557 }
558 if peers.contains_key("@earendil-works/pi-coding-agent") {
559 host_packages.push("@earendil-works/pi-server".to_string());
562 }
563 }
564 }
565 }
566 if !host_packages.is_empty() {
567 let mut command = Command::new(npm_program());
568 command
569 .args(["install", "--omit=dev", "--no-package-lock", "--no-save"])
570 .args(&host_packages)
571 .current_dir(root)
572 .stdin(Stdio::inherit())
573 .stdout(Stdio::inherit())
574 .stderr(Stdio::inherit());
575 let status = command
576 .status()
577 .map_err(|error| format!("could not install Pi host dependencies: {error}"))?;
578 if !status.success() {
579 return Err(format!("npm host dependency install exited with {status}"));
580 }
581 }
582 Ok(())
583}
584
585fn npm_program() -> &'static str {
586 if cfg!(windows) {
587 "npm.cmd"
588 } else {
589 "npm"
590 }
591}
592
593fn normalize_config_path(path: PathBuf) -> PathBuf {
594 if cfg!(windows) {
595 let text = path.to_string_lossy();
596 if let Some(stripped) = text.strip_prefix("\\\\?\\") {
597 return PathBuf::from(stripped);
598 }
599 }
600 path
601}
602
603fn looks_like_npm(spec: &str) -> bool {
604 !spec.contains(std::path::MAIN_SEPARATOR) && !spec.contains('/') && !spec.ends_with(".json")
605}
606
607pub fn print_help() {
608 println!("Usage: rpi install-pi [options] <spec>\n\nInstall a Pi npm/git/local package.\n\nSpecs:\n npm:@scope/package@1.0.0\n git:github.com/user/repo@v1\n ./local-package\n\nOptions:\n --global, -g Install into ~/.rpi/agent/packages\n --force, -f Replace an existing package\n --help, -h Show this help");
609}
610
611fn print_uninstall_help() {
612 println!("Usage: rpi uninstall-pi [options] <spec>\n\nRemove an installed Pi npm/git/local package and disable it in settings.\n\nSpecs:\n npm:@scope/package\n git:github.com/user/repo\n ./local-package\n\nOptions:\n --global, -g Remove from ~/.rpi/agent/packages\n --help, -h Show this help\n\nAliases:\n rpi uninstall pi <spec>");
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618
619 #[test]
620 fn parses_install_specs_and_flags() {
621 let options = parse_args(&["--global".into(), "npm:@scope/pkg@1.2.3".into()]).unwrap();
622 assert!(options.global);
623 assert_eq!(options.spec, "npm:@scope/pkg@1.2.3");
624 assert_eq!(package_name(&options.spec), "scope__pkg");
625 assert_eq!(npm_module_name(&options.spec), "@scope/pkg");
626 }
627
628 #[test]
629 fn rejects_missing_spec() {
630 assert!(parse_args(&[]).is_err());
631 }
632
633 #[test]
634 fn parses_uninstall_specs_and_flags() {
635 let options = parse_uninstall_args(&["--global".into(), "npm:@scope/pkg".into()]).unwrap();
636 assert!(options.global);
637 assert_eq!(options.spec, "npm:@scope/pkg");
638 }
639
640 #[test]
641 fn rejects_multiple_uninstall_specs() {
642 assert!(parse_uninstall_args(&["a".into(), "b".into()]).is_err());
643 }
644}