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//!
92//! # Optimization Tiers
93//!
94//! The install command uses a tiered optimization strategy for repeated installations:
95//!
96//! 1. **Fast Path** (skip resolution): When the manifest hash matches and all dependencies
97//!    are immutable (Git-based with tags/SHAs), the entire resolution phase is skipped.
98//!    The lockfile is used directly as the installation plan.
99//!    - Triggered by: `manifest_hash` match + `has_mutable_deps = false` + valid `resource_count`
100//!    - Saves: Network fetches, version resolution, transitive dependency discovery
101//!
102//! 2. **Ultra-Fast Path** (skip checksum computation): For each resource being installed,
103//!    if all content-affecting inputs match the previous lockfile entry (commit, path,
104//!    patches, template vars) and the file exists, skip reading and hashing the file.
105//!    - Triggered by: `trust_lockfile_checksums = true` + all inputs match old entry
106//!    - Saves: File I/O, SHA-256 computation (significant for large files)
107//!
108//! 3. **Trust Mode**: Within ultra-fast path, when a resource's inputs match exactly,
109//!    the previous checksum is reused without verification. This is safe because
110//!    immutable Git dependencies (tags/SHAs) guarantee identical content.
111//!
112//! # Security Considerations
113//!
114//! Trust mode assumes:
115//! - Upstream repositories have not been compromised (tag force-push attacks)
116//! - The local cache (`~/.agpm/cache/`) has not been tampered with
117//!
118//! For security-sensitive environments, consider:
119//! - Using `--no-cache` to always fetch fresh content
120//! - Modifying the manifest to force re-resolution (e.g., bumping version)
121//! - Regularly auditing installed resources against known-good checksums
122
123use anyhow::Result;
124use clap::Args;
125use std::path::{Path, PathBuf};
126
127use crate::cache::Cache;
128use crate::constants::{FALLBACK_CORE_COUNT, MIN_PARALLELISM, PARALLELISM_CORE_MULTIPLIER};
129use crate::core::{OperationContext, ResourceIterator};
130use crate::lockfile::LockFile;
131use crate::manifest::{ResourceDependency, find_manifest_with_optional};
132use crate::resolver::DependencyResolver;
133
134/// Check if the fast path can be used to skip dependency resolution.
135///
136/// The fast path allows skipping resolution entirely when:
137/// - Not in frozen mode (frozen uses lockfile as-is, different path)
138/// - An existing lockfile exists with matching manifest hash
139/// - All dependencies are immutable (no branches or local files)
140/// - The lockfile resource count matches the stored count (integrity check)
141///
142/// # Arguments
143///
144/// * `existing_lockfile` - Optional reference to the existing lockfile
145/// * `current_manifest_hash` - Hash of the current manifest dependencies
146/// * `has_mutable_deps` - Whether the manifest has any mutable dependencies
147/// * `frozen` - Whether running in frozen mode
148///
149/// # Returns
150///
151/// `true` if fast path can be used (skip resolution), `false` otherwise.
152fn can_use_fast_path(
153    existing_lockfile: Option<&LockFile>,
154    current_manifest_hash: &str,
155    has_mutable_deps: bool,
156    frozen: bool,
157) -> bool {
158    // Frozen mode uses the lockfile as-is through a different code path
159    if frozen {
160        return false;
161    }
162
163    let Some(existing) = existing_lockfile else {
164        return false;
165    };
166
167    // Lockfile must have valid fast-path metadata (both manifest_hash and has_mutable_deps)
168    // Older lockfiles without these fields require full resolution
169    if !existing.has_valid_fast_path_metadata() {
170        tracing::debug!("Fast path disabled: lockfile missing fast-path metadata fields");
171        return false;
172    }
173
174    // Validate manifest_hash format to catch corrupted/manually edited lockfiles
175    if !existing.has_valid_manifest_hash_format() {
176        tracing::debug!("Fast path disabled: lockfile has invalid manifest_hash format");
177        return false;
178    }
179
180    // Manifest hash must match (no dependency changes)
181    // This is the primary check - if the manifest hash matches, we know the
182    // dependency specifications are identical. This includes direct deps,
183    // pattern expansions, and transitive dependency declarations.
184    let hash_matches = existing.manifest_hash.as_ref() == Some(&current_manifest_hash.to_string());
185    if !hash_matches {
186        return false;
187    }
188
189    // Both lockfile and manifest must agree on no mutable deps
190    let no_mutable_deps = existing.has_mutable_deps == Some(false) && !has_mutable_deps;
191    if !no_mutable_deps {
192        return false;
193    }
194
195    // Validate resource count matches (detects manually edited lockfiles)
196    if !existing.has_valid_resource_count() {
197        tracing::debug!(
198            "Fast path disabled: resource count mismatch (stored: {:?}, actual: {})",
199            existing.resource_count,
200            existing.all_resources().len()
201        );
202        return false;
203    }
204
205    true
206}
207
208/// Command to install Claude Code resources from manifest dependencies.
209///
210/// This command reads the project's `agpm.toml` manifest file, resolves all dependencies,
211/// and installs the resource files to the appropriate directories. It generates or updates
212/// a `agpm.lock` lockfile to ensure reproducible installations.
213///
214/// # Behavior
215///
216/// 1. Locates and loads the project manifest (`agpm.toml`)
217/// 2. Resolves dependencies using the dependency resolver
218/// 3. Downloads or updates Git repository sources as needed
219/// 4. Installs resource files to target directories
220/// 5. Generates or updates the lockfile (`agpm.lock`)
221/// 6. Provides progress feedback during installation
222///
223/// # Examples
224///
225/// ```rust,no_run
226/// use agpm_cli::cli::install::InstallCommand;
227///
228/// // Standard installation
229/// let cmd = InstallCommand {
230///     no_lock: false,
231///     frozen: false,
232///     no_cache: false,
233///     max_parallel: None,
234///     quiet: false,
235///     no_progress: false,
236///     verbose: false,
237///     no_transitive: false,
238///     dry_run: false,
239///     yes: false,
240/// };
241///
242/// // CI/Production installation (frozen lockfile)
243/// let cmd = InstallCommand {
244///     no_lock: false,
245///     frozen: true,
246///     no_cache: false,
247///     max_parallel: Some(2),
248///     quiet: false,
249///     no_progress: false,
250///     verbose: false,
251///     no_transitive: false,
252///     dry_run: false,
253///     yes: false,
254/// };
255/// ```
256#[derive(Args)]
257pub struct InstallCommand {
258    /// Don't write lockfile after installation
259    ///
260    /// Prevents the command from creating or updating the `agpm.lock` file.
261    /// This is useful for development scenarios where you don't want to
262    /// commit lockfile changes.
263    #[arg(long)]
264    pub no_lock: bool,
265
266    /// Verify checksums from existing lockfile
267    ///
268    /// Uses the existing lockfile as-is without updating dependencies.
269    /// This mode ensures reproducible installations and is recommended
270    /// for CI/CD pipelines and production deployments.
271    #[arg(long)]
272    pub frozen: bool,
273
274    /// Don't use cache, clone fresh repositories
275    ///
276    /// Disables the local Git repository cache and clones repositories
277    /// to temporary locations. This increases installation time but ensures
278    /// completely fresh downloads.
279    #[arg(long)]
280    pub no_cache: bool,
281
282    /// Maximum number of parallel operations (default: max(MIN_PARALLELISM, PARALLELISM_CORE_MULTIPLIER × CPU cores))
283    ///
284    /// Controls the level of parallelism during installation. The default value
285    /// is calculated as `max(MIN_PARALLELISM, PARALLELISM_CORE_MULTIPLIER × CPU cores)` to provide good performance
286    /// while avoiding resource exhaustion. Higher values can speed up installation
287    /// of many dependencies but may strain system resources or hit API rate limits.
288    ///
289    /// # Performance Impact
290    ///
291    /// - **Low values (1-4)**: Conservative approach, slower but more reliable
292    /// - **Default values (10-16)**: Balanced performance for most systems
293    /// - **High values (>20)**: May overwhelm system resources or trigger rate limits
294    ///
295    /// # Examples
296    ///
297    /// - `--max-parallel 1`: Sequential installation (debugging)
298    /// - `--max-parallel 4`: Conservative parallel installation
299    /// - `--max-parallel 20`: Aggressive parallel installation (powerful systems)
300    #[arg(long, value_name = "NUM")]
301    pub max_parallel: Option<usize>,
302
303    /// Suppress non-essential output
304    ///
305    /// When enabled, only errors and essential information will be printed.
306    /// Progress bars and status messages will be hidden.
307    #[arg(short, long)]
308    pub quiet: bool,
309
310    /// Disable progress bars (for programmatic use, not exposed as CLI arg)
311    #[arg(skip)]
312    pub no_progress: bool,
313
314    /// Enable verbose output (for programmatic use, not exposed as CLI arg)
315    ///
316    /// This flag is populated from the global --verbose flag via execute_with_config
317    #[arg(skip)]
318    pub verbose: bool,
319
320    /// Don't resolve transitive dependencies
321    ///
322    /// When enabled, only direct dependencies from the manifest will be installed.
323    /// Transitive dependencies declared within resource files (via YAML frontmatter
324    /// or JSON fields) will be ignored. This can be useful for faster installations
325    /// when you know transitive dependencies are already satisfied or for debugging
326    /// dependency issues.
327    #[arg(long)]
328    pub no_transitive: bool,
329
330    /// Preview installation without making changes
331    ///
332    /// Shows what would be installed, including new dependencies and lockfile changes,
333    /// but doesn't modify any files. Useful for reviewing changes before applying them,
334    /// especially in CI/CD pipelines to detect when dependencies would change.
335    ///
336    /// When enabled:
337    /// - Resolves all dependencies normally
338    /// - Shows what resources would be installed
339    /// - Shows lockfile changes (new entries, version updates)
340    /// - Does NOT write the lockfile
341    /// - Does NOT install any resources
342    ///
343    /// Exit codes:
344    /// - 0: No changes would be made
345    /// - 1: Changes would be made (useful for CI checks)
346    #[arg(long)]
347    pub dry_run: bool,
348
349    /// Automatically accept migration prompts
350    ///
351    /// When set, automatically accepts migration prompts for legacy CCPM files
352    /// or legacy AGPM format without requiring user interaction. Useful for
353    /// CI/CD pipelines and automated scripts.
354    #[arg(short = 'y', long)]
355    pub yes: bool,
356}
357
358impl Default for InstallCommand {
359    fn default() -> Self {
360        Self::new()
361    }
362}
363
364impl InstallCommand {
365    /// Creates a default `InstallCommand` for programmatic use.
366    ///
367    /// This constructor creates an `InstallCommand` with standard settings:
368    /// - Lockfile generation enabled
369    /// - Fresh dependency resolution (not frozen)
370    /// - Cache enabled for performance
371    /// - Default parallelism (see `--max-parallel` for formula)
372    /// - Progress output enabled
373    ///
374    /// # Examples
375    ///
376    /// ```rust,ignore
377    /// use agpm_cli::cli::install::InstallCommand;
378    ///
379    /// let cmd = InstallCommand::new();
380    /// // cmd can now be executed with execute_from_path()
381    /// ```
382    #[allow(dead_code)] // Used by Default impl and in tests
383    pub const fn new() -> Self {
384        Self {
385            no_lock: false,
386            frozen: false,
387            no_cache: false,
388            max_parallel: None,
389            quiet: false,
390            no_progress: false,
391            verbose: false,
392            no_transitive: false,
393            dry_run: false,
394            yes: false,
395        }
396    }
397
398    /// Creates an `InstallCommand` configured for quiet operation.
399    ///
400    /// This constructor creates an `InstallCommand` with quiet mode enabled,
401    /// which suppresses progress bars and non-essential output. Useful for
402    /// automated scripts or CI/CD environments where minimal output is desired.
403    ///
404    /// # Examples
405    ///
406    /// ```rust,ignore
407    /// use agpm_cli::cli::install::InstallCommand;
408    ///
409    /// let cmd = InstallCommand::new_quiet();
410    /// // cmd will execute without progress bars or status messages
411    /// ```
412    #[allow(dead_code)] // Used in integration tests for quiet mode testing
413    pub const fn new_quiet() -> Self {
414        Self {
415            no_lock: false,
416            frozen: false,
417            no_cache: false,
418            max_parallel: None,
419            quiet: true,
420            no_progress: true,
421            verbose: false,
422            no_transitive: false,
423            dry_run: false,
424            yes: false,
425        }
426    }
427
428    /// Executes the install command with automatic manifest discovery.
429    ///
430    /// This method provides convenient manifest file discovery, searching for
431    /// `agpm.toml` in the current directory and parent directories if no specific
432    /// path is provided. It's the standard entry point for CLI usage.
433    ///
434    /// # Arguments
435    ///
436    /// * `manifest_path` - Optional explicit path to `agpm.toml`. If `None`,
437    ///   the method searches for `agpm.toml` starting from the current directory
438    ///   and walking up the directory tree.
439    ///
440    /// # Manifest Discovery
441    ///
442    /// When `manifest_path` is `None`, the search process:
443    /// 1. Checks current directory for `agpm.toml`
444    /// 2. Walks up parent directories until `agpm.toml` is found
445    /// 3. Stops at filesystem root if no manifest found
446    /// 4. Returns an error with helpful guidance if no manifest exists
447    ///
448    /// # Examples
449    ///
450    /// ```rust,ignore
451    /// use agpm_cli::cli::install::InstallCommand;
452    /// use std::path::PathBuf;
453    ///
454    /// # async fn example() -> anyhow::Result<()> {
455    /// let cmd = InstallCommand::new();
456    ///
457    /// // Auto-discover manifest in current directory or parents
458    /// cmd.execute_with_manifest_path(None).await?;
459    ///
460    /// // Use specific manifest file
461    /// cmd.execute_with_manifest_path(Some(PathBuf::from("./my-project/agpm.toml"))).await?;
462    /// # Ok(())
463    /// # }
464    /// ```
465    ///
466    /// # Errors
467    ///
468    /// Returns an error if:
469    /// - No `agpm.toml` file found in search path
470    /// - Specified manifest path doesn't exist
471    /// - Manifest file contains invalid TOML syntax
472    /// - Dependencies cannot be resolved
473    /// - Installation process fails
474    ///
475    /// # Error Messages
476    ///
477    /// When no manifest is found, the error includes helpful guidance:
478    /// ```text
479    /// No agpm.toml found in current directory or any parent directory.
480    ///
481    /// To get started, create a agpm.toml file with your dependencies:
482    ///
483    /// [sources]
484    /// official = "https://github.com/example-org/agpm-official.git"
485    ///
486    /// [agents]
487    /// my-agent = { source = "official", path = "agents/my-agent.md", version = "v1.0.0" }
488    /// ```
489    pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
490        // Find manifest file
491        let manifest_path = if let Ok(path) = find_manifest_with_optional(manifest_path) {
492            path
493        } else {
494            // Check if legacy CCPM files exist and offer interactive migration
495            match crate::cli::common::handle_legacy_ccpm_migration(None, self.yes).await {
496                Ok(Some(path)) => path,
497                Ok(None) => {
498                    return Err(anyhow::anyhow!(
499                        "No agpm.toml found in current directory or any parent directory.\n\n\
500                        To get started, create a agpm.toml file with your dependencies:\n\n\
501                        [sources]\n\
502                        official = \"https://github.com/example-org/agpm-official.git\"\n\n\
503                        [agents]\n\
504                        my-agent = {{ source = \"official\", path = \"agents/my-agent.md\", version = \"v1.0.0\" }}"
505                    ));
506                }
507                Err(e) => return Err(e),
508            }
509        };
510
511        self.execute_from_path(Some(&manifest_path)).await
512    }
513
514    pub async fn execute_from_path(&self, path: Option<&Path>) -> Result<()> {
515        use crate::installer::{ResourceFilter, install_resources};
516        use crate::manifest::Manifest;
517        use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
518        use std::sync::Arc;
519
520        let manifest_path = if let Some(p) = path {
521            p.to_path_buf()
522        } else {
523            std::env::current_dir()?.join("agpm.toml")
524        };
525
526        if !manifest_path.exists() {
527            return Err(anyhow::anyhow!("No agpm.toml found at {}", manifest_path.display()));
528        }
529
530        let (mut manifest, _patch_conflicts) = Manifest::load_with_private(&manifest_path)?;
531
532        // Note: Private patches silently override project patches when they conflict.
533        // This allows users to customize their local configuration without modifying
534        // the team-wide project configuration.
535
536        // Create command context for using enhanced lockfile loading
537        let project_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
538        let mut command_context =
539            crate::cli::common::CommandContext::new(manifest.clone(), project_dir.to_path_buf())?;
540
541        // In --frozen mode, check for corruption and security issues only
542        let lockfile_path = project_dir.join("agpm.lock");
543
544        if self.frozen && lockfile_path.exists() {
545            // In frozen mode, we should NOT regenerate - fail hard if lockfile is invalid
546            match LockFile::load(&lockfile_path) {
547                Ok(lockfile) => {
548                    if let Some(reason) = lockfile.validate_against_manifest(&manifest, false)? {
549                        return Err(anyhow::anyhow!(
550                            "Lockfile has critical issues in --frozen mode:\n\n\
551                             {reason}\n\n\
552                             Hint: Fix the issue or remove --frozen flag."
553                        ));
554                    }
555                }
556                Err(e) => {
557                    // In frozen mode, provide enhanced error message with beta notice
558                    return Err(anyhow::anyhow!(
559                        "Cannot proceed in --frozen mode due to invalid lockfile.\n\n\
560                         Error: {}\n\n\
561                         In --frozen mode, the lockfile must be valid.\n\
562                         Fix the lockfile manually or remove the --frozen flag to allow regeneration.\n\n\
563                         Note: The lockfile format is not yet stable as this is beta software.",
564                        e
565                    ));
566                }
567            }
568        }
569        let total_deps = manifest.all_dependencies().len();
570
571        // Initialize multi-phase progress for all progress tracking
572        let multi_phase = Arc::new(MultiPhaseProgress::new(!self.quiet && !self.no_progress));
573
574        // Show initial status
575
576        let actual_project_dir =
577            manifest_path.parent().ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?;
578
579        // Check for existing lockfile
580        let lockfile_path = actual_project_dir.join("agpm.lock");
581
582        // Use enhanced lockfile loading with automatic regeneration for non-frozen mode
583        let existing_lockfile = if !self.frozen {
584            command_context.load_lockfile_with_regeneration(true, "install")?
585        } else {
586            // In frozen mode, use the original loading logic (already validated above)
587            if lockfile_path.exists() {
588                let mut lockfile = LockFile::load(&lockfile_path)?;
589                // Also load and merge private lockfile if it exists
590                if let Ok(Some(private_lock)) =
591                    crate::lockfile::PrivateLockFile::load(actual_project_dir)
592                {
593                    lockfile.merge_private(&private_lock);
594                }
595                Some(lockfile)
596            } else {
597                None
598            }
599        };
600
601        // Check for legacy format migration (old paths → agpm/ subdirectory)
602        // Only check if we have an existing lockfile (indicates prior installation)
603        let existing_lockfile = if existing_lockfile.is_some() && !self.frozen {
604            let migrated =
605                crate::cli::common::handle_legacy_format_migration(actual_project_dir, self.yes)
606                    .await?;
607            if migrated {
608                // Reload manifest after migration since tools config may have changed
609                command_context.reload_manifest()?;
610                // Update local manifest variable to use reloaded manifest
611                manifest = command_context.manifest.clone();
612                // Reload lockfile after migration since paths have changed
613                command_context.load_lockfile_with_regeneration(true, "install")?
614            } else {
615                existing_lockfile
616            }
617        } else {
618            existing_lockfile
619        };
620
621        // Initialize cache (always needed now, even with --no-cache)
622        let cache = Cache::new()?;
623
624        // Calculate max concurrency (used for both resolution and installation)
625        let max_concurrency = self.max_parallel.unwrap_or_else(|| {
626            let cores = std::thread::available_parallelism()
627                .map(std::num::NonZero::get)
628                .unwrap_or(FALLBACK_CORE_COUNT);
629            std::cmp::max(MIN_PARALLELISM, cores * PARALLELISM_CORE_MULTIPLIER)
630        });
631
632        // Create operation context for warning deduplication
633        let operation_context = Arc::new(OperationContext::new());
634
635        // Resolution phase
636        let mut resolver = DependencyResolver::new_with_global_concurrency(
637            manifest.clone(),
638            cache.clone(),
639            Some(max_concurrency),
640            Some(operation_context.clone()),
641        )
642        .await?;
643
644        // Pre-sync sources phase (if not frozen and we have remote deps)
645        let has_remote_deps =
646            manifest.all_dependencies().iter().any(|(_, dep)| dep.get_source().is_some());
647
648        // Fast path detection: check if we can skip resolution entirely
649        let current_manifest_hash = manifest.compute_dependency_hash();
650        let has_mutable = manifest.has_mutable_dependencies();
651
652        let use_fast_path = can_use_fast_path(
653            existing_lockfile.as_ref(),
654            &current_manifest_hash,
655            has_mutable,
656            self.frozen,
657        );
658
659        // Skip pre-sync if using fast path (worktrees already exist from previous install)
660        if !self.frozen && has_remote_deps && !use_fast_path {
661            // Get all dependencies for pre-syncing (filtering out disabled tools)
662            let deps: Vec<(String, ResourceDependency)> = manifest
663                .all_dependencies_with_types()
664                .into_iter()
665                .map(|(name, dep, _resource_type)| (name.to_string(), dep.into_owned()))
666                .collect();
667
668            // Pre-sync all required sources (performs actual Git operations)
669            // Progress tracking for "Syncing sources" phase is handled internally with windowed display
670            let progress = if !self.quiet && !self.no_progress {
671                Some(multi_phase.clone())
672            } else {
673                None
674            };
675            resolver.pre_sync_sources(&deps, progress).await?;
676        } else if use_fast_path && !self.quiet && !self.no_progress {
677            // Skip syncing phase entirely for fast path
678            multi_phase.start_phase(InstallationPhase::SyncingSources, None);
679            multi_phase.complete_phase(Some("Sources up to date"));
680        }
681
682        let mut lockfile = if let Some(existing) = existing_lockfile {
683            if self.frozen {
684                // Use existing lockfile as-is
685                if !self.quiet {
686                    println!("✓ Using frozen lockfile ({total_deps} dependencies)");
687                }
688                existing
689            } else if use_fast_path {
690                // Fast path: manifest unchanged with immutable deps - skip resolution entirely
691                tracing::info!(
692                    "Fast path: manifest unchanged with immutable deps, using cached lockfile"
693                );
694                if !self.quiet && !self.no_progress {
695                    multi_phase.start_phase(
696                        InstallationPhase::ResolvingDependencies,
697                        Some(&format!("({total_deps} dependencies)")),
698                    );
699                    multi_phase
700                        .complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
701                }
702                existing
703            } else {
704                // Update lockfile with any new dependencies
705                let progress = if !self.quiet && !self.no_progress {
706                    Some(multi_phase.clone())
707                } else {
708                    None
709                };
710                resolver.update(&existing, None, progress).await?
711            }
712        } else {
713            // Fresh resolution with windowed progress tracking
714            let progress = if !self.quiet && !self.no_progress {
715                Some(multi_phase.clone())
716            } else {
717                None
718            };
719            resolver.resolve_with_options(!self.no_transitive, progress).await?
720        };
721
722        // Store fast-path metadata in lockfile for next run's detection
723        lockfile.manifest_hash = Some(current_manifest_hash);
724        lockfile.has_mutable_deps = Some(has_mutable);
725        lockfile.resource_count = Some(lockfile.all_resources().len());
726
727        // Check for tag movement if we have both old and new lockfiles (skip in frozen mode)
728        let old_lockfile = if !self.frozen && lockfile_path.exists() {
729            // Load the old lockfile for comparison
730            if let Ok(old) = LockFile::load(&lockfile_path) {
731                detect_tag_movement(&old, &lockfile, self.quiet);
732                Some(old)
733            } else {
734                None
735            }
736        } else {
737            None
738        };
739
740        // Handle dry-run mode: show what would be installed without making changes
741        if self.dry_run {
742            return crate::cli::common::display_dry_run_results(
743                &lockfile,
744                old_lockfile.as_ref(),
745                self.quiet,
746            );
747        }
748
749        // Acquire resource lock for cross-process coordination during file writes
750        // Resolution has completed above (outside lock), now we serialize file operations
751        let _resource_lock =
752            crate::installer::ProjectLock::acquire(actual_project_dir, "resource").await?;
753
754        let total_resources = ResourceIterator::count_total_resources(&lockfile);
755
756        // Track installation error to return later
757        let mut installation_error = None;
758
759        // Track counts for finalizing phase
760        let mut hook_count = 0;
761        let mut server_count = 0;
762
763        // Ultra-fast path: If we can use fast path AND all installed files exist,
764        // skip the entire installation phase (don't even iterate through resources)
765        //
766        // Note: There's a TOCTOU (time-of-check-to-time-of-use) race here where files
767        // could be deleted between this check and actual use. This is accepted as low
768        // risk since user-initiated deletion during install is rare, and the worst case
769        // is that a subsequent tool invocation fails to find the file (easily fixed by
770        // running `agpm install` again).
771        let all_files_exist = use_fast_path
772            && lockfile.all_resources().iter().all(|res| {
773                // Only check files that should be installed (install != false)
774                if res.install == Some(false) {
775                    return true; // Content-only deps don't need file check
776                }
777                if res.installed_at.is_empty() {
778                    return true; // No install path = nothing to check
779                }
780                actual_project_dir.join(&res.installed_at).exists()
781            });
782
783        let installed_count = if total_resources == 0 {
784            0
785        } else if all_files_exist {
786            // Ultra-fast path: all files exist, skip installation entirely
787            if !self.quiet && !self.no_progress {
788                multi_phase.start_phase(
789                    InstallationPhase::Installing,
790                    Some(&format!("({total_resources} resources)")),
791                );
792                multi_phase.complete_phase(Some("All up to date"));
793            }
794            tracing::info!(
795                "Ultra-fast path: all {} files exist, skipping installation",
796                total_resources
797            );
798            0 // No files actually installed (they all exist)
799        } else {
800            // Start installation phase
801            if !self.quiet && !self.no_progress {
802                multi_phase.start_phase(
803                    InstallationPhase::Installing,
804                    Some(&format!("({total_resources} resources)")),
805                );
806            }
807
808            // Install resources using the main installation function
809            // (max_concurrency calculated earlier and used for both resolution and installation)
810            // We need to wrap in Arc for the call, but we'll apply updates to the mutable version
811            let lockfile_for_install = Arc::new(lockfile.clone());
812
813            // Compute effective token warning threshold: manifest overrides global config
814            let global_config = crate::config::GlobalConfig::load().await.unwrap_or_default();
815            let token_warning_threshold =
816                manifest.token_warning_threshold.unwrap_or(global_config.token_warning_threshold);
817
818            match install_resources(
819                ResourceFilter::All,
820                &lockfile_for_install,
821                &manifest,
822                actual_project_dir,
823                cache.clone(),
824                self.no_cache,
825                Some(max_concurrency),
826                Some(multi_phase.clone()),
827                self.verbose,
828                old_lockfile.as_ref(), // Pass old lockfile for early-exit optimization
829                use_fast_path,         // Trust lockfile checksums in fast path mode
830                Some(token_warning_threshold),
831            )
832            .await
833            {
834                Ok(results) => {
835                    // Apply installation results to lockfile
836                    lockfile.apply_installation_results(
837                        results.checksums,
838                        results.context_checksums,
839                        results.applied_patches,
840                        results.token_counts,
841                    );
842
843                    results.installed_count
844                }
845                Err(e) => {
846                    // Save the error to return immediately - don't continue with hooks/mcp/finalization
847                    installation_error = Some(e);
848                    0
849                }
850            }
851        };
852
853        // Only proceed with hooks, MCP, and finalization if installation succeeded
854        if installation_error.is_none() {
855            // Start finalizing phase
856            if !self.quiet && !self.no_progress && installed_count > 0 {
857                multi_phase.start_phase(InstallationPhase::Finalizing, None);
858            }
859
860            // Call shared finalization function
861            let (hook_count_result, server_count_result) = crate::installer::finalize_installation(
862                &mut lockfile,
863                &manifest,
864                actual_project_dir,
865                &cache,
866                old_lockfile.as_ref(),
867                self.quiet,
868                self.no_lock,
869            )
870            .await?;
871
872            hook_count = hook_count_result;
873            server_count = server_count_result;
874
875            // Complete finalizing phase
876            if !self.quiet && !self.no_progress && installed_count > 0 {
877                multi_phase.complete_phase(Some("Installation finalized"));
878            }
879        }
880
881        // Return the installation error if there was one
882        if let Some(error) = installation_error {
883            return Err(error);
884        }
885
886        // Validate project configuration and offer to add missing gitignore entries
887        if !self.quiet && installed_count > 0 {
888            let validation =
889                crate::installer::validate_config(project_dir, &lockfile, manifest.gitignore).await;
890
891            // Print any Claude settings warnings
892            if let Some(warning) = &validation.claude_settings_warning {
893                eprintln!("\n{}", warning);
894            }
895
896            // Handle missing gitignore entries interactively
897            if !validation.missing_gitignore_entries.is_empty() {
898                // Ignore errors - gitignore is a convenience feature
899                let _ = crate::cli::common::handle_missing_gitignore_entries(
900                    &validation,
901                    project_dir,
902                    self.yes,
903                )
904                .await;
905            }
906        }
907
908        // Only show "no dependencies" message if nothing was installed AND no progress shown
909        if self.no_progress
910            && !self.quiet
911            && installed_count == 0
912            && hook_count == 0
913            && server_count == 0
914        {
915            crate::cli::common::display_no_changes(
916                crate::cli::common::OperationMode::Install,
917                self.quiet,
918            );
919        }
920
921        Ok(())
922    }
923}
924
925/// Detects if any tags have moved between the old and new lockfiles.
926///
927/// Tags in Git are supposed to be immutable, so if a tag points to a different
928/// commit than before, this is potentially problematic and worth warning about.
929///
930/// Branches are expected to move, so we don't warn about those.
931fn detect_tag_movement(old_lockfile: &LockFile, new_lockfile: &LockFile, quiet: bool) {
932    use crate::core::ResourceType;
933
934    // Helper function to check if a version looks like a tag (not a branch or SHA)
935    fn is_tag_like(version: &str) -> bool {
936        // Skip if it looks like a SHA
937        if version.len() >= 7 && version.chars().all(|c| c.is_ascii_hexdigit()) {
938            return false;
939        }
940
941        // Skip if it's a known branch name
942        if matches!(
943            version,
944            "main" | "master" | "develop" | "dev" | "staging" | "production" | "HEAD"
945        ) || version.starts_with("release/")
946            || version.starts_with("feature/")
947            || version.starts_with("hotfix/")
948            || version.starts_with("bugfix/")
949        {
950            return false;
951        }
952
953        // Likely a tag if it starts with 'v' or looks like a version
954        version.starts_with('v')
955            || version.starts_with("release-")
956            || version.parse::<semver::Version>().is_ok()
957            || version.contains('.') // Likely a version number
958    }
959
960    // Helper to check resources of a specific type
961    fn check_resources(
962        old_resources: &[crate::lockfile::LockedResource],
963        new_resources: &[crate::lockfile::LockedResource],
964        resource_type: ResourceType,
965        quiet: bool,
966    ) {
967        for new_resource in new_resources {
968            // Skip if no version or resolved commit
969            let Some(ref new_version) = new_resource.version else {
970                continue;
971            };
972            let Some(ref new_commit) = new_resource.resolved_commit else {
973                continue;
974            };
975
976            // Skip if not a tag
977            if !is_tag_like(new_version) {
978                continue;
979            }
980
981            // Find the corresponding old resource
982            if let Some(old_resource) =
983                old_resources.iter().find(|r| r.display_name() == new_resource.display_name())
984                && let (Some(old_version), Some(old_commit)) =
985                    (&old_resource.version, &old_resource.resolved_commit)
986            {
987                // Check if the same tag now points to a different commit
988                if old_version == new_version && old_commit != new_commit && !quiet {
989                    eprintln!(
990                        "⚠️  Warning: Tag '{}' for {} '{}' has moved from {} to {}",
991                        new_version,
992                        resource_type,
993                        new_resource.display_name(),
994                        &old_commit[..8.min(old_commit.len())],
995                        &new_commit[..8.min(new_commit.len())]
996                    );
997                    eprintln!(
998                        "   Tags should be immutable. This may indicate the upstream repository force-pushed the tag."
999                    );
1000                }
1001            }
1002        }
1003    }
1004
1005    // Check all resource types
1006    check_resources(&old_lockfile.agents, &new_lockfile.agents, ResourceType::Agent, quiet);
1007    check_resources(&old_lockfile.snippets, &new_lockfile.snippets, ResourceType::Snippet, quiet);
1008    check_resources(&old_lockfile.commands, &new_lockfile.commands, ResourceType::Command, quiet);
1009    check_resources(&old_lockfile.scripts, &new_lockfile.scripts, ResourceType::Script, quiet);
1010    check_resources(&old_lockfile.hooks, &new_lockfile.hooks, ResourceType::Hook, quiet);
1011    check_resources(
1012        &old_lockfile.mcp_servers,
1013        &new_lockfile.mcp_servers,
1014        ResourceType::McpServer,
1015        quiet,
1016    );
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use crate::lockfile::{LockFile, LockedResource};
1023    use crate::manifest::{DetailedDependency, Manifest, ResourceDependency};
1024
1025    use std::fs;
1026    use tempfile::TempDir;
1027
1028    #[tokio::test]
1029    async fn test_install_command_no_manifest() -> Result<(), anyhow::Error> {
1030        let temp = TempDir::new()?;
1031        let manifest_path = temp.path().join("agpm.toml");
1032
1033        let cmd = InstallCommand::new();
1034        let result = cmd.execute_from_path(Some(&manifest_path)).await;
1035        assert!(result.is_err());
1036        assert!(result.unwrap_err().to_string().contains("agpm.toml"));
1037        Ok(())
1038    }
1039
1040    #[tokio::test]
1041    async fn test_install_with_empty_manifest() -> Result<()> {
1042        let temp = TempDir::new()?;
1043        let manifest_path = temp.path().join("agpm.toml");
1044        Manifest::new().save(&manifest_path)?;
1045
1046        let cmd = InstallCommand::new();
1047        cmd.execute_from_path(Some(&manifest_path)).await?;
1048
1049        let lockfile_path = temp.path().join("agpm.lock");
1050        assert!(lockfile_path.exists());
1051        let lockfile = LockFile::load(&lockfile_path)?;
1052        assert!(lockfile.agents.is_empty());
1053        assert!(lockfile.snippets.is_empty());
1054        Ok(())
1055    }
1056
1057    #[tokio::test]
1058    async fn test_install_command_new_defaults() {
1059        let cmd = InstallCommand::new();
1060        assert!(!cmd.no_lock);
1061        assert!(!cmd.frozen);
1062        assert!(!cmd.no_cache);
1063        assert!(cmd.max_parallel.is_none());
1064        assert!(!cmd.quiet);
1065    }
1066
1067    #[tokio::test]
1068    async fn test_install_respects_no_lock_flag() -> anyhow::Result<()> {
1069        let temp = TempDir::new().unwrap();
1070        let manifest_path = temp.path().join("agpm.toml");
1071        Manifest::new().save(&manifest_path).unwrap();
1072
1073        let cmd = InstallCommand {
1074            no_lock: true,
1075            frozen: false,
1076            no_cache: false,
1077            max_parallel: None,
1078            quiet: false,
1079            no_progress: false,
1080            verbose: false,
1081            no_transitive: false,
1082            dry_run: false,
1083            yes: false,
1084        };
1085
1086        cmd.execute_from_path(Some(&manifest_path)).await?;
1087        assert!(!temp.path().join("agpm.lock").exists());
1088        Ok(())
1089    }
1090
1091    #[tokio::test]
1092    async fn test_install_with_local_dependency() -> Result<(), anyhow::Error> {
1093        let temp = TempDir::new()?;
1094        let manifest_path = temp.path().join("agpm.toml");
1095        let local_file = temp.path().join("local-agent.md");
1096        fs::write(
1097            &local_file,
1098            "# Local Agent
1099This is a test agent.",
1100        )?;
1101
1102        let mut manifest = Manifest::new();
1103        manifest.agents.insert(
1104            "local-agent".into(),
1105            ResourceDependency::Detailed(Box::new(DetailedDependency {
1106                source: None,
1107                path: "local-agent.md".into(),
1108                version: None,
1109                branch: None,
1110                rev: None,
1111                command: None,
1112                args: None,
1113                target: None,
1114                filename: None,
1115                dependencies: None,
1116                tool: Some("claude-code".to_string()),
1117                flatten: None,
1118                install: None,
1119
1120                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1121            })),
1122        );
1123        manifest.save(&manifest_path)?;
1124
1125        let cmd = InstallCommand::new();
1126        cmd.execute_from_path(Some(&manifest_path)).await?;
1127        assert!(temp.path().join(".claude/agents/agpm/local-agent.md").exists());
1128        Ok(())
1129    }
1130
1131    #[tokio::test]
1132    async fn test_install_with_invalid_manifest_syntax() -> Result<(), anyhow::Error> {
1133        let temp = TempDir::new()?;
1134        let manifest_path = temp.path().join("agpm.toml");
1135        fs::write(&manifest_path, "[invalid toml")?;
1136
1137        let cmd = InstallCommand::new();
1138        let err = cmd.execute_from_path(Some(temp.path())).await.unwrap_err();
1139        // The actual error will be about parsing the invalid TOML
1140        let err_str = err.to_string();
1141        assert!(
1142            err_str.contains("File operation failed")
1143                || err_str.contains("Failed reading file")
1144                || err_str.contains("Cannot read manifest")
1145                || err_str.contains("unclosed")
1146                || err_str.contains("parse")
1147                || err_str.contains("expected")
1148                || err_str.contains("invalid"),
1149            "Unexpected error message: {}",
1150            err_str
1151        );
1152        Ok(())
1153    }
1154
1155    #[tokio::test]
1156    async fn test_install_uses_existing_lockfile_when_frozen() -> anyhow::Result<()> {
1157        let temp = TempDir::new()?;
1158        let manifest_path = temp.path().join("agpm.toml");
1159        let lockfile_path = temp.path().join("agpm.lock");
1160
1161        let local_file = temp.path().join("test-agent.md");
1162        fs::write(
1163            &local_file,
1164            "# Test Agent
1165Body",
1166        )?;
1167
1168        let mut manifest = Manifest::new();
1169        manifest.agents.insert(
1170            "test-agent".into(),
1171            ResourceDependency::Detailed(Box::new(DetailedDependency {
1172                source: None,
1173                path: "test-agent.md".into(),
1174                version: None,
1175                branch: None,
1176                rev: None,
1177                command: None,
1178                args: None,
1179                target: None,
1180                filename: None,
1181                dependencies: None,
1182                tool: Some("claude-code".to_string()),
1183                flatten: None,
1184                install: None,
1185
1186                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1187            })),
1188        );
1189        manifest.save(&manifest_path)?;
1190
1191        LockFile {
1192            version: 1,
1193            sources: vec![],
1194            commands: vec![],
1195            agents: vec![LockedResource {
1196                name: "test-agent".into(),
1197                source: None,
1198                url: None,
1199                path: "test-agent.md".into(),
1200                version: None,
1201                resolved_commit: None,
1202                checksum: String::new(),
1203                installed_at: ".claude/agents/test-agent.md".into(),
1204                dependencies: vec![],
1205                resource_type: crate::core::ResourceType::Agent,
1206                tool: Some("claude-code".to_string()),
1207                manifest_alias: None,
1208                context_checksum: None,
1209                applied_patches: std::collections::BTreeMap::new(),
1210                install: None,
1211                variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1212                is_private: false,
1213                approximate_token_count: None,
1214            }],
1215            snippets: vec![],
1216            mcp_servers: vec![],
1217            scripts: vec![],
1218            hooks: vec![],
1219            skills: vec![],
1220            manifest_hash: None,
1221            has_mutable_deps: None,
1222            resource_count: None,
1223        }
1224        .save(&lockfile_path)?;
1225
1226        let cmd = InstallCommand {
1227            no_lock: false,
1228            frozen: true,
1229            no_cache: false,
1230            max_parallel: None,
1231            quiet: false,
1232            no_progress: false,
1233            verbose: false,
1234            no_transitive: false,
1235            dry_run: false,
1236            yes: false,
1237        };
1238
1239        cmd.execute_from_path(Some(&manifest_path)).await?;
1240        assert!(temp.path().join(".claude/agents/test-agent.md").exists());
1241        Ok(())
1242    }
1243
1244    #[tokio::test]
1245    async fn test_install_errors_when_local_file_missing() -> Result<(), anyhow::Error> {
1246        let temp = TempDir::new()?;
1247        let manifest_path = temp.path().join("agpm.toml");
1248
1249        let mut manifest = Manifest::new();
1250        manifest.agents.insert(
1251            "missing".into(),
1252            ResourceDependency::Detailed(Box::new(DetailedDependency {
1253                source: None,
1254                path: "missing.md".into(),
1255                version: None,
1256                branch: None,
1257                rev: None,
1258                command: None,
1259                args: None,
1260                target: None,
1261                filename: None,
1262                dependencies: None,
1263                tool: Some("claude-code".to_string()),
1264                flatten: None,
1265                install: None,
1266
1267                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1268            })),
1269        );
1270        manifest.save(&manifest_path)?;
1271
1272        let err = InstallCommand::new().execute_from_path(Some(&manifest_path)).await.unwrap_err();
1273        let err_string = err.to_string();
1274        // After converting warnings to errors, missing local files fail with resource fetch error
1275        assert!(
1276            err_string.contains("Failed to fetch resource")
1277                || err_string.contains("local file")
1278                || err_string.contains("Failed to install 1 resources:"),
1279            "Error should indicate resource fetch failure, got: {}",
1280            err_string
1281        );
1282        Ok(())
1283    }
1284
1285    #[tokio::test]
1286    async fn test_install_single_resource_paths() -> Result<(), anyhow::Error> {
1287        let temp = TempDir::new()?;
1288        let manifest_path = temp.path().join("agpm.toml");
1289        let snippet_file = temp.path().join("single-snippet.md");
1290        fs::write(
1291            &snippet_file,
1292            "# Snippet
1293Body",
1294        )?;
1295
1296        let mut manifest = Manifest::new();
1297        manifest.snippets.insert(
1298            "single".into(),
1299            ResourceDependency::Detailed(Box::new(DetailedDependency {
1300                source: None,
1301                path: "single-snippet.md".into(),
1302                version: None,
1303                branch: None,
1304                rev: None,
1305                command: None,
1306                args: None,
1307                target: None,
1308                filename: None,
1309                dependencies: None,
1310                tool: Some("claude-code".to_string()),
1311                flatten: None,
1312                install: None,
1313
1314                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1315            })),
1316        );
1317        manifest.save(&manifest_path)?;
1318
1319        let cmd = InstallCommand::new();
1320        cmd.execute_from_path(Some(&manifest_path)).await?;
1321
1322        let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
1323        assert_eq!(lockfile.snippets.len(), 1);
1324        let installed_path = temp.path().join(&lockfile.snippets[0].installed_at);
1325        assert!(installed_path.exists());
1326        Ok(())
1327    }
1328
1329    #[tokio::test]
1330    async fn test_install_single_command_resource() -> anyhow::Result<()> {
1331        let temp = TempDir::new()?;
1332        let manifest_path = temp.path().join("agpm.toml");
1333        let command_file = temp.path().join("single-command.md");
1334        fs::write(
1335            &command_file,
1336            "# Command
1337Body",
1338        )?;
1339
1340        let mut manifest = Manifest::new();
1341        manifest.commands.insert(
1342            "cmd".into(),
1343            ResourceDependency::Detailed(Box::new(DetailedDependency {
1344                source: None,
1345                path: "single-command.md".into(),
1346                version: None,
1347                branch: None,
1348                rev: None,
1349                command: None,
1350                args: None,
1351                target: None,
1352                filename: None,
1353                dependencies: None,
1354                tool: Some("claude-code".to_string()),
1355                flatten: None,
1356                install: None,
1357
1358                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1359            })),
1360        );
1361        manifest.save(&manifest_path)?;
1362
1363        let cmd = InstallCommand::new();
1364        cmd.execute_from_path(Some(&manifest_path)).await?;
1365
1366        let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
1367        assert_eq!(lockfile.commands.len(), 1);
1368        assert!(temp.path().join(&lockfile.commands[0].installed_at).exists());
1369        Ok(())
1370    }
1371
1372    #[tokio::test]
1373    async fn test_install_dry_run_mode() -> Result<(), anyhow::Error> {
1374        let temp = TempDir::new()?;
1375        let manifest_path = temp.path().join("agpm.toml");
1376        let lockfile_path = temp.path().join("agpm.lock");
1377        let agent_file = temp.path().join("test-agent.md");
1378
1379        // Create a local file for the agent
1380        fs::write(&agent_file, "# Test Agent\nBody")?;
1381
1382        let mut manifest = Manifest::new();
1383        manifest.agents.insert(
1384            "test-agent".into(),
1385            ResourceDependency::Detailed(Box::new(DetailedDependency {
1386                source: None,
1387                path: "test-agent.md".into(),
1388                version: None,
1389                branch: None,
1390                rev: None,
1391                command: None,
1392                args: None,
1393                target: None,
1394                filename: None,
1395                dependencies: None,
1396                tool: Some("claude-code".to_string()),
1397                flatten: None,
1398                install: None,
1399
1400                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1401            })),
1402        );
1403        manifest.save(&manifest_path)?;
1404
1405        let cmd = InstallCommand {
1406            no_lock: false,
1407            frozen: false,
1408            no_cache: false,
1409            max_parallel: None,
1410            quiet: true, // Suppress output in test
1411            no_progress: true,
1412            verbose: false,
1413            no_transitive: false,
1414            dry_run: true,
1415            yes: false,
1416        };
1417
1418        // In dry-run mode, this should return an error indicating changes would be made
1419        let result = cmd.execute_from_path(Some(&manifest_path)).await;
1420
1421        // Should return an error because changes would be made
1422        assert!(result.is_err());
1423        let err_msg = result.unwrap_err().to_string();
1424        assert!(err_msg.contains("Dry-run detected changes"));
1425
1426        // Lockfile should NOT be created in dry-run mode
1427        assert!(!lockfile_path.exists());
1428        // Resource should NOT be installed
1429        assert!(!temp.path().join(".claude/agents/test-agent.md").exists());
1430        Ok(())
1431    }
1432
1433    #[tokio::test]
1434    async fn test_install_summary_with_mcp_servers() -> Result<(), anyhow::Error> {
1435        let temp = TempDir::new()?;
1436        let manifest_path = temp.path().join("agpm.toml");
1437        let agent_file = temp.path().join("summary-agent.md");
1438        fs::write(&agent_file, "# Agent\nBody")?;
1439
1440        let mcp_dir = temp.path().join("mcp");
1441        fs::create_dir_all(&mcp_dir)?;
1442        fs::write(mcp_dir.join("test-mcp.json"), "{\"name\":\"test\"}")?;
1443
1444        let mut manifest = Manifest::new();
1445        manifest.agents.insert(
1446            "summary".into(),
1447            ResourceDependency::Detailed(Box::new(DetailedDependency {
1448                source: None,
1449                path: "summary-agent.md".into(),
1450                version: None,
1451                branch: None,
1452                rev: None,
1453                command: None,
1454                args: None,
1455                target: None,
1456                filename: None,
1457                dependencies: None,
1458                tool: Some("claude-code".to_string()),
1459                flatten: None,
1460                install: None,
1461
1462                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1463            })),
1464        );
1465        manifest.add_mcp_server(
1466            "test-mcp".into(),
1467            ResourceDependency::Detailed(Box::new(DetailedDependency {
1468                source: None,
1469                path: "mcp/test-mcp.json".into(),
1470                version: None,
1471                branch: None,
1472                rev: None,
1473                command: None,
1474                args: None,
1475                target: None,
1476                filename: None,
1477                dependencies: None,
1478                tool: Some("claude-code".to_string()),
1479                flatten: None,
1480                install: None,
1481
1482                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1483            })),
1484        );
1485        manifest.save(&manifest_path)?;
1486
1487        let cmd = InstallCommand::new();
1488        cmd.execute_from_path(Some(&manifest_path)).await?;
1489        Ok(())
1490    }
1491}