agpm_cli/cli/
install.rs

1//! Install Claude Code resources from manifest dependencies.
2//!
3//! This module provides the `install` command which reads dependencies from the
4//! `agpm.toml` manifest file, resolves them, and installs the resource files
5//! to the project directory. The command supports both fresh installations and
6//! updates to existing installations with advanced parallel processing capabilities.
7//!
8//! # Features
9//!
10//! - **Dependency Resolution**: Resolves all dependencies defined in the manifest
11//! - **Transitive Dependencies**: Automatically discovers and installs dependencies declared in resource files
12//! - **Lockfile Management**: Generates and maintains `agpm.lock` for reproducible builds
13//! - **Worktree-Based Parallel Installation**: Uses Git worktrees for safe concurrent resource installation
14//! - **Multi-Phase Progress Tracking**: Shows detailed progress with phase transitions and real-time updates
15//! - **Resource Validation**: Validates markdown files and content during installation
16//! - **Cache Support**: Advanced cache with instance-level optimizations and worktree management
17//! - **Concurrency Control**: User-configurable parallelism via `--max-parallel` flag
18//! - **Cycle Detection**: Prevents circular dependency loops in transitive dependency graphs
19//!
20//! # Examples
21//!
22//! Install all dependencies from manifest:
23//! ```bash
24//! agpm install
25//! ```
26//!
27//! Force reinstall all dependencies:
28//! ```bash
29//! agpm install --force
30//! ```
31//!
32//! Install without creating lockfile:
33//! ```bash
34//! agpm install --no-lock
35//! ```
36//!
37//! Use frozen lockfile (CI/production):
38//! ```bash
39//! agpm install --frozen
40//! ```
41//!
42//! Disable cache and clone fresh:
43//! ```bash
44//! agpm install --no-cache
45//! ```
46//!
47//! Install only direct dependencies (skip transitive):
48//! ```bash
49//! agpm install --no-transitive
50//! ```
51//!
52//! Preview installation without making changes:
53//! ```bash
54//! agpm install --dry-run
55//! ```
56//!
57//! # Installation Process
58//!
59//! 1. **Manifest Loading**: Reads `agpm.toml` to understand dependencies
60//! 2. **Source Synchronization**: Clones/fetches Git repositories for all sources
61//! 3. **Dependency Resolution**: Resolves versions and creates dependency graph
62//! 4. **Transitive Discovery**: Extracts dependencies from resource files (YAML/JSON metadata)
63//! 5. **Cycle Detection**: Validates dependency graph for circular references
64//! 6. **Worktree Preparation**: Pre-creates Git worktrees for optimal parallel access
65//! 7. **Parallel Resource Installation**: Installs resources concurrently using isolated worktrees
66//! 8. **Progress Coordination**: Updates multi-phase progress tracking throughout installation
67//! 9. **Configuration Updates**: Updates hooks and MCP server configurations as needed
68//! 10. **Lockfile Generation**: Creates or updates `agpm.lock` with checksums and metadata
69//! 11. **Artifact Cleanup**: Removes old artifacts from removed or relocated dependencies
70//!
71//! # Error Conditions
72//!
73//! - No manifest file found in project
74//! - Invalid manifest syntax or structure
75//! - Dependency resolution conflicts
76//! - Circular dependency loops detected
77//! - Invalid transitive dependency metadata (malformed YAML/JSON)
78//! - Network or Git access issues
79//! - File system permissions or disk space issues
80//! - Invalid resource file format
81//!
82//! # Performance
83//!
84//! The install command is optimized for maximum performance:
85//! - **Worktree-based parallelism**: Each dependency gets its own isolated Git worktree
86//! - **Instance-level caching**: Optimized worktree reuse within command execution
87//! - **Configurable concurrency**: `--max-parallel` flag controls dependency-level parallelism
88//! - **Pre-warming strategy**: Creates all needed worktrees upfront for optimal parallel access
89//! - **Atomic file operations**: Safe, corruption-resistant file installation
90//! - **Multi-phase progress**: Real-time progress updates with phase transitions
91
92use anyhow::Result;
93use clap::Args;
94use std::path::{Path, PathBuf};
95
96use crate::cache::Cache;
97use crate::core::{OperationContext, ResourceIterator};
98use crate::lockfile::LockFile;
99use crate::manifest::{ResourceDependency, find_manifest_with_optional};
100use crate::resolver::DependencyResolver;
101
102/// Command to install Claude Code resources from manifest dependencies.
103///
104/// This command reads the project's `agpm.toml` manifest file, resolves all dependencies,
105/// and installs the resource files to the appropriate directories. It generates or updates
106/// a `agpm.lock` lockfile to ensure reproducible installations.
107///
108/// # Behavior
109///
110/// 1. Locates and loads the project manifest (`agpm.toml`)
111/// 2. Resolves dependencies using the dependency resolver
112/// 3. Downloads or updates Git repository sources as needed
113/// 4. Installs resource files to target directories
114/// 5. Generates or updates the lockfile (`agpm.lock`)
115/// 6. Provides progress feedback during installation
116///
117/// # Examples
118///
119/// ```rust,no_run
120/// use agpm_cli::cli::install::InstallCommand;
121///
122/// // Standard installation
123/// let cmd = InstallCommand {
124///     no_lock: false,
125///     frozen: false,
126///     no_cache: false,
127///     max_parallel: None,
128///     quiet: false,
129///     no_progress: false,
130///     verbose: false,
131///     no_transitive: false,
132///     dry_run: false,
133/// };
134///
135/// // CI/Production installation (frozen lockfile)
136/// let cmd = InstallCommand {
137///     no_lock: false,
138///     frozen: true,
139///     no_cache: false,
140///     max_parallel: Some(2),
141///     quiet: false,
142///     no_progress: false,
143///     verbose: false,
144///     no_transitive: false,
145///     dry_run: false,
146/// };
147/// ```
148#[derive(Args)]
149pub struct InstallCommand {
150    /// Don't write lockfile after installation
151    ///
152    /// Prevents the command from creating or updating the `agpm.lock` file.
153    /// This is useful for development scenarios where you don't want to
154    /// commit lockfile changes.
155    #[arg(long)]
156    pub no_lock: bool,
157
158    /// Verify checksums from existing lockfile
159    ///
160    /// Uses the existing lockfile as-is without updating dependencies.
161    /// This mode ensures reproducible installations and is recommended
162    /// for CI/CD pipelines and production deployments.
163    #[arg(long)]
164    pub frozen: bool,
165
166    /// Don't use cache, clone fresh repositories
167    ///
168    /// Disables the local Git repository cache and clones repositories
169    /// to temporary locations. This increases installation time but ensures
170    /// completely fresh downloads.
171    #[arg(long)]
172    pub no_cache: bool,
173
174    /// Maximum number of parallel operations (default: max(10, 2 × CPU cores))
175    ///
176    /// Controls the level of parallelism during installation. The default value
177    /// is calculated as `max(10, 2 × CPU cores)` to provide good performance
178    /// while avoiding resource exhaustion. Higher values can speed up installation
179    /// of many dependencies but may strain system resources or hit API rate limits.
180    ///
181    /// # Performance Impact
182    ///
183    /// - **Low values (1-4)**: Conservative approach, slower but more reliable
184    /// - **Default values (10-16)**: Balanced performance for most systems
185    /// - **High values (>20)**: May overwhelm system resources or trigger rate limits
186    ///
187    /// # Examples
188    ///
189    /// - `--max-parallel 1`: Sequential installation (debugging)
190    /// - `--max-parallel 4`: Conservative parallel installation
191    /// - `--max-parallel 20`: Aggressive parallel installation (powerful systems)
192    #[arg(long, value_name = "NUM")]
193    pub max_parallel: Option<usize>,
194
195    /// Suppress non-essential output
196    ///
197    /// When enabled, only errors and essential information will be printed.
198    /// Progress bars and status messages will be hidden.
199    #[arg(short, long)]
200    pub quiet: bool,
201
202    /// Disable progress bars (for programmatic use, not exposed as CLI arg)
203    #[arg(skip)]
204    pub no_progress: bool,
205
206    /// Enable verbose output (for programmatic use, not exposed as CLI arg)
207    ///
208    /// This flag is populated from the global --verbose flag via execute_with_config
209    #[arg(skip)]
210    pub verbose: bool,
211
212    /// Don't resolve transitive dependencies
213    ///
214    /// When enabled, only direct dependencies from the manifest will be installed.
215    /// Transitive dependencies declared within resource files (via YAML frontmatter
216    /// or JSON fields) will be ignored. This can be useful for faster installations
217    /// when you know transitive dependencies are already satisfied or for debugging
218    /// dependency issues.
219    #[arg(long)]
220    pub no_transitive: bool,
221
222    /// Preview installation without making changes
223    ///
224    /// Shows what would be installed, including new dependencies and lockfile changes,
225    /// but doesn't modify any files. Useful for reviewing changes before applying them,
226    /// especially in CI/CD pipelines to detect when dependencies would change.
227    ///
228    /// When enabled:
229    /// - Resolves all dependencies normally
230    /// - Shows what resources would be installed
231    /// - Shows lockfile changes (new entries, version updates)
232    /// - Does NOT write the lockfile
233    /// - Does NOT install any resources
234    /// - Does NOT update .gitignore
235    ///
236    /// Exit codes:
237    /// - 0: No changes would be made
238    /// - 1: Changes would be made (useful for CI checks)
239    #[arg(long)]
240    pub dry_run: bool,
241}
242
243impl Default for InstallCommand {
244    fn default() -> Self {
245        Self::new()
246    }
247}
248
249impl InstallCommand {
250    /// Creates a default `InstallCommand` for programmatic use.
251    ///
252    /// This constructor creates an `InstallCommand` with standard settings:
253    /// - Lockfile generation enabled
254    /// - Fresh dependency resolution (not frozen)
255    /// - Cache enabled for performance
256    /// - Default parallelism (max(10, 2 × CPU cores))
257    /// - Progress output enabled
258    ///
259    /// # Examples
260    ///
261    /// ```rust,ignore
262    /// use agpm_cli::cli::install::InstallCommand;
263    ///
264    /// let cmd = InstallCommand::new();
265    /// // cmd can now be executed with execute_from_path()
266    /// ```
267    #[allow(dead_code)]
268    pub const fn new() -> Self {
269        Self {
270            no_lock: false,
271            frozen: false,
272            no_cache: false,
273            max_parallel: None,
274            quiet: false,
275            no_progress: false,
276            verbose: false,
277            no_transitive: false,
278            dry_run: false,
279        }
280    }
281
282    /// Creates an `InstallCommand` configured for quiet operation.
283    ///
284    /// This constructor creates an `InstallCommand` with quiet mode enabled,
285    /// which suppresses progress bars and non-essential output. Useful for
286    /// automated scripts or CI/CD environments where minimal output is desired.
287    ///
288    /// # Examples
289    ///
290    /// ```rust,ignore
291    /// use agpm_cli::cli::install::InstallCommand;
292    ///
293    /// let cmd = InstallCommand::new_quiet();
294    /// // cmd will execute without progress bars or status messages
295    /// ```
296    #[allow(dead_code)]
297    pub const fn new_quiet() -> Self {
298        Self {
299            no_lock: false,
300            frozen: false,
301            no_cache: false,
302            max_parallel: None,
303            quiet: true,
304            no_progress: true,
305            verbose: false,
306            no_transitive: false,
307            dry_run: false,
308        }
309    }
310
311    /// Executes the install command with automatic manifest discovery.
312    ///
313    /// This method provides convenient manifest file discovery, searching for
314    /// `agpm.toml` in the current directory and parent directories if no specific
315    /// path is provided. It's the standard entry point for CLI usage.
316    ///
317    /// # Arguments
318    ///
319    /// * `manifest_path` - Optional explicit path to `agpm.toml`. If `None`,
320    ///   the method searches for `agpm.toml` starting from the current directory
321    ///   and walking up the directory tree.
322    ///
323    /// # Manifest Discovery
324    ///
325    /// When `manifest_path` is `None`, the search process:
326    /// 1. Checks current directory for `agpm.toml`
327    /// 2. Walks up parent directories until `agpm.toml` is found
328    /// 3. Stops at filesystem root if no manifest found
329    /// 4. Returns an error with helpful guidance if no manifest exists
330    ///
331    /// # Examples
332    ///
333    /// ```rust,ignore
334    /// use agpm_cli::cli::install::InstallCommand;
335    /// use std::path::PathBuf;
336    ///
337    /// # async fn example() -> anyhow::Result<()> {
338    /// let cmd = InstallCommand::new();
339    ///
340    /// // Auto-discover manifest in current directory or parents
341    /// cmd.execute_with_manifest_path(None).await?;
342    ///
343    /// // Use specific manifest file
344    /// cmd.execute_with_manifest_path(Some(PathBuf::from("./my-project/agpm.toml"))).await?;
345    /// # Ok(())
346    /// # }
347    /// ```
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if:
352    /// - No `agpm.toml` file found in search path
353    /// - Specified manifest path doesn't exist
354    /// - Manifest file contains invalid TOML syntax
355    /// - Dependencies cannot be resolved
356    /// - Installation process fails
357    ///
358    /// # Error Messages
359    ///
360    /// When no manifest is found, the error includes helpful guidance:
361    /// ```text
362    /// No agpm.toml found in current directory or any parent directory.
363    ///
364    /// To get started, create a agpm.toml file with your dependencies:
365    ///
366    /// [sources]
367    /// official = "https://github.com/example-org/agpm-official.git"
368    ///
369    /// [agents]
370    /// my-agent = { source = "official", path = "agents/my-agent.md", version = "v1.0.0" }
371    /// ```
372    pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
373        // Find manifest file
374        let manifest_path = if let Ok(path) = find_manifest_with_optional(manifest_path) {
375            path
376        } else {
377            // Check if legacy CCPM files exist and offer interactive migration
378            match crate::cli::common::handle_legacy_ccpm_migration().await {
379                Ok(Some(path)) => path,
380                Ok(None) => {
381                    return Err(anyhow::anyhow!(
382                        "No agpm.toml found in current directory or any parent directory.\n\n\
383                        To get started, create a agpm.toml file with your dependencies:\n\n\
384                        [sources]\n\
385                        official = \"https://github.com/example-org/agpm-official.git\"\n\n\
386                        [agents]\n\
387                        my-agent = {{ source = \"official\", path = \"agents/my-agent.md\", version = \"v1.0.0\" }}"
388                    ));
389                }
390                Err(e) => return Err(e),
391            }
392        };
393
394        self.execute_from_path(Some(&manifest_path)).await
395    }
396
397    pub async fn execute_from_path(&self, path: Option<&Path>) -> Result<()> {
398        use crate::installer::{ResourceFilter, install_resources};
399        use crate::manifest::Manifest;
400        use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
401        use std::sync::Arc;
402
403        let manifest_path = if let Some(p) = path {
404            p.to_path_buf()
405        } else {
406            std::env::current_dir()?.join("agpm.toml")
407        };
408
409        if !manifest_path.exists() {
410            return Err(anyhow::anyhow!("No agpm.toml found at {}", manifest_path.display()));
411        }
412
413        let (manifest, _patch_conflicts) = Manifest::load_with_private(&manifest_path)?;
414
415        // Note: Private patches silently override project patches when they conflict.
416        // This allows users to customize their local configuration without modifying
417        // the team-wide project configuration.
418
419        // Create command context for using enhanced lockfile loading
420        let project_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
421        let command_context =
422            crate::cli::common::CommandContext::new(manifest.clone(), project_dir.to_path_buf())?;
423
424        // In --frozen mode, check for corruption and security issues only
425        let lockfile_path = project_dir.join("agpm.lock");
426
427        if self.frozen && lockfile_path.exists() {
428            // In frozen mode, we should NOT regenerate - fail hard if lockfile is invalid
429            match LockFile::load(&lockfile_path) {
430                Ok(lockfile) => {
431                    if let Some(reason) = lockfile.validate_against_manifest(&manifest, false)? {
432                        return Err(anyhow::anyhow!(
433                            "Lockfile has critical issues in --frozen mode:\n\n\
434                             {reason}\n\n\
435                             Hint: Fix the issue or remove --frozen flag."
436                        ));
437                    }
438                }
439                Err(e) => {
440                    // In frozen mode, provide enhanced error message with beta notice
441                    return Err(anyhow::anyhow!(
442                        "Cannot proceed in --frozen mode due to invalid lockfile.\n\n\
443                         Error: {}\n\n\
444                         In --frozen mode, the lockfile must be valid.\n\
445                         Fix the lockfile manually or remove the --frozen flag to allow regeneration.\n\n\
446                         Note: The lockfile format is not yet stable as this is beta software.",
447                        e
448                    ));
449                }
450            }
451        }
452        let total_deps = manifest.all_dependencies().len();
453
454        // Initialize multi-phase progress for all progress tracking
455        let multi_phase = Arc::new(MultiPhaseProgress::new(!self.quiet && !self.no_progress));
456
457        // Show initial status
458
459        let actual_project_dir =
460            manifest_path.parent().ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?;
461
462        // Check for existing lockfile
463        let lockfile_path = actual_project_dir.join("agpm.lock");
464
465        // Use enhanced lockfile loading with automatic regeneration for non-frozen mode
466        let existing_lockfile = if !self.frozen {
467            command_context.load_lockfile_with_regeneration(true, "install")?
468        } else {
469            // In frozen mode, use the original loading logic (already validated above)
470            if lockfile_path.exists() {
471                Some(LockFile::load(&lockfile_path)?)
472            } else {
473                None
474            }
475        };
476
477        // Initialize cache (always needed now, even with --no-cache)
478        let cache = Cache::new()?;
479
480        // Resolution phase
481        let mut resolver =
482            DependencyResolver::new_with_global(manifest.clone(), cache.clone()).await?;
483
484        // Create operation context for warning deduplication
485        let operation_context = Arc::new(OperationContext::new());
486        resolver.set_operation_context(operation_context);
487
488        // Pre-sync sources phase (if not frozen and we have remote deps)
489        let has_remote_deps =
490            manifest.all_dependencies().iter().any(|(_, dep)| dep.get_source().is_some());
491
492        if !self.frozen && has_remote_deps {
493            // Start syncing sources phase
494            if !self.quiet && !self.no_progress {
495                multi_phase.start_phase(InstallationPhase::SyncingSources, None);
496            }
497
498            // Get all dependencies for pre-syncing (filtering out disabled tools)
499            let deps: Vec<(String, ResourceDependency)> = manifest
500                .all_dependencies_with_types()
501                .into_iter()
502                .map(|(name, dep, _resource_type)| (name.to_string(), dep.into_owned()))
503                .collect();
504
505            // Pre-sync all required sources (performs actual Git operations)
506            resolver.pre_sync_sources(&deps).await?;
507
508            // Complete syncing sources phase
509            if !self.quiet && !self.no_progress {
510                multi_phase.complete_phase(Some("Sources synced"));
511            }
512        }
513
514        let mut lockfile = if let Some(existing) = existing_lockfile {
515            if self.frozen {
516                // Use existing lockfile as-is
517                if !self.quiet {
518                    println!("✓ Using frozen lockfile ({total_deps} dependencies)");
519                }
520                existing
521            } else {
522                // Start resolving phase
523                if !self.quiet && !self.no_progress && total_deps > 0 {
524                    multi_phase.start_phase(InstallationPhase::ResolvingDependencies, None);
525                }
526
527                // Update lockfile with any new dependencies
528                let result = resolver.update(&existing, None).await?;
529
530                // Complete resolving phase
531                if !self.quiet && !self.no_progress && total_deps > 0 {
532                    multi_phase
533                        .complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
534                }
535
536                result
537            }
538        } else {
539            // Start resolving phase
540            if !self.quiet && !self.no_progress && total_deps > 0 {
541                multi_phase.start_phase(InstallationPhase::ResolvingDependencies, None);
542            }
543
544            // Fresh resolution
545            let result = resolver.resolve_with_options(!self.no_transitive).await?;
546
547            // Complete resolving phase
548            if !self.quiet && !self.no_progress && total_deps > 0 {
549                multi_phase.complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
550            }
551
552            result
553        };
554
555        // Check for tag movement if we have both old and new lockfiles (skip in frozen mode)
556        let old_lockfile = if !self.frozen && lockfile_path.exists() {
557            // Load the old lockfile for comparison
558            if let Ok(old) = LockFile::load(&lockfile_path) {
559                detect_tag_movement(&old, &lockfile, self.quiet);
560                Some(old)
561            } else {
562                None
563            }
564        } else {
565            None
566        };
567
568        // Handle dry-run mode: show what would be installed without making changes
569        if self.dry_run {
570            return crate::cli::common::display_dry_run_results(
571                &lockfile,
572                old_lockfile.as_ref(),
573                self.quiet,
574            );
575        }
576
577        let total_resources = ResourceIterator::count_total_resources(&lockfile);
578
579        // Track installation error to return later
580        let mut installation_error = None;
581
582        // Track counts for finalizing phase
583        let mut hook_count = 0;
584        let mut server_count = 0;
585
586        let installed_count = if total_resources == 0 {
587            0
588        } else {
589            // Start installation phase
590            if !self.quiet && !self.no_progress {
591                multi_phase.start_phase(
592                    InstallationPhase::Installing,
593                    Some(&format!("({total_resources} resources)")),
594                );
595            }
596
597            let max_concurrency = self.max_parallel.unwrap_or_else(|| {
598                let cores =
599                    std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(4);
600                std::cmp::max(10, cores * 2)
601            });
602
603            // Install resources using the main installation function
604            // We need to wrap in Arc for the call, but we'll apply updates to the mutable version
605            let lockfile_for_install = Arc::new(lockfile.clone());
606            match install_resources(
607                ResourceFilter::All,
608                &lockfile_for_install,
609                &manifest,
610                actual_project_dir,
611                cache.clone(),
612                self.no_cache,
613                Some(max_concurrency),
614                Some(multi_phase.clone()),
615                self.verbose,
616                old_lockfile.as_ref(), // Pass old lockfile for early-exit optimization
617            )
618            .await
619            {
620                Ok(results) => {
621                    // Apply installation results to lockfile
622                    lockfile.apply_installation_results(
623                        results.checksums,
624                        results.context_checksums,
625                        results.applied_patches,
626                    );
627
628                    results.installed_count
629                }
630                Err(e) => {
631                    // Save the error to return immediately - don't continue with hooks/mcp/gitignore
632                    installation_error = Some(e);
633                    0
634                }
635            }
636        };
637
638        // Only proceed with hooks, MCP, and finalization if installation succeeded
639        if installation_error.is_none() {
640            // Start finalizing phase
641            if !self.quiet && !self.no_progress && installed_count > 0 {
642                multi_phase.start_phase(InstallationPhase::Finalizing, None);
643            }
644
645            // Call shared finalization function
646            let (hook_count_result, server_count_result) = crate::installer::finalize_installation(
647                &mut lockfile,
648                &manifest,
649                actual_project_dir,
650                &cache,
651                old_lockfile.as_ref(),
652                self.quiet,
653                self.no_lock,
654            )
655            .await?;
656
657            hook_count = hook_count_result;
658            server_count = server_count_result;
659
660            // Complete finalizing phase
661            if !self.quiet && !self.no_progress && installed_count > 0 {
662                multi_phase.complete_phase(Some("Installation finalized"));
663            }
664        }
665
666        // Return the installation error if there was one
667        if let Some(error) = installation_error {
668            return Err(error);
669        }
670
671        // Only show "no dependencies" message if nothing was installed AND no progress shown
672        if self.no_progress
673            && !self.quiet
674            && installed_count == 0
675            && hook_count == 0
676            && server_count == 0
677        {
678            crate::cli::common::display_no_changes(
679                crate::cli::common::OperationMode::Install,
680                self.quiet,
681            );
682        }
683
684        Ok(())
685    }
686}
687
688/// Detects if any tags have moved between the old and new lockfiles.
689///
690/// Tags in Git are supposed to be immutable, so if a tag points to a different
691/// commit than before, this is potentially problematic and worth warning about.
692///
693/// Branches are expected to move, so we don't warn about those.
694fn detect_tag_movement(old_lockfile: &LockFile, new_lockfile: &LockFile, quiet: bool) {
695    use crate::core::ResourceType;
696
697    // Helper function to check if a version looks like a tag (not a branch or SHA)
698    fn is_tag_like(version: &str) -> bool {
699        // Skip if it looks like a SHA
700        if version.len() >= 7 && version.chars().all(|c| c.is_ascii_hexdigit()) {
701            return false;
702        }
703
704        // Skip if it's a known branch name
705        if matches!(
706            version,
707            "main" | "master" | "develop" | "dev" | "staging" | "production" | "HEAD"
708        ) || version.starts_with("release/")
709            || version.starts_with("feature/")
710            || version.starts_with("hotfix/")
711            || version.starts_with("bugfix/")
712        {
713            return false;
714        }
715
716        // Likely a tag if it starts with 'v' or looks like a version
717        version.starts_with('v')
718            || version.starts_with("release-")
719            || version.parse::<semver::Version>().is_ok()
720            || version.contains('.') // Likely a version number
721    }
722
723    // Helper to check resources of a specific type
724    fn check_resources(
725        old_resources: &[crate::lockfile::LockedResource],
726        new_resources: &[crate::lockfile::LockedResource],
727        resource_type: ResourceType,
728        quiet: bool,
729    ) {
730        for new_resource in new_resources {
731            // Skip if no version or resolved commit
732            let Some(ref new_version) = new_resource.version else {
733                continue;
734            };
735            let Some(ref new_commit) = new_resource.resolved_commit else {
736                continue;
737            };
738
739            // Skip if not a tag
740            if !is_tag_like(new_version) {
741                continue;
742            }
743
744            // Find the corresponding old resource
745            if let Some(old_resource) =
746                old_resources.iter().find(|r| r.display_name() == new_resource.display_name())
747                && let (Some(old_version), Some(old_commit)) =
748                    (&old_resource.version, &old_resource.resolved_commit)
749            {
750                // Check if the same tag now points to a different commit
751                if old_version == new_version && old_commit != new_commit && !quiet {
752                    eprintln!(
753                        "⚠️  Warning: Tag '{}' for {} '{}' has moved from {} to {}",
754                        new_version,
755                        resource_type,
756                        new_resource.display_name(),
757                        &old_commit[..8.min(old_commit.len())],
758                        &new_commit[..8.min(new_commit.len())]
759                    );
760                    eprintln!(
761                        "   Tags should be immutable. This may indicate the upstream repository force-pushed the tag."
762                    );
763                }
764            }
765        }
766    }
767
768    // Check all resource types
769    check_resources(&old_lockfile.agents, &new_lockfile.agents, ResourceType::Agent, quiet);
770    check_resources(&old_lockfile.snippets, &new_lockfile.snippets, ResourceType::Snippet, quiet);
771    check_resources(&old_lockfile.commands, &new_lockfile.commands, ResourceType::Command, quiet);
772    check_resources(&old_lockfile.scripts, &new_lockfile.scripts, ResourceType::Script, quiet);
773    check_resources(&old_lockfile.hooks, &new_lockfile.hooks, ResourceType::Hook, quiet);
774    check_resources(
775        &old_lockfile.mcp_servers,
776        &new_lockfile.mcp_servers,
777        ResourceType::McpServer,
778        quiet,
779    );
780}
781
782#[cfg(test)]
783mod tests {
784    use super::*;
785    use crate::lockfile::{LockFile, LockedResource};
786    use crate::manifest::{DetailedDependency, Manifest, ResourceDependency};
787
788    use std::fs;
789    use tempfile::TempDir;
790
791    #[tokio::test]
792    async fn test_install_command_no_manifest() {
793        let temp = TempDir::new().unwrap();
794        let manifest_path = temp.path().join("agpm.toml");
795
796        let cmd = InstallCommand::new();
797        let result = cmd.execute_from_path(Some(&manifest_path)).await;
798        assert!(result.is_err());
799        assert!(result.unwrap_err().to_string().contains("agpm.toml"));
800    }
801
802    #[tokio::test]
803    async fn test_install_with_empty_manifest() {
804        let temp = TempDir::new().unwrap();
805        let manifest_path = temp.path().join("agpm.toml");
806        Manifest::new().save(&manifest_path).unwrap();
807
808        let cmd = InstallCommand::new();
809        let result = cmd.execute_from_path(Some(&manifest_path)).await;
810        assert!(result.is_ok());
811
812        let lockfile_path = temp.path().join("agpm.lock");
813        assert!(lockfile_path.exists());
814        let lockfile = LockFile::load(&lockfile_path).unwrap();
815        assert!(lockfile.agents.is_empty());
816        assert!(lockfile.snippets.is_empty());
817    }
818
819    #[tokio::test]
820    async fn test_install_command_new_defaults() {
821        let cmd = InstallCommand::new();
822        assert!(!cmd.no_lock);
823        assert!(!cmd.frozen);
824        assert!(!cmd.no_cache);
825        assert!(cmd.max_parallel.is_none());
826        assert!(!cmd.quiet);
827    }
828
829    #[tokio::test]
830    async fn test_install_respects_no_lock_flag() {
831        let temp = TempDir::new().unwrap();
832        let manifest_path = temp.path().join("agpm.toml");
833        Manifest::new().save(&manifest_path).unwrap();
834
835        let cmd = InstallCommand {
836            no_lock: true,
837            frozen: false,
838            no_cache: false,
839            max_parallel: None,
840            quiet: false,
841            no_progress: false,
842            verbose: false,
843            no_transitive: false,
844            dry_run: false,
845        };
846
847        let result = cmd.execute_from_path(Some(&manifest_path)).await;
848        assert!(result.is_ok());
849        assert!(!temp.path().join("agpm.lock").exists());
850    }
851
852    #[tokio::test]
853    async fn test_install_with_local_dependency() {
854        let temp = TempDir::new().unwrap();
855        let manifest_path = temp.path().join("agpm.toml");
856        let local_file = temp.path().join("local-agent.md");
857        fs::write(
858            &local_file,
859            "# Local Agent
860This is a test agent.",
861        )
862        .unwrap();
863
864        let mut manifest = Manifest::new();
865        manifest.agents.insert(
866            "local-agent".into(),
867            ResourceDependency::Detailed(Box::new(DetailedDependency {
868                source: None,
869                path: "local-agent.md".into(),
870                version: None,
871                branch: None,
872                rev: None,
873                command: None,
874                args: None,
875                target: None,
876                filename: None,
877                dependencies: None,
878                tool: Some("claude-code".to_string()),
879                flatten: None,
880                install: None,
881
882                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
883            })),
884        );
885        manifest.save(&manifest_path).unwrap();
886
887        let cmd = InstallCommand::new();
888        let result = cmd.execute_from_path(Some(&manifest_path)).await;
889        assert!(result.is_ok());
890        assert!(temp.path().join(".claude/agents/local-agent.md").exists());
891    }
892
893    #[tokio::test]
894    async fn test_install_with_invalid_manifest_syntax() {
895        let temp = TempDir::new().unwrap();
896        let manifest_path = temp.path().join("agpm.toml");
897        fs::write(&manifest_path, "[invalid toml").unwrap();
898
899        let cmd = InstallCommand::new();
900        let err = cmd.execute_from_path(Some(temp.path())).await.unwrap_err();
901        // The actual error will be about parsing the invalid TOML
902        let err_str = err.to_string();
903        assert!(
904            err_str.contains("File operation failed")
905                || err_str.contains("Failed reading file")
906                || err_str.contains("Cannot read manifest")
907                || err_str.contains("unclosed")
908                || err_str.contains("parse")
909                || err_str.contains("expected")
910                || err_str.contains("invalid"),
911            "Unexpected error message: {}",
912            err_str
913        );
914    }
915
916    #[tokio::test]
917    async fn test_install_uses_existing_lockfile_when_frozen() {
918        let temp = TempDir::new().unwrap();
919        let manifest_path = temp.path().join("agpm.toml");
920        let lockfile_path = temp.path().join("agpm.lock");
921
922        let local_file = temp.path().join("test-agent.md");
923        fs::write(
924            &local_file,
925            "# Test Agent
926Body",
927        )
928        .unwrap();
929
930        let mut manifest = Manifest::new();
931        manifest.agents.insert(
932            "test-agent".into(),
933            ResourceDependency::Detailed(Box::new(DetailedDependency {
934                source: None,
935                path: "test-agent.md".into(),
936                version: None,
937                branch: None,
938                rev: None,
939                command: None,
940                args: None,
941                target: None,
942                filename: None,
943                dependencies: None,
944                tool: Some("claude-code".to_string()),
945                flatten: None,
946                install: None,
947
948                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
949            })),
950        );
951        manifest.save(&manifest_path).unwrap();
952
953        LockFile {
954            version: 1,
955            sources: vec![],
956            commands: vec![],
957            agents: vec![LockedResource {
958                name: "test-agent".into(),
959                source: None,
960                url: None,
961                path: "test-agent.md".into(),
962                version: None,
963                resolved_commit: None,
964                checksum: String::new(),
965                installed_at: ".claude/agents/test-agent.md".into(),
966                dependencies: vec![],
967                resource_type: crate::core::ResourceType::Agent,
968                tool: Some("claude-code".to_string()),
969                manifest_alias: None,
970                context_checksum: None,
971                applied_patches: std::collections::BTreeMap::new(),
972                install: None,
973                variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
974            }],
975            snippets: vec![],
976            mcp_servers: vec![],
977            scripts: vec![],
978            hooks: vec![],
979        }
980        .save(&lockfile_path)
981        .unwrap();
982
983        let cmd = InstallCommand {
984            no_lock: false,
985            frozen: true,
986            no_cache: false,
987            max_parallel: None,
988            quiet: false,
989            no_progress: false,
990            verbose: false,
991            no_transitive: false,
992            dry_run: false,
993        };
994
995        let result = cmd.execute_from_path(Some(&manifest_path)).await;
996        assert!(result.is_ok());
997        assert!(temp.path().join(".claude/agents/test-agent.md").exists());
998    }
999
1000    #[tokio::test]
1001    async fn test_install_errors_when_local_file_missing() {
1002        let temp = TempDir::new().unwrap();
1003        let manifest_path = temp.path().join("agpm.toml");
1004
1005        let mut manifest = Manifest::new();
1006        manifest.agents.insert(
1007            "missing".into(),
1008            ResourceDependency::Detailed(Box::new(DetailedDependency {
1009                source: None,
1010                path: "missing.md".into(),
1011                version: None,
1012                branch: None,
1013                rev: None,
1014                command: None,
1015                args: None,
1016                target: None,
1017                filename: None,
1018                dependencies: None,
1019                tool: Some("claude-code".to_string()),
1020                flatten: None,
1021                install: None,
1022
1023                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1024            })),
1025        );
1026        manifest.save(&manifest_path).unwrap();
1027
1028        let err = InstallCommand::new().execute_from_path(Some(&manifest_path)).await.unwrap_err();
1029        let err_string = err.to_string();
1030        // After converting warnings to errors, missing local files fail with resource fetch error
1031        assert!(
1032            err_string.contains("Failed to fetch resource")
1033                || err_string.contains("local file")
1034                || err_string.contains("Failed to install 1 resources:"),
1035            "Error should indicate resource fetch failure, got: {}",
1036            err_string
1037        );
1038    }
1039
1040    #[tokio::test]
1041    async fn test_install_single_resource_paths() {
1042        let temp = TempDir::new().unwrap();
1043        let manifest_path = temp.path().join("agpm.toml");
1044        let snippet_file = temp.path().join("single-snippet.md");
1045        fs::write(
1046            &snippet_file,
1047            "# Snippet
1048Body",
1049        )
1050        .unwrap();
1051
1052        let mut manifest = Manifest::new();
1053        manifest.snippets.insert(
1054            "single".into(),
1055            ResourceDependency::Detailed(Box::new(DetailedDependency {
1056                source: None,
1057                path: "single-snippet.md".into(),
1058                version: None,
1059                branch: None,
1060                rev: None,
1061                command: None,
1062                args: None,
1063                target: None,
1064                filename: None,
1065                dependencies: None,
1066                tool: Some("claude-code".to_string()),
1067                flatten: None,
1068                install: None,
1069
1070                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1071            })),
1072        );
1073        manifest.save(&manifest_path).unwrap();
1074
1075        let cmd = InstallCommand::new();
1076        assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
1077
1078        let lockfile = LockFile::load(&temp.path().join("agpm.lock")).unwrap();
1079        assert_eq!(lockfile.snippets.len(), 1);
1080        let installed_path = temp.path().join(&lockfile.snippets[0].installed_at);
1081        assert!(installed_path.exists());
1082    }
1083
1084    #[tokio::test]
1085    async fn test_install_single_command_resource() {
1086        let temp = TempDir::new().unwrap();
1087        let manifest_path = temp.path().join("agpm.toml");
1088        let command_file = temp.path().join("single-command.md");
1089        fs::write(
1090            &command_file,
1091            "# Command
1092Body",
1093        )
1094        .unwrap();
1095
1096        let mut manifest = Manifest::new();
1097        manifest.commands.insert(
1098            "cmd".into(),
1099            ResourceDependency::Detailed(Box::new(DetailedDependency {
1100                source: None,
1101                path: "single-command.md".into(),
1102                version: None,
1103                branch: None,
1104                rev: None,
1105                command: None,
1106                args: None,
1107                target: None,
1108                filename: None,
1109                dependencies: None,
1110                tool: Some("claude-code".to_string()),
1111                flatten: None,
1112                install: None,
1113
1114                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1115            })),
1116        );
1117        manifest.save(&manifest_path).unwrap();
1118
1119        let cmd = InstallCommand::new();
1120        assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
1121
1122        let lockfile = LockFile::load(&temp.path().join("agpm.lock")).unwrap();
1123        assert_eq!(lockfile.commands.len(), 1);
1124        assert!(temp.path().join(&lockfile.commands[0].installed_at).exists());
1125    }
1126
1127    #[tokio::test]
1128    async fn test_install_dry_run_mode() {
1129        let temp = TempDir::new().unwrap();
1130        let manifest_path = temp.path().join("agpm.toml");
1131        let lockfile_path = temp.path().join("agpm.lock");
1132        let agent_file = temp.path().join("test-agent.md");
1133
1134        // Create a local file for the agent
1135        fs::write(&agent_file, "# Test Agent\nBody").unwrap();
1136
1137        let mut manifest = Manifest::new();
1138        manifest.agents.insert(
1139            "test-agent".into(),
1140            ResourceDependency::Detailed(Box::new(DetailedDependency {
1141                source: None,
1142                path: "test-agent.md".into(),
1143                version: None,
1144                branch: None,
1145                rev: None,
1146                command: None,
1147                args: None,
1148                target: None,
1149                filename: None,
1150                dependencies: None,
1151                tool: Some("claude-code".to_string()),
1152                flatten: None,
1153                install: None,
1154
1155                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1156            })),
1157        );
1158        manifest.save(&manifest_path).unwrap();
1159
1160        let cmd = InstallCommand {
1161            no_lock: false,
1162            frozen: false,
1163            no_cache: false,
1164            max_parallel: None,
1165            quiet: true, // Suppress output in test
1166            no_progress: true,
1167            verbose: false,
1168            no_transitive: false,
1169            dry_run: true,
1170        };
1171
1172        // In dry-run mode, this should return an error indicating changes would be made
1173        let result = cmd.execute_from_path(Some(&manifest_path)).await;
1174
1175        // Should return an error because changes would be made
1176        assert!(result.is_err());
1177        let err_msg = result.unwrap_err().to_string();
1178        assert!(err_msg.contains("Dry-run detected changes"));
1179
1180        // Lockfile should NOT be created in dry-run mode
1181        assert!(!lockfile_path.exists());
1182        // Resource should NOT be installed
1183        assert!(!temp.path().join(".claude/agents/test-agent.md").exists());
1184    }
1185
1186    #[tokio::test]
1187    async fn test_install_summary_with_mcp_servers() {
1188        let temp = TempDir::new().unwrap();
1189        let manifest_path = temp.path().join("agpm.toml");
1190        let agent_file = temp.path().join("summary-agent.md");
1191        fs::write(&agent_file, "# Agent\nBody").unwrap();
1192
1193        let mcp_dir = temp.path().join("mcp");
1194        fs::create_dir_all(&mcp_dir).unwrap();
1195        fs::write(mcp_dir.join("test-mcp.json"), "{\"name\":\"test\"}").unwrap();
1196
1197        let mut manifest = Manifest::new();
1198        manifest.agents.insert(
1199            "summary".into(),
1200            ResourceDependency::Detailed(Box::new(DetailedDependency {
1201                source: None,
1202                path: "summary-agent.md".into(),
1203                version: None,
1204                branch: None,
1205                rev: None,
1206                command: None,
1207                args: None,
1208                target: None,
1209                filename: None,
1210                dependencies: None,
1211                tool: Some("claude-code".to_string()),
1212                flatten: None,
1213                install: None,
1214
1215                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1216            })),
1217        );
1218        manifest.add_mcp_server(
1219            "test-mcp".into(),
1220            ResourceDependency::Detailed(Box::new(DetailedDependency {
1221                source: None,
1222                path: "mcp/test-mcp.json".into(),
1223                version: None,
1224                branch: None,
1225                rev: None,
1226                command: None,
1227                args: None,
1228                target: None,
1229                filename: None,
1230                dependencies: None,
1231                tool: Some("claude-code".to_string()),
1232                flatten: None,
1233                install: None,
1234
1235                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1236            })),
1237        );
1238        manifest.save(&manifest_path).unwrap();
1239
1240        let cmd = InstallCommand::new();
1241        assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
1242    }
1243}