Skip to main content

nika_cli/
pkg.rs

1//! Package management subcommand handler
2
3use clap::Subcommand;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use nika_engine::error::NikaError;
8use nika_engine::serde_yaml;
9
10/// Package management actions
11///
12/// Manage SuperNovae packages (workflows, skills, schemas) stored in ~/.nika/packages/
13#[derive(Subcommand)]
14pub enum PkgAction {
15    /// List installed packages
16    List {
17        /// Output as JSON
18        #[arg(long)]
19        json: bool,
20    },
21
22    /// Show information about a package
23    Info {
24        /// Package name (e.g., @nika/seo-audit, @workflows/code-review)
25        package: String,
26    },
27
28    /// Add a package to the project
29    ///
30    /// Downloads and installs the package and its dependencies.
31    /// Updates nika.yaml and nika.lock in the project directory.
32    Add {
33        /// Package name (e.g., @nika/seo-audit, @workflows/code-review)
34        package: String,
35
36        /// Package type (workflow, agent, skill, prompt, job, schema)
37        #[arg(short, long)]
38        r#type: Option<String>,
39
40        /// Version constraint (e.g., ^0.1, 1.0.0)
41        #[arg(long, visible_alias = "ver")]
42        version: Option<String>,
43
44        /// Add as dev dependency
45        #[arg(long)]
46        dev: bool,
47    },
48
49    /// Remove a package from the project
50    Remove {
51        /// Package name to remove
52        package: String,
53
54        /// Skip confirmation prompt
55        #[arg(short, long)]
56        yes: bool,
57    },
58
59    /// Install packages from nika.yaml
60    Install {
61        /// Use exact versions from nika.lock
62        #[arg(long)]
63        frozen: bool,
64    },
65
66    /// Update packages to latest compatible versions
67    Update {
68        /// Package to update (updates all if not specified)
69        package: Option<String>,
70    },
71
72    /// List outdated packages
73    Outdated,
74
75    /// Search packages in the registry
76    Search {
77        /// Search query
78        query: String,
79
80        /// Package type filter (workflow, agent, skill, prompt, job, schema)
81        #[arg(short, long)]
82        r#type: Option<String>,
83
84        /// Maximum results to show
85        #[arg(short, long, default_value = "20")]
86        limit: usize,
87    },
88}
89
90///
91/// Manages packages (workflows, skills, schemas) stored in ~/.nika/packages/
92pub async fn handle_pkg_command(action: PkgAction) -> Result<(), NikaError> {
93    use colored::Colorize;
94    use nika_engine::registry::{list_installed, load_manifest, load_registry};
95
96    match action {
97        PkgAction::List { json } => {
98            let packages = list_installed()?;
99
100            if json {
101                let registry = load_registry()?;
102                println!("{}", serde_json::to_string_pretty(&registry)?);
103            } else if packages.is_empty() {
104                println!("{} No packages installed", "ℹ".cyan());
105                println!();
106                println!("Install packages with:");
107                println!("  nika pkg add @nika/seo-audit");
108                println!("  nika pkg install  # Install from nika.yaml");
109            } else {
110                println!("{}", "Installed Packages".bold());
111                println!("{}", "─".repeat(60));
112
113                for (name, version) in &packages {
114                    println!("  {}@{}", name.cyan(), version.green());
115                }
116
117                println!();
118                println!("{} package(s) installed", packages.len());
119            }
120            Ok(())
121        }
122
123        PkgAction::Info { package } => {
124            // Try to find the installed version
125            let registry = load_registry()?;
126
127            if let Some(installed) = registry.get(&package) {
128                println!("{}", format!("Package: {package}").bold());
129                println!("{}", "─".repeat(60));
130                println!("  Version:   {}", installed.version.green());
131                println!("  Path:      {}", installed.manifest_path.dimmed());
132                println!("  Installed: {}", installed.installed_at.dimmed());
133
134                // Try to load manifest for more details
135                if let Ok(manifest) = load_manifest(&package, &installed.version) {
136                    if let Some(ref desc) = manifest.description {
137                        println!("  Description: {desc}");
138                    }
139                    if !manifest.skills.is_empty() {
140                        println!();
141                        println!("  Skills:");
142                        for (name, skill) in &manifest.skills {
143                            println!("    • {} ({})", name.cyan(), skill.path.dimmed());
144                        }
145                    }
146                }
147            } else {
148                println!("{} Package '{}' not installed", "ℹ".cyan(), package);
149                println!();
150                println!("To install: nika pkg add {package}");
151            }
152            Ok(())
153        }
154
155        PkgAction::Add {
156            package,
157            r#type,
158            version,
159            dev: _dev,
160        } => {
161            use nika_engine::registry::{
162                ensure_nika_home, is_version_installed, package_dir, save_registry,
163                InstalledPackage, RegistryClient,
164            };
165
166            println!("{} Adding package: {}", "📦".cyan(), package.green());
167
168            // Infer type from scope if not provided
169            let pkg_type = r#type
170                .as_deref()
171                .or_else(|| infer_package_type(&package))
172                .unwrap_or("workflow");
173
174            println!("  Type: {}", pkg_type.dimmed());
175
176            // Ensure ~/.nika/ exists
177            ensure_nika_home()?;
178
179            // Create registry client
180            let client = RegistryClient::new().map_err(|e| NikaError::ConfigError {
181                reason: format!("Failed to create registry client: {e}"),
182            })?;
183
184            // Fetch package info from registry
185            println!("  {} Fetching package info...", "→".dimmed());
186            let pkg_info =
187                client
188                    .get_package(&package)
189                    .await
190                    .map_err(|_e| NikaError::PackageNotFound {
191                        name: package.clone(),
192                        version: "latest".to_string(),
193                    })?;
194
195            // Determine version to install
196            let target_version = version.as_deref().unwrap_or(&pkg_info.latest_version);
197            println!("  {} Version: {}", "→".dimmed(), target_version.green());
198
199            // Check if already installed
200            if is_version_installed(&package, target_version)? {
201                println!(
202                    "{} {}@{} is already installed",
203                    "✓".green(),
204                    package.cyan(),
205                    target_version.green()
206                );
207                return Ok(());
208            }
209
210            // Get target directory
211            let target_dir = package_dir(&package, target_version)?;
212            println!(
213                "  {} Installing to: {}",
214                "→".dimmed(),
215                target_dir.display().to_string().dimmed()
216            );
217
218            // Download and extract package
219            println!("  {} Downloading...", "→".dimmed());
220            client
221                .download_and_extract(&package, target_version, &target_dir)
222                .await
223                .map_err(|e| NikaError::ValidationError {
224                    reason: format!("Failed to download package: {e}"),
225                })?;
226
227            // Update registry index
228            let mut registry = load_registry()?;
229            let manifest_path = format!("packages/{package}/{target_version}/manifest.yaml");
230            registry.insert(
231                package.clone(),
232                InstalledPackage::now(target_version.to_string(), manifest_path),
233            );
234            save_registry(&registry)?;
235
236            println!();
237            println!(
238                "{} Successfully installed {}@{}",
239                "✓".green(),
240                package.cyan(),
241                target_version.green()
242            );
243
244            // Show installed skills if any
245            if let Ok(manifest) = load_manifest(&package, target_version) {
246                if !manifest.skills.is_empty() {
247                    println!();
248                    println!("  {} Skills:", "📚".cyan());
249                    for (name, skill) in &manifest.skills {
250                        println!("    • {} ({})", name.cyan(), skill.path.dimmed());
251                    }
252                }
253            }
254
255            Ok(())
256        }
257
258        PkgAction::Remove { package, yes: _ } => {
259            use nika_engine::registry::{package_dir, save_registry};
260
261            println!("{} Removing package: {}", "🗑".red(), package);
262
263            // Check if installed
264            let mut registry = load_registry()?;
265            let installed = match registry.get(&package) {
266                Some(pkg) => pkg.clone(),
267                None => {
268                    println!("{} Package '{}' is not installed", "ℹ".cyan(), package);
269                    return Ok(());
270                }
271            };
272
273            // Get package directory
274            let pkg_dir = package_dir(&package, &installed.version)?;
275
276            // Remove package directory
277            if pkg_dir.exists() {
278                println!(
279                    "  {} Removing {}",
280                    "→".dimmed(),
281                    pkg_dir.display().to_string().dimmed()
282                );
283                std::fs::remove_dir_all(&pkg_dir).map_err(|e| NikaError::ValidationError {
284                    reason: format!("Failed to remove package directory: {e}"),
285                })?;
286
287                // Clean up empty parent directories
288                if let Some(parent) = pkg_dir.parent() {
289                    if parent
290                        .read_dir()
291                        .map(|mut d| d.next().is_none())
292                        .unwrap_or(false)
293                    {
294                        let _ = std::fs::remove_dir(parent);
295                    }
296                }
297            }
298
299            // Update registry
300            registry.remove(&package);
301            save_registry(&registry)?;
302
303            println!();
304            println!(
305                "{} Successfully removed {}@{}",
306                "✓".green(),
307                package.cyan(),
308                installed.version.green()
309            );
310            Ok(())
311        }
312
313        PkgAction::Install { frozen } => {
314            use nika_engine::registry::{
315                ensure_nika_home, is_version_installed, package_dir, save_registry,
316                InstalledPackage, Lockfile, RegistryClient,
317            };
318
319            println!(
320                "{} Installing packages from project manifest{}",
321                "📦".cyan(),
322                if frozen { " (frozen)" } else { "" }
323            );
324
325            // Ensure ~/.nika/ exists
326            ensure_nika_home()?;
327
328            // Find project manifest
329            let manifest_path = if Path::new("nika.yaml").exists() {
330                PathBuf::from("nika.yaml")
331            } else {
332                println!("{} No project manifest found (nika.yaml)", "⚠".yellow());
333                println!();
334                println!("Create one with:");
335                println!("  nika init");
336                return Ok(());
337            };
338
339            println!("  {} Reading {}", "→".dimmed(), manifest_path.display());
340
341            // Read manifest
342            let content =
343                fs::read_to_string(&manifest_path).map_err(|e| NikaError::ValidationError {
344                    reason: format!("Failed to read {}: {}", manifest_path.display(), e),
345                })?;
346
347            // Parse manifest to get dependencies
348            #[derive(serde::Deserialize)]
349            struct ProjectManifest {
350                #[serde(default)]
351                dependencies: std::collections::HashMap<String, String>,
352            }
353
354            let manifest: ProjectManifest =
355                serde_yaml::from_str(&content).map_err(|e| NikaError::ParseError {
356                    details: format!("Failed to parse manifest: {e}"),
357                })?;
358
359            if manifest.dependencies.is_empty() {
360                println!("{} No dependencies to install", "ℹ".cyan());
361                return Ok(());
362            }
363
364            // Load lockfile for frozen installs
365            let lockfile = if frozen {
366                println!("  {} Reading nika.lock", "→".dimmed());
367                Lockfile::load(None).unwrap_or_else(|_| {
368                    println!(
369                        "{} No nika.lock found, will use latest versions",
370                        "⚠".yellow()
371                    );
372                    Lockfile::new()
373                })
374            } else {
375                Lockfile::new()
376            };
377
378            // Create registry client
379            let client = RegistryClient::new().map_err(|e| NikaError::ConfigError {
380                reason: format!("Failed to create registry client: {e}"),
381            })?;
382            let mut registry = load_registry()?;
383            let mut installed_count = 0;
384            let mut skipped_count = 0;
385
386            println!();
387            println!(
388                "{} Installing {} dependencies...",
389                "📦".cyan(),
390                manifest.dependencies.len()
391            );
392
393            for (name, version_spec) in &manifest.dependencies {
394                // Determine version to install
395                let target_version = if frozen {
396                    // Use locked version if available
397                    lockfile
398                        .find_version(name)
399                        .map_or_else(|| version_spec.clone(), |v| v.to_string())
400                } else {
401                    // Use version spec or fetch latest
402                    if version_spec == "*" || version_spec == "latest" {
403                        match client.get_package(name).await {
404                            Ok(info) => info.latest_version.clone(),
405                            Err(_) => {
406                                println!("  {} {} - not found", "✗".red(), name.cyan());
407                                continue;
408                            }
409                        }
410                    } else {
411                        version_spec.trim_start_matches('^').to_string()
412                    }
413                };
414
415                // Check if already installed
416                if is_version_installed(name, &target_version)? {
417                    println!(
418                        "  {} {}@{} (already installed)",
419                        "✓".green(),
420                        name.cyan(),
421                        target_version.dimmed()
422                    );
423                    skipped_count += 1;
424                    continue;
425                }
426
427                // Install package
428                let target_dir = package_dir(name, &target_version)?;
429                match client
430                    .download_and_extract(name, &target_version, &target_dir)
431                    .await
432                {
433                    Ok(_) => {
434                        // Update registry
435                        let manifest_path =
436                            format!("packages/{name}/{target_version}/manifest.yaml");
437                        registry.insert(
438                            name.clone(),
439                            InstalledPackage::now(target_version.clone(), manifest_path),
440                        );
441                        println!(
442                            "  {} {}@{}",
443                            "✓".green(),
444                            name.cyan(),
445                            target_version.green()
446                        );
447                        installed_count += 1;
448                    }
449                    Err(e) => {
450                        println!(
451                            "  {} {} - {}",
452                            "✗".red(),
453                            name.cyan(),
454                            e.to_string().dimmed()
455                        );
456                    }
457                }
458            }
459
460            // Save registry
461            save_registry(&registry)?;
462
463            println!();
464            if installed_count > 0 || skipped_count > 0 {
465                println!(
466                    "{} {} package(s) installed, {} already up to date",
467                    "✓".green(),
468                    installed_count,
469                    skipped_count
470                );
471            }
472
473            Ok(())
474        }
475
476        PkgAction::Update { package } => {
477            if let Some(ref pkg) = package {
478                println!("{} Updating package: {}", "🔄".cyan(), pkg.green());
479            } else {
480                println!("{} Updating all packages", "🔄".cyan());
481            }
482
483            println!();
484            println!("{} Package update is not yet implemented", "⚠".yellow());
485            Ok(())
486        }
487
488        PkgAction::Outdated => {
489            println!("{} Checking for outdated packages...", "📋".cyan());
490
491            println!();
492            println!(
493                "{} Outdated package detection is not yet implemented",
494                "⚠".yellow()
495            );
496            Ok(())
497        }
498
499        PkgAction::Search {
500            query,
501            r#type,
502            limit,
503        } => {
504            use nika_engine::registry::RegistryClient;
505
506            // Build search query - append type filter if provided
507            let search_query = if let Some(ref t) = r#type {
508                format!("{query} type:{t}")
509            } else {
510                query.clone()
511            };
512
513            println!(
514                "{} Searching registry for '{}'...",
515                "🔍".cyan(),
516                query.green()
517            );
518
519            if let Some(ref t) = r#type {
520                println!("  Type filter: {}", t.dimmed());
521            }
522
523            // Create registry client
524            let client = RegistryClient::new().map_err(|e| NikaError::ConfigError {
525                reason: format!("Failed to create registry client: {e}"),
526            })?;
527
528            // Search registry (page=1, per_page=limit)
529            let response = client.search(&search_query, 1, limit).await.map_err(|e| {
530                NikaError::ValidationError {
531                    reason: format!("Search failed: {e}"),
532                }
533            })?;
534
535            println!();
536            if response.results.is_empty() {
537                println!("{} No packages found matching '{}'", "ℹ".cyan(), query);
538            } else {
539                println!(
540                    "{} Found {} package(s):",
541                    "📦".cyan(),
542                    response.results.len()
543                );
544                println!("{}", "─".repeat(60));
545
546                for result in &response.results {
547                    println!(
548                        "  {} {}",
549                        result.name.cyan(),
550                        format!("v{}", result.version).green()
551                    );
552                    if let Some(ref desc) = result.description {
553                        println!("    {}", desc.dimmed());
554                    }
555                    if let Some(ref keywords) = result.keywords {
556                        if !keywords.is_empty() {
557                            println!("    Keywords: {}", keywords.join(", ").dimmed());
558                        }
559                    }
560                }
561
562                println!("{}", "─".repeat(60));
563                println!();
564                println!("Install with: nika pkg add <package-name>");
565            }
566            Ok(())
567        }
568    }
569}
570
571fn infer_package_type(package: &str) -> Option<&'static str> {
572    if package.starts_with("@workflows/") || package.starts_with("@nika/") {
573        Some("workflow")
574    } else if package.starts_with("@agents/") {
575        Some("agent")
576    } else if package.starts_with("@skills/") {
577        Some("skill")
578    } else if package.starts_with("@prompts/") {
579        Some("prompt")
580    } else if package.starts_with("@jobs/") {
581        Some("job")
582    } else if package.starts_with("@schemas/") || package.starts_with("@novanet/") {
583        Some("schema")
584    } else {
585        None
586    }
587}