use anyhow::Result;
use clap::Args;
use std::path::{Path, PathBuf};
use crate::cache::Cache;
use crate::constants::{FALLBACK_CORE_COUNT, MIN_PARALLELISM, PARALLELISM_CORE_MULTIPLIER};
use crate::core::{OperationContext, ResourceIterator};
use crate::lockfile::LockFile;
use crate::manifest::{ResourceDependency, find_manifest_with_optional};
use crate::resolver::DependencyResolver;
fn can_use_fast_path(
existing_lockfile: Option<&LockFile>,
current_manifest_hash: &str,
has_mutable_deps: bool,
frozen: bool,
) -> bool {
if frozen {
return false;
}
let Some(existing) = existing_lockfile else {
return false;
};
if !existing.has_valid_fast_path_metadata() {
tracing::debug!("Fast path disabled: lockfile missing fast-path metadata fields");
return false;
}
if !existing.has_valid_manifest_hash_format() {
tracing::debug!("Fast path disabled: lockfile has invalid manifest_hash format");
return false;
}
let hash_matches = existing.manifest_hash.as_ref() == Some(¤t_manifest_hash.to_string());
if !hash_matches {
return false;
}
let no_mutable_deps = existing.has_mutable_deps == Some(false) && !has_mutable_deps;
if !no_mutable_deps {
return false;
}
if !existing.has_valid_resource_count() {
tracing::debug!(
"Fast path disabled: resource count mismatch (stored: {:?}, actual: {})",
existing.resource_count,
existing.all_resources().len()
);
return false;
}
true
}
#[derive(Args)]
pub struct InstallCommand {
#[arg(long)]
pub no_lock: bool,
#[arg(long)]
pub frozen: bool,
#[arg(long)]
pub no_cache: bool,
#[arg(long, value_name = "NUM")]
pub max_parallel: Option<usize>,
#[arg(short, long)]
pub quiet: bool,
#[arg(skip)]
pub no_progress: bool,
#[arg(skip)]
pub verbose: bool,
#[arg(long)]
pub no_transitive: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(short = 'y', long)]
pub yes: bool,
}
impl Default for InstallCommand {
fn default() -> Self {
Self::new()
}
}
impl InstallCommand {
#[allow(dead_code)] pub const fn new() -> Self {
Self {
no_lock: false,
frozen: false,
no_cache: false,
max_parallel: None,
quiet: false,
no_progress: false,
verbose: false,
no_transitive: false,
dry_run: false,
yes: false,
}
}
#[allow(dead_code)] pub const fn new_quiet() -> Self {
Self {
no_lock: false,
frozen: false,
no_cache: false,
max_parallel: None,
quiet: true,
no_progress: true,
verbose: false,
no_transitive: false,
dry_run: false,
yes: false,
}
}
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
let manifest_path = if let Ok(path) = find_manifest_with_optional(manifest_path) {
path
} else {
match crate::cli::common::handle_legacy_ccpm_migration(None, self.yes).await {
Ok(Some(path)) => path,
Ok(None) => {
return Err(anyhow::anyhow!(
"No agpm.toml found in current directory or any parent directory.\n\n\
To get started, create a agpm.toml file with your dependencies:\n\n\
[sources]\n\
official = \"https://github.com/example-org/agpm-official.git\"\n\n\
[agents]\n\
my-agent = {{ source = \"official\", path = \"agents/my-agent.md\", version = \"v1.0.0\" }}"
));
}
Err(e) => return Err(e),
}
};
self.execute_from_path(Some(&manifest_path)).await
}
pub async fn execute_from_path(&self, path: Option<&Path>) -> Result<()> {
use crate::installer::{ResourceFilter, install_resources};
use crate::manifest::Manifest;
use crate::utils::progress::{InstallationPhase, MultiPhaseProgress};
use std::sync::Arc;
let manifest_path = if let Some(p) = path {
p.to_path_buf()
} else {
std::env::current_dir()?.join("agpm.toml")
};
if !manifest_path.exists() {
return Err(anyhow::anyhow!("No agpm.toml found at {}", manifest_path.display()));
}
let (mut manifest, _patch_conflicts) = Manifest::load_with_private(&manifest_path)?;
let project_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let mut command_context =
crate::cli::common::CommandContext::new(manifest.clone(), project_dir.to_path_buf())?;
let lockfile_path = project_dir.join("agpm.lock");
if self.frozen && lockfile_path.exists() {
match LockFile::load(&lockfile_path) {
Ok(lockfile) => {
if let Some(reason) = lockfile.validate_against_manifest(&manifest, false)? {
return Err(anyhow::anyhow!(
"Lockfile has critical issues in --frozen mode:\n\n\
{reason}\n\n\
Hint: Fix the issue or remove --frozen flag."
));
}
}
Err(e) => {
return Err(anyhow::anyhow!(
"Cannot proceed in --frozen mode due to invalid lockfile.\n\n\
Error: {}\n\n\
In --frozen mode, the lockfile must be valid.\n\
Fix the lockfile manually or remove the --frozen flag to allow regeneration.\n\n\
Note: The lockfile format is not yet stable as this is beta software.",
e
));
}
}
}
let total_deps = manifest.all_dependencies().len();
let multi_phase = Arc::new(MultiPhaseProgress::new(!self.quiet && !self.no_progress));
let actual_project_dir =
manifest_path.parent().ok_or_else(|| anyhow::anyhow!("Invalid manifest path"))?;
let lockfile_path = actual_project_dir.join("agpm.lock");
let existing_lockfile = if !self.frozen {
command_context.load_lockfile_with_regeneration(true, "install")?
} else {
if lockfile_path.exists() {
let mut lockfile = LockFile::load(&lockfile_path)?;
if let Ok(Some(private_lock)) =
crate::lockfile::PrivateLockFile::load(actual_project_dir)
{
lockfile.merge_private(&private_lock);
}
Some(lockfile)
} else {
None
}
};
let existing_lockfile = if existing_lockfile.is_some() && !self.frozen {
let migrated =
crate::cli::common::handle_legacy_format_migration(actual_project_dir, self.yes)
.await?;
if migrated {
command_context.reload_manifest()?;
manifest = command_context.manifest.clone();
command_context.load_lockfile_with_regeneration(true, "install")?
} else {
existing_lockfile
}
} else {
existing_lockfile
};
let cache = Cache::new()?;
let max_concurrency = self.max_parallel.unwrap_or_else(|| {
let cores = std::thread::available_parallelism()
.map(std::num::NonZero::get)
.unwrap_or(FALLBACK_CORE_COUNT);
std::cmp::max(MIN_PARALLELISM, cores * PARALLELISM_CORE_MULTIPLIER)
});
let operation_context = Arc::new(OperationContext::new());
let mut resolver = DependencyResolver::new_with_global_concurrency(
manifest.clone(),
cache.clone(),
Some(max_concurrency),
Some(operation_context.clone()),
)
.await?;
let has_remote_deps =
manifest.all_dependencies().iter().any(|(_, dep)| dep.get_source().is_some());
let current_manifest_hash = manifest.compute_dependency_hash();
let has_mutable = manifest.has_mutable_dependencies();
let use_fast_path = can_use_fast_path(
existing_lockfile.as_ref(),
¤t_manifest_hash,
has_mutable,
self.frozen,
);
if !self.frozen && has_remote_deps && !use_fast_path {
let deps: Vec<(String, ResourceDependency)> = manifest
.all_dependencies_with_types()
.into_iter()
.map(|(name, dep, _resource_type)| (name.to_string(), dep.into_owned()))
.collect();
let progress = if !self.quiet && !self.no_progress {
Some(multi_phase.clone())
} else {
None
};
resolver.pre_sync_sources(&deps, progress).await?;
} else if use_fast_path && !self.quiet && !self.no_progress {
multi_phase.start_phase(InstallationPhase::SyncingSources, None);
multi_phase.complete_phase(Some("Sources up to date"));
}
let mut lockfile = if let Some(existing) = existing_lockfile {
if self.frozen {
if !self.quiet {
println!("✓ Using frozen lockfile ({total_deps} dependencies)");
}
existing
} else if use_fast_path {
tracing::info!(
"Fast path: manifest unchanged with immutable deps, using cached lockfile"
);
if !self.quiet && !self.no_progress {
multi_phase.start_phase(
InstallationPhase::ResolvingDependencies,
Some(&format!("({total_deps} dependencies)")),
);
multi_phase
.complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
}
existing
} else {
let progress = if !self.quiet && !self.no_progress {
Some(multi_phase.clone())
} else {
None
};
resolver.update(&existing, None, progress).await?
}
} else {
let progress = if !self.quiet && !self.no_progress {
Some(multi_phase.clone())
} else {
None
};
resolver.resolve_with_options(!self.no_transitive, progress).await?
};
lockfile.manifest_hash = Some(current_manifest_hash);
lockfile.has_mutable_deps = Some(has_mutable);
lockfile.resource_count = Some(lockfile.all_resources().len());
let old_lockfile = if !self.frozen && lockfile_path.exists() {
if let Ok(old) = LockFile::load(&lockfile_path) {
detect_tag_movement(&old, &lockfile, self.quiet);
Some(old)
} else {
None
}
} else {
None
};
if self.dry_run {
return crate::cli::common::display_dry_run_results(
&lockfile,
old_lockfile.as_ref(),
self.quiet,
);
}
let _resource_lock =
crate::installer::ProjectLock::acquire(actual_project_dir, "resource").await?;
let total_resources = ResourceIterator::count_total_resources(&lockfile);
let mut installation_error = None;
let mut hook_count = 0;
let mut server_count = 0;
let all_files_exist = use_fast_path
&& lockfile.all_resources().iter().all(|res| {
if res.install == Some(false) {
return true; }
if res.installed_at.is_empty() {
return true; }
actual_project_dir.join(&res.installed_at).exists()
});
let installed_count = if total_resources == 0 {
0
} else if all_files_exist {
if !self.quiet && !self.no_progress {
multi_phase.start_phase(
InstallationPhase::Installing,
Some(&format!("({total_resources} resources)")),
);
multi_phase.complete_phase(Some("All up to date"));
}
tracing::info!(
"Ultra-fast path: all {} files exist, skipping installation",
total_resources
);
0 } else {
if !self.quiet && !self.no_progress {
multi_phase.start_phase(
InstallationPhase::Installing,
Some(&format!("({total_resources} resources)")),
);
}
let lockfile_for_install = Arc::new(lockfile.clone());
let global_config = crate::config::GlobalConfig::load().await.unwrap_or_default();
let token_warning_threshold =
manifest.token_warning_threshold.unwrap_or(global_config.token_warning_threshold);
match install_resources(
ResourceFilter::All,
&lockfile_for_install,
&manifest,
actual_project_dir,
cache.clone(),
self.no_cache,
Some(max_concurrency),
Some(multi_phase.clone()),
self.verbose,
old_lockfile.as_ref(), use_fast_path, Some(token_warning_threshold),
)
.await
{
Ok(results) => {
lockfile.apply_installation_results(
results.checksums,
results.context_checksums,
results.applied_patches,
results.token_counts,
);
results.installed_count
}
Err(e) => {
installation_error = Some(e);
0
}
}
};
if installation_error.is_none() {
if !self.quiet && !self.no_progress && installed_count > 0 {
multi_phase.start_phase(InstallationPhase::Finalizing, None);
}
let (hook_count_result, server_count_result) = crate::installer::finalize_installation(
&mut lockfile,
&manifest,
actual_project_dir,
&cache,
old_lockfile.as_ref(),
self.quiet,
self.no_lock,
)
.await?;
hook_count = hook_count_result;
server_count = server_count_result;
if !self.quiet && !self.no_progress && installed_count > 0 {
multi_phase.complete_phase(Some("Installation finalized"));
}
}
if let Some(error) = installation_error {
return Err(error);
}
if !self.quiet && installed_count > 0 {
let validation =
crate::installer::validate_config(project_dir, &lockfile, manifest.gitignore).await;
if let Some(warning) = &validation.claude_settings_warning {
eprintln!("\n{}", warning);
}
if !validation.missing_gitignore_entries.is_empty() {
let _ = crate::cli::common::handle_missing_gitignore_entries(
&validation,
project_dir,
self.yes,
)
.await;
}
}
if self.no_progress
&& !self.quiet
&& installed_count == 0
&& hook_count == 0
&& server_count == 0
{
crate::cli::common::display_no_changes(
crate::cli::common::OperationMode::Install,
self.quiet,
);
}
Ok(())
}
}
fn detect_tag_movement(old_lockfile: &LockFile, new_lockfile: &LockFile, quiet: bool) {
use crate::core::ResourceType;
fn is_tag_like(version: &str) -> bool {
if version.len() >= 7 && version.chars().all(|c| c.is_ascii_hexdigit()) {
return false;
}
if matches!(
version,
"main" | "master" | "develop" | "dev" | "staging" | "production" | "HEAD"
) || version.starts_with("release/")
|| version.starts_with("feature/")
|| version.starts_with("hotfix/")
|| version.starts_with("bugfix/")
{
return false;
}
version.starts_with('v')
|| version.starts_with("release-")
|| version.parse::<semver::Version>().is_ok()
|| version.contains('.') }
fn check_resources(
old_resources: &[crate::lockfile::LockedResource],
new_resources: &[crate::lockfile::LockedResource],
resource_type: ResourceType,
quiet: bool,
) {
for new_resource in new_resources {
let Some(ref new_version) = new_resource.version else {
continue;
};
let Some(ref new_commit) = new_resource.resolved_commit else {
continue;
};
if !is_tag_like(new_version) {
continue;
}
if let Some(old_resource) =
old_resources.iter().find(|r| r.display_name() == new_resource.display_name())
&& let (Some(old_version), Some(old_commit)) =
(&old_resource.version, &old_resource.resolved_commit)
{
if old_version == new_version && old_commit != new_commit && !quiet {
eprintln!(
"⚠️ Warning: Tag '{}' for {} '{}' has moved from {} to {}",
new_version,
resource_type,
new_resource.display_name(),
&old_commit[..8.min(old_commit.len())],
&new_commit[..8.min(new_commit.len())]
);
eprintln!(
" Tags should be immutable. This may indicate the upstream repository force-pushed the tag."
);
}
}
}
}
check_resources(&old_lockfile.agents, &new_lockfile.agents, ResourceType::Agent, quiet);
check_resources(&old_lockfile.snippets, &new_lockfile.snippets, ResourceType::Snippet, quiet);
check_resources(&old_lockfile.commands, &new_lockfile.commands, ResourceType::Command, quiet);
check_resources(&old_lockfile.scripts, &new_lockfile.scripts, ResourceType::Script, quiet);
check_resources(&old_lockfile.hooks, &new_lockfile.hooks, ResourceType::Hook, quiet);
check_resources(
&old_lockfile.mcp_servers,
&new_lockfile.mcp_servers,
ResourceType::McpServer,
quiet,
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::{DetailedDependency, Manifest, ResourceDependency};
use std::fs;
use tempfile::TempDir;
#[tokio::test]
async fn test_install_command_no_manifest() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let cmd = InstallCommand::new();
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("agpm.toml"));
Ok(())
}
#[tokio::test]
async fn test_install_with_empty_manifest() -> Result<()> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
Manifest::new().save(&manifest_path)?;
let cmd = InstallCommand::new();
cmd.execute_from_path(Some(&manifest_path)).await?;
let lockfile_path = temp.path().join("agpm.lock");
assert!(lockfile_path.exists());
let lockfile = LockFile::load(&lockfile_path)?;
assert!(lockfile.agents.is_empty());
assert!(lockfile.snippets.is_empty());
Ok(())
}
#[tokio::test]
async fn test_install_command_new_defaults() {
let cmd = InstallCommand::new();
assert!(!cmd.no_lock);
assert!(!cmd.frozen);
assert!(!cmd.no_cache);
assert!(cmd.max_parallel.is_none());
assert!(!cmd.quiet);
}
#[tokio::test]
async fn test_install_respects_no_lock_flag() -> anyhow::Result<()> {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
Manifest::new().save(&manifest_path).unwrap();
let cmd = InstallCommand {
no_lock: true,
frozen: false,
no_cache: false,
max_parallel: None,
quiet: false,
no_progress: false,
verbose: false,
no_transitive: false,
dry_run: false,
yes: false,
};
cmd.execute_from_path(Some(&manifest_path)).await?;
assert!(!temp.path().join("agpm.lock").exists());
Ok(())
}
#[tokio::test]
async fn test_install_with_local_dependency() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let local_file = temp.path().join("local-agent.md");
fs::write(
&local_file,
"# Local Agent
This is a test agent.",
)?;
let mut manifest = Manifest::new();
manifest.agents.insert(
"local-agent".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "local-agent.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let cmd = InstallCommand::new();
cmd.execute_from_path(Some(&manifest_path)).await?;
assert!(temp.path().join(".claude/agents/agpm/local-agent.md").exists());
Ok(())
}
#[tokio::test]
async fn test_install_with_invalid_manifest_syntax() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
fs::write(&manifest_path, "[invalid toml")?;
let cmd = InstallCommand::new();
let err = cmd.execute_from_path(Some(temp.path())).await.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("File operation failed")
|| err_str.contains("Failed reading file")
|| err_str.contains("Cannot read manifest")
|| err_str.contains("unclosed")
|| err_str.contains("parse")
|| err_str.contains("expected")
|| err_str.contains("invalid"),
"Unexpected error message: {}",
err_str
);
Ok(())
}
#[tokio::test]
async fn test_install_uses_existing_lockfile_when_frozen() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let local_file = temp.path().join("test-agent.md");
fs::write(
&local_file,
"# Test Agent
Body",
)?;
let mut manifest = Manifest::new();
manifest.agents.insert(
"test-agent".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "test-agent.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
LockFile {
version: 1,
sources: vec![],
commands: vec![],
agents: vec![LockedResource {
name: "test-agent".into(),
source: None,
url: None,
path: "test-agent.md".into(),
version: None,
resolved_commit: None,
checksum: String::new(),
installed_at: ".claude/agents/test-agent.md".into(),
dependencies: vec![],
resource_type: crate::core::ResourceType::Agent,
tool: Some("claude-code".to_string()),
manifest_alias: None,
context_checksum: None,
applied_patches: std::collections::BTreeMap::new(),
install: None,
variant_inputs: crate::resolver::lockfile_builder::VariantInputs::default(),
is_private: false,
approximate_token_count: None,
}],
snippets: vec![],
mcp_servers: vec![],
scripts: vec![],
hooks: vec![],
skills: vec![],
manifest_hash: None,
has_mutable_deps: None,
resource_count: None,
}
.save(&lockfile_path)?;
let cmd = InstallCommand {
no_lock: false,
frozen: true,
no_cache: false,
max_parallel: None,
quiet: false,
no_progress: false,
verbose: false,
no_transitive: false,
dry_run: false,
yes: false,
};
cmd.execute_from_path(Some(&manifest_path)).await?;
assert!(temp.path().join(".claude/agents/test-agent.md").exists());
Ok(())
}
#[tokio::test]
async fn test_install_errors_when_local_file_missing() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let mut manifest = Manifest::new();
manifest.agents.insert(
"missing".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "missing.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let err = InstallCommand::new().execute_from_path(Some(&manifest_path)).await.unwrap_err();
let err_string = err.to_string();
assert!(
err_string.contains("Failed to fetch resource")
|| err_string.contains("local file")
|| err_string.contains("Failed to install 1 resources:"),
"Error should indicate resource fetch failure, got: {}",
err_string
);
Ok(())
}
#[tokio::test]
async fn test_install_single_resource_paths() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let snippet_file = temp.path().join("single-snippet.md");
fs::write(
&snippet_file,
"# Snippet
Body",
)?;
let mut manifest = Manifest::new();
manifest.snippets.insert(
"single".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "single-snippet.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let cmd = InstallCommand::new();
cmd.execute_from_path(Some(&manifest_path)).await?;
let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
assert_eq!(lockfile.snippets.len(), 1);
let installed_path = temp.path().join(&lockfile.snippets[0].installed_at);
assert!(installed_path.exists());
Ok(())
}
#[tokio::test]
async fn test_install_single_command_resource() -> anyhow::Result<()> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let command_file = temp.path().join("single-command.md");
fs::write(
&command_file,
"# Command
Body",
)?;
let mut manifest = Manifest::new();
manifest.commands.insert(
"cmd".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "single-command.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let cmd = InstallCommand::new();
cmd.execute_from_path(Some(&manifest_path)).await?;
let lockfile = LockFile::load(&temp.path().join("agpm.lock"))?;
assert_eq!(lockfile.commands.len(), 1);
assert!(temp.path().join(&lockfile.commands[0].installed_at).exists());
Ok(())
}
#[tokio::test]
async fn test_install_dry_run_mode() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let lockfile_path = temp.path().join("agpm.lock");
let agent_file = temp.path().join("test-agent.md");
fs::write(&agent_file, "# Test Agent\nBody")?;
let mut manifest = Manifest::new();
manifest.agents.insert(
"test-agent".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "test-agent.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let cmd = InstallCommand {
no_lock: false,
frozen: false,
no_cache: false,
max_parallel: None,
quiet: true, no_progress: true,
verbose: false,
no_transitive: false,
dry_run: true,
yes: false,
};
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Dry-run detected changes"));
assert!(!lockfile_path.exists());
assert!(!temp.path().join(".claude/agents/test-agent.md").exists());
Ok(())
}
#[tokio::test]
async fn test_install_summary_with_mcp_servers() -> Result<(), anyhow::Error> {
let temp = TempDir::new()?;
let manifest_path = temp.path().join("agpm.toml");
let agent_file = temp.path().join("summary-agent.md");
fs::write(&agent_file, "# Agent\nBody")?;
let mcp_dir = temp.path().join("mcp");
fs::create_dir_all(&mcp_dir)?;
fs::write(mcp_dir.join("test-mcp.json"), "{\"name\":\"test\"}")?;
let mut manifest = Manifest::new();
manifest.agents.insert(
"summary".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "summary-agent.md".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.add_mcp_server(
"test-mcp".into(),
ResourceDependency::Detailed(Box::new(DetailedDependency {
source: None,
path: "mcp/test-mcp.json".into(),
version: None,
branch: None,
rev: None,
command: None,
args: None,
target: None,
filename: None,
dependencies: None,
tool: Some("claude-code".to_string()),
flatten: None,
install: None,
template_vars: Some(serde_json::Value::Object(serde_json::Map::new())),
})),
);
manifest.save(&manifest_path)?;
let cmd = InstallCommand::new();
cmd.execute_from_path(Some(&manifest_path)).await?;
Ok(())
}
}