use anyhow::{Context, Result};
use clap::Args;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::cache::Cache;
use crate::core::ResourceIterator;
use crate::installer::update_gitignore;
use crate::lockfile::LockFile;
use crate::manifest::{ResourceDependency, find_manifest_with_optional};
use crate::mcp::handlers::McpHandler;
use crate::resolver::DependencyResolver;
#[derive(Args)]
pub struct InstallCommand {
#[arg(long)]
no_lock: bool,
#[arg(long)]
frozen: bool,
#[arg(long)]
no_cache: bool,
#[arg(long, value_name = "NUM")]
max_parallel: Option<usize>,
#[arg(short, long)]
quiet: bool,
#[arg(skip)]
pub no_progress: bool,
#[arg(skip)]
pub verbose: bool,
#[arg(long)]
no_transitive: bool,
#[arg(long)]
dry_run: bool,
}
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,
}
}
#[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,
}
}
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().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 (manifest, _patch_conflicts) = Manifest::load_with_private(&manifest_path)?;
let lockfile_path =
manifest_path.parent().unwrap_or_else(|| Path::new(".")).join("agpm.lock");
if self.frozen && lockfile_path.exists() {
let lockfile = LockFile::load(&lockfile_path)?;
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."
));
}
}
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 lockfile_path.exists() {
Some(LockFile::load(&lockfile_path)?)
} else {
None
};
let cache = Cache::new()?;
let mut resolver =
DependencyResolver::new_with_global(manifest.clone(), cache.clone()).await?;
let has_remote_deps =
manifest.all_dependencies().iter().any(|(_, dep)| dep.get_source().is_some());
if !self.frozen && has_remote_deps {
if !self.quiet && !self.no_progress {
multi_phase.start_phase(InstallationPhase::SyncingSources, None);
}
let deps: Vec<(String, ResourceDependency)> = manifest
.all_dependencies_with_types()
.into_iter()
.map(|(name, dep, _resource_type)| (name.to_string(), dep.into_owned()))
.collect();
resolver.pre_sync_sources(&deps).await?;
if !self.quiet && !self.no_progress {
multi_phase.complete_phase(Some("Sources synced"));
}
}
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 !self.quiet && !self.no_progress && total_deps > 0 {
multi_phase.start_phase(InstallationPhase::ResolvingDependencies, None);
}
let result = resolver.update(&existing, None).await?;
if !self.quiet && !self.no_progress && total_deps > 0 {
multi_phase
.complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
}
result
}
} else {
if !self.quiet && !self.no_progress && total_deps > 0 {
multi_phase.start_phase(InstallationPhase::ResolvingDependencies, None);
}
let result = resolver.resolve_with_options(!self.no_transitive).await?;
if !self.quiet && !self.no_progress && total_deps > 0 {
multi_phase.complete_phase(Some(&format!("Resolved {total_deps} dependencies")));
}
result
};
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 {
let lockfile = Arc::new(lockfile);
return self.handle_dry_run(&lockfile, &lockfile_path, &multi_phase);
}
let total_resources = ResourceIterator::count_total_resources(&lockfile);
let mut installation_error = None;
let mut hook_count = 0;
let mut server_count = 0;
let installed_count = if total_resources == 0 {
0
} else {
if !self.quiet && !self.no_progress {
multi_phase.start_phase(
InstallationPhase::Installing,
Some(&format!("({total_resources} resources)")),
);
}
let max_concurrency = self.max_parallel.unwrap_or_else(|| {
let cores =
std::thread::available_parallelism().map(std::num::NonZero::get).unwrap_or(4);
std::cmp::max(10, cores * 2)
});
let lockfile_for_install = Arc::new(lockfile.clone());
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,
)
.await
{
Ok((count, checksums, applied_patches_list)) => {
for (name, checksum) in checksums {
lockfile.update_resource_checksum(&name, &checksum);
}
for (name, applied_patches) in applied_patches_list {
lockfile.update_resource_applied_patches(&name, &applied_patches);
}
if count > 0 && !self.quiet && !self.no_progress {
multi_phase.complete_phase(Some(&format!("Installed {count} resources")));
}
count
}
Err(e) => {
installation_error = Some(e);
0
}
}
};
if installation_error.is_none() {
if !lockfile.hooks.is_empty() {
crate::hooks::install_hooks(&lockfile, actual_project_dir, &cache).await?;
hook_count = lockfile.hooks.len();
}
if !lockfile.mcp_servers.is_empty() {
use std::collections::HashMap;
let mut servers_by_type: HashMap<String, Vec<&crate::lockfile::LockedResource>> =
HashMap::new();
for server in &lockfile.mcp_servers {
let tool = server.tool.clone().unwrap_or_else(|| "claude-code".to_string());
servers_by_type.entry(tool).or_default().push(server);
}
let mut all_mcp_patches: Vec<(String, crate::manifest::patches::AppliedPatches)> =
Vec::new();
for (artifact_type, servers) in servers_by_type {
if let Some(handler) = crate::mcp::handlers::get_mcp_handler(&artifact_type) {
let artifact_base = if let Some(artifact_path) =
manifest.get_tool_config(&artifact_type).map(|c| &c.path)
{
actual_project_dir.join(artifact_path)
} else {
#[allow(deprecated)]
actual_project_dir.join(match artifact_type.as_str() {
"claude-code" => ".claude",
"opencode" => ".opencode",
_ => continue, })
};
let server_entries: Vec<_> = servers.iter().map(|s| (*s).clone()).collect();
let applied_patches_list = handler
.configure_mcp_servers(
actual_project_dir,
&artifact_base,
&server_entries,
&cache,
&manifest,
)
.await
.with_context(|| {
format!(
"Failed to configure MCP servers for artifact type '{}'",
artifact_type
)
})?;
all_mcp_patches.extend(applied_patches_list);
server_count += servers.len();
}
}
for (name, applied_patches) in all_mcp_patches {
lockfile.update_resource_applied_patches(&name, &applied_patches);
}
if server_count > 0 && !self.quiet {
if server_count == 1 {
println!("✓ Configured 1 MCP server");
} else {
println!("✓ Configured {server_count} MCP servers");
}
}
}
if installation_error.is_none()
&& let Some(old) = old_lockfile
&& let Ok(removed) =
crate::installer::cleanup_removed_artifacts(&old, &lockfile, actual_project_dir)
.await
&& !removed.is_empty()
&& !self.quiet
{
println!("🗑️ Cleaned up {} moved or removed artifact(s)", removed.len());
}
if !self.quiet
&& !self.no_progress
&& (installed_count > 0 || hook_count > 0 || server_count > 0)
{
multi_phase.start_phase(InstallationPhase::Finalizing, None);
}
if !self.no_lock {
lockfile.save(&lockfile_path).with_context(|| {
format!("Failed to save lockfile to {}", lockfile_path.display())
})?;
use crate::lockfile::PrivateLockFile;
let mut private_lock = PrivateLockFile::new();
for (entry, _) in ResourceIterator::collect_all_entries(&lockfile, &manifest) {
let resource_type = entry.resource_type.to_plural();
let lookup_name = entry.manifest_alias.as_ref().unwrap_or(&entry.name);
if let Some(private_patches) =
manifest.private_patches.get(resource_type, lookup_name)
{
private_lock.add_private_patches(
resource_type,
&entry.name,
private_patches.clone(),
);
}
}
private_lock
.save(actual_project_dir)
.with_context(|| "Failed to save private lockfile".to_string())?;
}
update_gitignore(&lockfile, actual_project_dir, true)?;
if !self.quiet
&& !self.no_progress
&& (installed_count > 0 || hook_count > 0 || server_count > 0)
{
multi_phase.complete_phase(Some("Installation complete!"));
}
}
if let Some(error) = installation_error {
return Err(error);
}
if !self.quiet && !self.no_progress {
multi_phase.clear();
}
if self.no_progress
&& !self.quiet
&& installed_count == 0
&& hook_count == 0
&& server_count == 0
{
println!("\nNo dependencies to install");
}
Ok(())
}
fn handle_dry_run(
&self,
new_lockfile: &Arc<LockFile>,
lockfile_path: &Path,
multi_phase: &std::sync::Arc<crate::utils::progress::MultiPhaseProgress>,
) -> Result<()> {
use colored::Colorize;
if !self.quiet && !self.no_progress {
multi_phase.clear();
}
let mut new_resources = Vec::new();
let mut updated_resources = Vec::new();
let mut unchanged_count = 0;
let existing_lockfile = if lockfile_path.exists() {
LockFile::load(lockfile_path).ok()
} else {
None
};
if let Some(existing) = existing_lockfile.as_ref() {
ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
if let Some((_, old_entry)) = ResourceIterator::find_resource_by_name_and_source(
existing,
&new_entry.name,
new_entry.source.as_deref(),
) {
if old_entry.resolved_commit == new_entry.resolved_commit {
unchanged_count += 1;
} else {
let old_version =
old_entry.version.clone().unwrap_or_else(|| "latest".to_string());
let new_version =
new_entry.version.clone().unwrap_or_else(|| "latest".to_string());
updated_resources.push((
resource_type.to_string(),
new_entry.name.clone(),
old_version,
new_version,
));
}
} else {
new_resources.push((
resource_type.to_string(),
new_entry.name.clone(),
new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
));
}
});
} else {
ResourceIterator::for_each_resource(new_lockfile, |resource_type, new_entry| {
new_resources.push((
resource_type.to_string(),
new_entry.name.clone(),
new_entry.version.clone().unwrap_or_else(|| "latest".to_string()),
));
});
}
let has_changes = !new_resources.is_empty() || !updated_resources.is_empty();
if !self.quiet {
if has_changes {
println!("{}", "Dry run - the following changes would be made:".yellow());
println!();
if !new_resources.is_empty() {
println!("{}", "New resources:".green().bold());
for (resource_type, name, version) in &new_resources {
println!(
" {} {} ({})",
"+".green(),
name.cyan(),
format!("{resource_type} {version}").dimmed()
);
}
println!();
}
if !updated_resources.is_empty() {
println!("{}", "Updated resources:".yellow().bold());
for (resource_type, name, old_ver, new_ver) in &updated_resources {
print!(" {} {} {} → ", "~".yellow(), name.cyan(), old_ver.yellow());
println!("{} ({})", new_ver.green(), resource_type.dimmed());
}
println!();
}
if unchanged_count > 0 {
println!("{}", format!("{unchanged_count} unchanged resources").dimmed());
}
println!();
println!(
"{}",
format!(
"Total: {} new, {} updated, {} unchanged",
new_resources.len(),
updated_resources.len(),
unchanged_count
)
.bold()
);
println!();
println!("{}", "No files were modified (dry-run mode)".yellow());
} else {
println!("✓ {}", "No changes would be made".green());
}
}
if has_changes {
return Err(anyhow::anyhow!("Dry-run detected changes (exit 1)"));
}
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.name == new_resource.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.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() {
let temp = TempDir::new().unwrap();
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"));
}
#[tokio::test]
async fn test_install_with_empty_manifest() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
Manifest::new().save(&manifest_path).unwrap();
let cmd = InstallCommand::new();
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_ok());
let lockfile_path = temp.path().join("agpm.lock");
assert!(lockfile_path.exists());
let lockfile = LockFile::load(&lockfile_path).unwrap();
assert!(lockfile.agents.is_empty());
assert!(lockfile.snippets.is_empty());
}
#[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() {
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,
};
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_ok());
assert!(!temp.path().join("agpm.lock").exists());
}
#[tokio::test]
async fn test_install_with_local_dependency() {
let temp = TempDir::new().unwrap();
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.",
)
.unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
let cmd = InstallCommand::new();
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_ok());
assert!(temp.path().join(".claude/agents/local-agent.md").exists());
}
#[tokio::test]
async fn test_install_with_invalid_manifest_syntax() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
fs::write(&manifest_path, "[invalid toml").unwrap();
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("Cannot read manifest")
|| err_str.contains("unclosed")
|| err_str.contains("parse")
|| err_str.contains("expected")
|| err_str.contains("invalid"),
"Unexpected error message: {}",
err_str
);
}
#[tokio::test]
async fn test_install_uses_existing_lockfile_when_frozen() {
let temp = TempDir::new().unwrap();
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",
)
.unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
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,
applied_patches: std::collections::HashMap::new(),
install: None,
}],
snippets: vec![],
mcp_servers: vec![],
scripts: vec![],
hooks: vec![],
}
.save(&lockfile_path)
.unwrap();
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,
};
let result = cmd.execute_from_path(Some(&manifest_path)).await;
assert!(result.is_ok());
assert!(temp.path().join(".claude/agents/test-agent.md").exists());
}
#[tokio::test]
async fn test_install_errors_when_local_file_missing() {
let temp = TempDir::new().unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
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"),
"Error should indicate resource fetch failure, got: {}",
err_string
);
}
#[tokio::test]
async fn test_install_single_resource_paths() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let snippet_file = temp.path().join("single-snippet.md");
fs::write(
&snippet_file,
"# Snippet
Body",
)
.unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
let cmd = InstallCommand::new();
assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
let lockfile = LockFile::load(&temp.path().join("agpm.lock")).unwrap();
assert_eq!(lockfile.snippets.len(), 1);
let installed_path = temp.path().join(&lockfile.snippets[0].installed_at);
assert!(installed_path.exists());
}
#[tokio::test]
async fn test_install_single_command_resource() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let command_file = temp.path().join("single-command.md");
fs::write(
&command_file,
"# Command
Body",
)
.unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
let cmd = InstallCommand::new();
assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
let lockfile = LockFile::load(&temp.path().join("agpm.lock")).unwrap();
assert_eq!(lockfile.commands.len(), 1);
assert!(temp.path().join(&lockfile.commands[0].installed_at).exists());
}
#[tokio::test]
async fn test_install_dry_run_mode() {
let temp = TempDir::new().unwrap();
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").unwrap();
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,
})),
);
manifest.save(&manifest_path).unwrap();
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,
};
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());
}
#[tokio::test]
async fn test_install_summary_with_mcp_servers() {
let temp = TempDir::new().unwrap();
let manifest_path = temp.path().join("agpm.toml");
let agent_file = temp.path().join("summary-agent.md");
fs::write(&agent_file, "# Agent\nBody").unwrap();
let mcp_dir = temp.path().join("mcp");
fs::create_dir_all(&mcp_dir).unwrap();
fs::write(mcp_dir.join("test-mcp.json"), "{\"name\":\"test\"}").unwrap();
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,
})),
);
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,
})),
);
manifest.save(&manifest_path).unwrap();
let cmd = InstallCommand::new();
assert!(cmd.execute_from_path(Some(&manifest_path)).await.is_ok());
}
}