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            match install_resources(
813                ResourceFilter::All,
814                &lockfile_for_install,
815                &manifest,
816                actual_project_dir,
817                cache.clone(),
818                self.no_cache,
819                Some(max_concurrency),
820                Some(multi_phase.clone()),
821                self.verbose,
822                old_lockfile.as_ref(), // Pass old lockfile for early-exit optimization
823                use_fast_path,         // Trust lockfile checksums in fast path mode
824            )
825            .await
826            {
827                Ok(results) => {
828                    // Apply installation results to lockfile
829                    lockfile.apply_installation_results(
830                        results.checksums,
831                        results.context_checksums,
832                        results.applied_patches,
833                    );
834
835                    results.installed_count
836                }
837                Err(e) => {
838                    // Save the error to return immediately - don't continue with hooks/mcp/finalization
839                    installation_error = Some(e);
840                    0
841                }
842            }
843        };
844
845        // Only proceed with hooks, MCP, and finalization if installation succeeded
846        if installation_error.is_none() {
847            // Start finalizing phase
848            if !self.quiet && !self.no_progress && installed_count > 0 {
849                multi_phase.start_phase(InstallationPhase::Finalizing, None);
850            }
851
852            // Call shared finalization function
853            let (hook_count_result, server_count_result) = crate::installer::finalize_installation(
854                &mut lockfile,
855                &manifest,
856                actual_project_dir,
857                &cache,
858                old_lockfile.as_ref(),
859                self.quiet,
860                self.no_lock,
861            )
862            .await?;
863
864            hook_count = hook_count_result;
865            server_count = server_count_result;
866
867            // Complete finalizing phase
868            if !self.quiet && !self.no_progress && installed_count > 0 {
869                multi_phase.complete_phase(Some("Installation finalized"));
870            }
871        }
872
873        // Return the installation error if there was one
874        if let Some(error) = installation_error {
875            return Err(error);
876        }
877
878        // Validate project configuration and warn about missing entries
879        if !self.quiet && installed_count > 0 {
880            let validation =
881                crate::installer::validate_config(project_dir, &lockfile, manifest.gitignore).await;
882            validation.print_warnings();
883        }
884
885        // Only show "no dependencies" message if nothing was installed AND no progress shown
886        if self.no_progress
887            && !self.quiet
888            && installed_count == 0
889            && hook_count == 0
890            && server_count == 0
891        {
892            crate::cli::common::display_no_changes(
893                crate::cli::common::OperationMode::Install,
894                self.quiet,
895            );
896        }
897
898        Ok(())
899    }
900}
901
902/// Detects if any tags have moved between the old and new lockfiles.
903///
904/// Tags in Git are supposed to be immutable, so if a tag points to a different
905/// commit than before, this is potentially problematic and worth warning about.
906///
907/// Branches are expected to move, so we don't warn about those.
908fn detect_tag_movement(old_lockfile: &LockFile, new_lockfile: &LockFile, quiet: bool) {
909    use crate::core::ResourceType;
910
911    // Helper function to check if a version looks like a tag (not a branch or SHA)
912    fn is_tag_like(version: &str) -> bool {
913        // Skip if it looks like a SHA
914        if version.len() >= 7 && version.chars().all(|c| c.is_ascii_hexdigit()) {
915            return false;
916        }
917
918        // Skip if it's a known branch name
919        if matches!(
920            version,
921            "main" | "master" | "develop" | "dev" | "staging" | "production" | "HEAD"
922        ) || version.starts_with("release/")
923            || version.starts_with("feature/")
924            || version.starts_with("hotfix/")
925            || version.starts_with("bugfix/")
926        {
927            return false;
928        }
929
930        // Likely a tag if it starts with 'v' or looks like a version
931        version.starts_with('v')
932            || version.starts_with("release-")
933            || version.parse::<semver::Version>().is_ok()
934            || version.contains('.') // Likely a version number
935    }
936
937    // Helper to check resources of a specific type
938    fn check_resources(
939        old_resources: &[crate::lockfile::LockedResource],
940        new_resources: &[crate::lockfile::LockedResource],
941        resource_type: ResourceType,
942        quiet: bool,
943    ) {
944        for new_resource in new_resources {
945            // Skip if no version or resolved commit
946            let Some(ref new_version) = new_resource.version else {
947                continue;
948            };
949            let Some(ref new_commit) = new_resource.resolved_commit else {
950                continue;
951            };
952
953            // Skip if not a tag
954            if !is_tag_like(new_version) {
955                continue;
956            }
957
958            // Find the corresponding old resource
959            if let Some(old_resource) =
960                old_resources.iter().find(|r| r.display_name() == new_resource.display_name())
961                && let (Some(old_version), Some(old_commit)) =
962                    (&old_resource.version, &old_resource.resolved_commit)
963            {
964                // Check if the same tag now points to a different commit
965                if old_version == new_version && old_commit != new_commit && !quiet {
966                    eprintln!(
967                        "⚠️  Warning: Tag '{}' for {} '{}' has moved from {} to {}",
968                        new_version,
969                        resource_type,
970                        new_resource.display_name(),
971                        &old_commit[..8.min(old_commit.len())],
972                        &new_commit[..8.min(new_commit.len())]
973                    );
974                    eprintln!(
975                        "   Tags should be immutable. This may indicate the upstream repository force-pushed the tag."
976                    );
977                }
978            }
979        }
980    }
981
982    // Check all resource types
983    check_resources(&old_lockfile.agents, &new_lockfile.agents, ResourceType::Agent, quiet);
984    check_resources(&old_lockfile.snippets, &new_lockfile.snippets, ResourceType::Snippet, quiet);
985    check_resources(&old_lockfile.commands, &new_lockfile.commands, ResourceType::Command, quiet);
986    check_resources(&old_lockfile.scripts, &new_lockfile.scripts, ResourceType::Script, quiet);
987    check_resources(&old_lockfile.hooks, &new_lockfile.hooks, ResourceType::Hook, quiet);
988    check_resources(
989        &old_lockfile.mcp_servers,
990        &new_lockfile.mcp_servers,
991        ResourceType::McpServer,
992        quiet,
993    );
994}
995
996#[cfg(test)]
997mod tests {
998    use super::*;
999    use crate::lockfile::{LockFile, LockedResource};
1000    use crate::manifest::{DetailedDependency, Manifest, ResourceDependency};
1001
1002    use std::fs;
1003    use tempfile::TempDir;
1004
1005    #[tokio::test]
1006    async fn test_install_command_no_manifest() -> Result<(), anyhow::Error> {
1007        let temp = TempDir::new()?;
1008        let manifest_path = temp.path().join("agpm.toml");
1009
1010        let cmd = InstallCommand::new();
1011        let result = cmd.execute_from_path(Some(&manifest_path)).await;
1012        assert!(result.is_err());
1013        assert!(result.unwrap_err().to_string().contains("agpm.toml"));
1014        Ok(())
1015    }
1016
1017    #[tokio::test]
1018    async fn test_install_with_empty_manifest() -> Result<()> {
1019        let temp = TempDir::new()?;
1020        let manifest_path = temp.path().join("agpm.toml");
1021        Manifest::new().save(&manifest_path)?;
1022
1023        let cmd = InstallCommand::new();
1024        cmd.execute_from_path(Some(&manifest_path)).await?;
1025
1026        let lockfile_path = temp.path().join("agpm.lock");
1027        assert!(lockfile_path.exists());
1028        let lockfile = LockFile::load(&lockfile_path)?;
1029        assert!(lockfile.agents.is_empty());
1030        assert!(lockfile.snippets.is_empty());
1031        Ok(())
1032    }
1033
1034    #[tokio::test]
1035    async fn test_install_command_new_defaults() {
1036        let cmd = InstallCommand::new();
1037        assert!(!cmd.no_lock);
1038        assert!(!cmd.frozen);
1039        assert!(!cmd.no_cache);
1040        assert!(cmd.max_parallel.is_none());
1041        assert!(!cmd.quiet);
1042    }
1043
1044    #[tokio::test]
1045    async fn test_install_respects_no_lock_flag() -> anyhow::Result<()> {
1046        let temp = TempDir::new().unwrap();
1047        let manifest_path = temp.path().join("agpm.toml");
1048        Manifest::new().save(&manifest_path).unwrap();
1049
1050        let cmd = InstallCommand {
1051            no_lock: true,
1052            frozen: false,
1053            no_cache: false,
1054            max_parallel: None,
1055            quiet: false,
1056            no_progress: false,
1057            verbose: false,
1058            no_transitive: false,
1059            dry_run: false,
1060            yes: false,
1061        };
1062
1063        cmd.execute_from_path(Some(&manifest_path)).await?;
1064        assert!(!temp.path().join("agpm.lock").exists());
1065        Ok(())
1066    }
1067
1068    #[tokio::test]
1069    async fn test_install_with_local_dependency() -> Result<(), anyhow::Error> {
1070        let temp = TempDir::new()?;
1071        let manifest_path = temp.path().join("agpm.toml");
1072        let local_file = temp.path().join("local-agent.md");
1073        fs::write(
1074            &local_file,
1075            "# Local Agent
1076This is a test agent.",
1077        )?;
1078
1079        let mut manifest = Manifest::new();
1080        manifest.agents.insert(
1081            "local-agent".into(),
1082            ResourceDependency::Detailed(Box::new(DetailedDependency {
1083                source: None,
1084                path: "local-agent.md".into(),
1085                version: None,
1086                branch: None,
1087                rev: None,
1088                command: None,
1089                args: None,
1090                target: None,
1091                filename: None,
1092                dependencies: None,
1093                tool: Some("claude-code".to_string()),
1094                flatten: None,
1095                install: None,
1096
1097                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1098            })),
1099        );
1100        manifest.save(&manifest_path)?;
1101
1102        let cmd = InstallCommand::new();
1103        cmd.execute_from_path(Some(&manifest_path)).await?;
1104        assert!(temp.path().join(".claude/agents/agpm/local-agent.md").exists());
1105        Ok(())
1106    }
1107
1108    #[tokio::test]
1109    async fn test_install_with_invalid_manifest_syntax() -> Result<(), anyhow::Error> {
1110        let temp = TempDir::new()?;
1111        let manifest_path = temp.path().join("agpm.toml");
1112        fs::write(&manifest_path, "[invalid toml")?;
1113
1114        let cmd = InstallCommand::new();
1115        let err = cmd.execute_from_path(Some(temp.path())).await.unwrap_err();
1116        // The actual error will be about parsing the invalid TOML
1117        let err_str = err.to_string();
1118        assert!(
1119            err_str.contains("File operation failed")
1120                || err_str.contains("Failed reading file")
1121                || err_str.contains("Cannot read manifest")
1122                || err_str.contains("unclosed")
1123                || err_str.contains("parse")
1124                || err_str.contains("expected")
1125                || err_str.contains("invalid"),
1126            "Unexpected error message: {}",
1127            err_str
1128        );
1129        Ok(())
1130    }
1131
1132    #[tokio::test]
1133    async fn test_install_uses_existing_lockfile_when_frozen() -> anyhow::Result<()> {
1134        let temp = TempDir::new()?;
1135        let manifest_path = temp.path().join("agpm.toml");
1136        let lockfile_path = temp.path().join("agpm.lock");
1137
1138        let local_file = temp.path().join("test-agent.md");
1139        fs::write(
1140            &local_file,
1141            "# Test Agent
1142Body",
1143        )?;
1144
1145        let mut manifest = Manifest::new();
1146        manifest.agents.insert(
1147            "test-agent".into(),
1148            ResourceDependency::Detailed(Box::new(DetailedDependency {
1149                source: None,
1150                path: "test-agent.md".into(),
1151                version: None,
1152                branch: None,
1153                rev: None,
1154                command: None,
1155                args: None,
1156                target: None,
1157                filename: None,
1158                dependencies: None,
1159                tool: Some("claude-code".to_string()),
1160                flatten: None,
1161                install: None,
1162
1163                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1164            })),
1165        );
1166        manifest.save(&manifest_path)?;
1167
1168        LockFile {
1169            version: 1,
1170            sources: vec![],
1171            commands: vec![],
1172            agents: vec![LockedResource {
1173                name: "test-agent".into(),
1174                source: None,
1175                url: None,
1176                path: "test-agent.md".into(),
1177                version: None,
1178                resolved_commit: None,
1179                checksum: String::new(),
1180                installed_at: ".claude/agents/test-agent.md".into(),
1181                dependencies: vec![],
1182                resource_type: crate::core::ResourceType::Agent,
1183                tool: Some("claude-code".to_string()),
1184                manifest_alias: None,
1185                context_checksum: None,
1186                applied_patches: std::collections::BTreeMap::new(),
1187                install: None,
1188                variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
1189                is_private: false,
1190            }],
1191            snippets: vec![],
1192            mcp_servers: vec![],
1193            scripts: vec![],
1194            hooks: vec![],
1195            skills: vec![],
1196            manifest_hash: None,
1197            has_mutable_deps: None,
1198            resource_count: None,
1199        }
1200        .save(&lockfile_path)?;
1201
1202        let cmd = InstallCommand {
1203            no_lock: false,
1204            frozen: true,
1205            no_cache: false,
1206            max_parallel: None,
1207            quiet: false,
1208            no_progress: false,
1209            verbose: false,
1210            no_transitive: false,
1211            dry_run: false,
1212            yes: false,
1213        };
1214
1215        cmd.execute_from_path(Some(&manifest_path)).await?;
1216        assert!(temp.path().join(".claude/agents/test-agent.md").exists());
1217        Ok(())
1218    }
1219
1220    #[tokio::test]
1221    async fn test_install_errors_when_local_file_missing() -> Result<(), anyhow::Error> {
1222        let temp = TempDir::new()?;
1223        let manifest_path = temp.path().join("agpm.toml");
1224
1225        let mut manifest = Manifest::new();
1226        manifest.agents.insert(
1227            "missing".into(),
1228            ResourceDependency::Detailed(Box::new(DetailedDependency {
1229                source: None,
1230                path: "missing.md".into(),
1231                version: None,
1232                branch: None,
1233                rev: None,
1234                command: None,
1235                args: None,
1236                target: None,
1237                filename: None,
1238                dependencies: None,
1239                tool: Some("claude-code".to_string()),
1240                flatten: None,
1241                install: None,
1242
1243                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1244            })),
1245        );
1246        manifest.save(&manifest_path)?;
1247
1248        let err = InstallCommand::new().execute_from_path(Some(&manifest_path)).await.unwrap_err();
1249        let err_string = err.to_string();
1250        // After converting warnings to errors, missing local files fail with resource fetch error
1251        assert!(
1252            err_string.contains("Failed to fetch resource")
1253                || err_string.contains("local file")
1254                || err_string.contains("Failed to install 1 resources:"),
1255            "Error should indicate resource fetch failure, got: {}",
1256            err_string
1257        );
1258        Ok(())
1259    }
1260
1261    #[tokio::test]
1262    async fn test_install_single_resource_paths() -> Result<(), anyhow::Error> {
1263        let temp = TempDir::new()?;
1264        let manifest_path = temp.path().join("agpm.toml");
1265        let snippet_file = temp.path().join("single-snippet.md");
1266        fs::write(
1267            &snippet_file,
1268            "# Snippet
1269Body",
1270        )?;
1271
1272        let mut manifest = Manifest::new();
1273        manifest.snippets.insert(
1274            "single".into(),
1275            ResourceDependency::Detailed(Box::new(DetailedDependency {
1276                source: None,
1277                path: "single-snippet.md".into(),
1278                version: None,
1279                branch: None,
1280                rev: None,
1281                command: None,
1282                args: None,
1283                target: None,
1284                filename: None,
1285                dependencies: None,
1286                tool: Some("claude-code".to_string()),
1287                flatten: None,
1288                install: None,
1289
1290                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1291            })),
1292        );
1293        manifest.save(&manifest_path)?;
1294
1295        let cmd = InstallCommand::new();
1296        cmd.execute_from_path(Some(&manifest_path)).await?;
1297
1298        let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
1299        assert_eq!(lockfile.snippets.len(), 1);
1300        let installed_path = temp.path().join(&lockfile.snippets[0].installed_at);
1301        assert!(installed_path.exists());
1302        Ok(())
1303    }
1304
1305    #[tokio::test]
1306    async fn test_install_single_command_resource() -> anyhow::Result<()> {
1307        let temp = TempDir::new()?;
1308        let manifest_path = temp.path().join("agpm.toml");
1309        let command_file = temp.path().join("single-command.md");
1310        fs::write(
1311            &command_file,
1312            "# Command
1313Body",
1314        )?;
1315
1316        let mut manifest = Manifest::new();
1317        manifest.commands.insert(
1318            "cmd".into(),
1319            ResourceDependency::Detailed(Box::new(DetailedDependency {
1320                source: None,
1321                path: "single-command.md".into(),
1322                version: None,
1323                branch: None,
1324                rev: None,
1325                command: None,
1326                args: None,
1327                target: None,
1328                filename: None,
1329                dependencies: None,
1330                tool: Some("claude-code".to_string()),
1331                flatten: None,
1332                install: None,
1333
1334                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1335            })),
1336        );
1337        manifest.save(&manifest_path)?;
1338
1339        let cmd = InstallCommand::new();
1340        cmd.execute_from_path(Some(&manifest_path)).await?;
1341
1342        let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
1343        assert_eq!(lockfile.commands.len(), 1);
1344        assert!(temp.path().join(&lockfile.commands[0].installed_at).exists());
1345        Ok(())
1346    }
1347
1348    #[tokio::test]
1349    async fn test_install_dry_run_mode() -> Result<(), anyhow::Error> {
1350        let temp = TempDir::new()?;
1351        let manifest_path = temp.path().join("agpm.toml");
1352        let lockfile_path = temp.path().join("agpm.lock");
1353        let agent_file = temp.path().join("test-agent.md");
1354
1355        // Create a local file for the agent
1356        fs::write(&agent_file, "# Test Agent\nBody")?;
1357
1358        let mut manifest = Manifest::new();
1359        manifest.agents.insert(
1360            "test-agent".into(),
1361            ResourceDependency::Detailed(Box::new(DetailedDependency {
1362                source: None,
1363                path: "test-agent.md".into(),
1364                version: None,
1365                branch: None,
1366                rev: None,
1367                command: None,
1368                args: None,
1369                target: None,
1370                filename: None,
1371                dependencies: None,
1372                tool: Some("claude-code".to_string()),
1373                flatten: None,
1374                install: None,
1375
1376                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1377            })),
1378        );
1379        manifest.save(&manifest_path)?;
1380
1381        let cmd = InstallCommand {
1382            no_lock: false,
1383            frozen: false,
1384            no_cache: false,
1385            max_parallel: None,
1386            quiet: true, // Suppress output in test
1387            no_progress: true,
1388            verbose: false,
1389            no_transitive: false,
1390            dry_run: true,
1391            yes: false,
1392        };
1393
1394        // In dry-run mode, this should return an error indicating changes would be made
1395        let result = cmd.execute_from_path(Some(&manifest_path)).await;
1396
1397        // Should return an error because changes would be made
1398        assert!(result.is_err());
1399        let err_msg = result.unwrap_err().to_string();
1400        assert!(err_msg.contains("Dry-run detected changes"));
1401
1402        // Lockfile should NOT be created in dry-run mode
1403        assert!(!lockfile_path.exists());
1404        // Resource should NOT be installed
1405        assert!(!temp.path().join(".claude/agents/test-agent.md").exists());
1406        Ok(())
1407    }
1408
1409    #[tokio::test]
1410    async fn test_install_summary_with_mcp_servers() -> Result<(), anyhow::Error> {
1411        let temp = TempDir::new()?;
1412        let manifest_path = temp.path().join("agpm.toml");
1413        let agent_file = temp.path().join("summary-agent.md");
1414        fs::write(&agent_file, "# Agent\nBody")?;
1415
1416        let mcp_dir = temp.path().join("mcp");
1417        fs::create_dir_all(&mcp_dir)?;
1418        fs::write(mcp_dir.join("test-mcp.json"), "{\"name\":\"test\"}")?;
1419
1420        let mut manifest = Manifest::new();
1421        manifest.agents.insert(
1422            "summary".into(),
1423            ResourceDependency::Detailed(Box::new(DetailedDependency {
1424                source: None,
1425                path: "summary-agent.md".into(),
1426                version: None,
1427                branch: None,
1428                rev: None,
1429                command: None,
1430                args: None,
1431                target: None,
1432                filename: None,
1433                dependencies: None,
1434                tool: Some("claude-code".to_string()),
1435                flatten: None,
1436                install: None,
1437
1438                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1439            })),
1440        );
1441        manifest.add_mcp_server(
1442            "test-mcp".into(),
1443            ResourceDependency::Detailed(Box::new(DetailedDependency {
1444                source: None,
1445                path: "mcp/test-mcp.json".into(),
1446                version: None,
1447                branch: None,
1448                rev: None,
1449                command: None,
1450                args: None,
1451                target: None,
1452                filename: None,
1453                dependencies: None,
1454                tool: Some("claude-code".to_string()),
1455                flatten: None,
1456                install: None,
1457
1458                template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
1459            })),
1460        );
1461        manifest.save(&manifest_path)?;
1462
1463        let cmd = InstallCommand::new();
1464        cmd.execute_from_path(Some(&manifest_path)).await?;
1465        Ok(())
1466    }
1467}