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