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(¤t_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 ¤t_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}