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