use anyhow::{Result, anyhow};
use clap::Args;
use colored::Colorize;
use std::fs;
use std::path::PathBuf;
const DEFAULT_MANIFEST_TEMPLATE: &str = r#"# AGPM Manifest
# This file defines your Claude Code resource dependencies
[sources]
# Add your Git repository sources here
# Example: official = "https://github.com/aig787/agpm-community.git"
# Project-specific template variables (optional)
# Provides context to AI agents - use any structure you want!
# [project]
# style_guide = "docs/STYLE_GUIDE.md"
# max_line_length = 100
# test_framework = "pytest"
#
# [project.paths]
# architecture = "docs/ARCHITECTURE.md"
# conventions = "docs/CONVENTIONS.md"
#
# Access in templates: {{ agpm.project.style_guide }}
# Tool type configurations (multi-tool support)
[tools.claude-code]
path = ".claude"
resources = { agents = { path = "agents", flatten = true }, snippets = { path = "snippets" }, commands = { path = "commands", flatten = true }, scripts = { path = "scripts" }, hooks = { merge-target = ".claude/settings.local.json" }, mcp-servers = { merge-target = ".mcp.json" } }
# Note: hooks and mcp-servers merge into configuration files (no file installation)
[tools.opencode]
path = ".opencode"
enabled = false
resources = { agents = { path = "agent", flatten = true }, commands = { path = "command", flatten = true }, mcp-servers = { merge-target = ".opencode/opencode.json" } }
# Note: MCP servers merge into opencode.json (no file installation)
[tools.agpm]
path = ".agpm"
resources = { snippets = { path = "snippets" } }
[agents]
# Add your agent dependencies here
# Example: my-agent = { source = "official", path = "agents/my-agent.md", version = "v1.0.0" }
# For OpenCode: my-agent = { source = "official", path = "agents/my-agent.md", version = "v1.0.0", tool = "opencode" }
[snippets]
# Add your snippet dependencies here
# Example: utils = { source = "official", path = "snippets/utils.md", tool = "agpm" }
[commands]
# Add your command dependencies here
# Example: deploy = { source = "official", path = "commands/deploy.md" }
[scripts]
# Add your script dependencies here
# Example: build = { source = "official", path = "scripts/build.sh" }
[hooks]
# Add your hook dependencies here
# Example: pre-commit = { source = "official", path = "hooks/pre-commit.json" }
[mcp-servers]
# Add your MCP server dependencies here
# Example: filesystem = { source = "official", path = "mcp-servers/filesystem.json" }
"#;
#[derive(Args)]
pub struct InitCommand {
#[arg(short, long)]
path: Option<PathBuf>,
#[arg(short, long)]
force: bool,
#[arg(long)]
defaults: bool,
}
impl InitCommand {
fn update_gitignore(target_dir: &std::path::Path) -> Result<()> {
let gitignore_path = target_dir.join(".gitignore");
let entries = [".agpm/backups/", "agpm.private.toml", "agpm.private.lock"];
let mut content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path)?
} else {
String::new()
};
let entries_to_add: Vec<&str> = entries
.iter()
.filter(|entry| !content.lines().any(|line| line.trim() == **entry))
.copied()
.collect();
if entries_to_add.is_empty() {
return Ok(());
}
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
if !content.is_empty() {
content.push('\n');
content.push_str("# AGPM\n");
}
for entry in entries_to_add {
content.push_str(entry);
content.push('\n');
}
fs::write(&gitignore_path, content)?;
Ok(())
}
pub async fn execute_with_manifest_path(
self,
_manifest_path: Option<std::path::PathBuf>,
) -> Result<()> {
self.execute().await
}
pub async fn execute(self) -> Result<()> {
if self.defaults {
return self.execute_with_defaults().await;
}
let target_dir = self.path.unwrap_or_else(|| PathBuf::from("."));
let manifest_path = target_dir.join("agpm.toml");
if manifest_path.exists() && !self.force {
return Err(anyhow!(
"Manifest already exists at {}. Use --force to overwrite",
manifest_path.display()
));
}
if !target_dir.exists() {
fs::create_dir_all(&target_dir)?;
}
fs::write(&manifest_path, DEFAULT_MANIFEST_TEMPLATE)?;
Self::update_gitignore(&target_dir)?;
println!("{} Initialized agpm.toml at {}", "✓".green(), manifest_path.display());
println!("\n{}", "Next steps:".cyan());
println!(" Add dependencies with {}:", "agpm add".bright_white());
println!(
" agpm add agent my-agent --source https://github.com/org/repo.git --path agents/my-agent.md"
);
println!(" agpm add snippet utils --path ../local/snippets/utils.md");
println!("\n Then run {} to install", "agpm install".bright_white());
Ok(())
}
async fn execute_with_defaults(&self) -> Result<()> {
let target_dir = self.path.clone().unwrap_or_else(|| PathBuf::from("."));
let manifest_path = target_dir.join("agpm.toml");
if !manifest_path.exists() {
return Err(anyhow!(
"No manifest found at {}\nRun 'agpm init' first to create a new manifest.",
manifest_path.display()
));
}
let default_doc = DEFAULT_MANIFEST_TEMPLATE
.parse::<toml_edit::DocumentMut>()
.map_err(|e| anyhow!("Failed to parse default template: {}", e))?;
let existing_content = fs::read_to_string(&manifest_path)?;
let mut existing_doc = existing_content
.parse::<toml_edit::DocumentMut>()
.map_err(|e| anyhow!("Failed to parse existing manifest: {}", e))?;
Self::merge_toml_documents(&mut existing_doc, &default_doc);
fs::write(&manifest_path, existing_doc.to_string())?;
Self::update_gitignore(&target_dir)?;
println!("{} Updated agpm.toml with default configurations", "✓".green());
Ok(())
}
fn merge_toml_documents(
existing: &mut toml_edit::DocumentMut,
defaults: &toml_edit::DocumentMut,
) {
Self::merge_toml_tables(existing.as_table_mut(), defaults.as_table());
}
fn merge_toml_tables(existing: &mut toml_edit::Table, defaults: &toml_edit::Table) {
for (key, default_value) in defaults.iter() {
if !existing.contains_key(key) {
existing.insert(key, default_value.clone());
} else {
match (existing.get_mut(key), default_value) {
(
Some(toml_edit::Item::Table(existing_table)),
toml_edit::Item::Table(default_table),
) => {
Self::merge_toml_tables(existing_table, default_table);
}
(
Some(toml_edit::Item::Value(toml_edit::Value::InlineTable(
existing_inline,
))),
toml_edit::Item::Value(toml_edit::Value::InlineTable(default_inline)),
) => {
for (inline_key, inline_default_value) in default_inline.iter() {
if !existing_inline.contains_key(inline_key) {
existing_inline.insert(inline_key, inline_default_value.clone());
}
}
}
_ => {
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_init_creates_manifest() {
let temp_dir = TempDir::new().unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let manifest_path = temp_dir.path().join("agpm.toml");
assert!(manifest_path.exists());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("[sources]"));
assert!(content.contains("[agents]"));
assert!(content.contains("[snippets]"));
}
#[tokio::test]
async fn test_init_creates_directory_if_not_exists() {
let temp_dir = TempDir::new().unwrap();
let new_dir = temp_dir.path().join("new_project");
let cmd = InitCommand {
path: Some(new_dir.clone()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
assert!(new_dir.exists());
assert!(new_dir.join("agpm.toml").exists());
}
#[tokio::test]
async fn test_init_fails_if_manifest_exists() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
fs::write(&manifest_path, "existing content").unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("already exists"));
}
#[tokio::test]
async fn test_init_force_overwrites_existing() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
fs::write(&manifest_path, "old content").unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: true,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("[sources]"));
assert!(!content.contains("old content"));
}
#[tokio::test]
async fn test_init_uses_current_dir_by_default() {
let temp_dir = TempDir::new().unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
assert!(temp_dir.path().join("agpm.toml").exists());
}
#[tokio::test]
async fn test_init_template_content() {
let temp_dir = TempDir::new().unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let manifest_path = temp_dir.path().join("agpm.toml");
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("# AGPM Manifest"));
assert!(content.contains("# This file defines your Claude Code resource dependencies"));
assert!(content.contains("# Add your Git repository sources here"));
assert!(content.contains("# Example: official ="));
assert!(content.contains("# Add your agent dependencies here"));
assert!(content.contains("# Example: my-agent ="));
assert!(content.contains("# Add your snippet dependencies here"));
assert!(content.contains("# Example: utils ="));
assert!(content.contains("[tools.opencode]"));
assert!(content.contains("flatten = true"));
assert!(
content.contains("# Note: MCP servers merge into opencode.json (no file installation)")
);
}
#[tokio::test]
async fn test_init_nested_directory_creation() {
let temp_dir = TempDir::new().unwrap();
let nested_path = temp_dir.path().join("a").join("b").join("c");
let cmd = InitCommand {
path: Some(nested_path.clone()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
assert!(nested_path.exists());
assert!(nested_path.join("agpm.toml").exists());
}
#[tokio::test]
async fn test_init_force_flag_behavior() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
let initial_content = "# Old manifest\n[sources]\n";
fs::write(&manifest_path, initial_content).unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_err());
let content = fs::read_to_string(&manifest_path).unwrap();
assert_eq!(content, initial_content);
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: true,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let new_content = fs::read_to_string(&manifest_path).unwrap();
assert!(new_content.contains("# AGPM Manifest"));
assert!(!new_content.contains("# Old manifest"));
}
#[tokio::test]
async fn test_init_creates_gitignore() {
let temp_dir = TempDir::new().unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let gitignore_path = temp_dir.path().join(".gitignore");
assert!(gitignore_path.exists());
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains(".agpm/backups/"));
assert!(content.contains("agpm.private.toml"));
assert!(content.contains("agpm.private.lock"));
}
#[tokio::test]
async fn test_init_updates_existing_gitignore() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".gitignore");
fs::write(&gitignore_path, "node_modules/\n*.log\n").unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("node_modules/"));
assert!(content.contains("*.log"));
assert!(content.contains(".agpm/backups/"));
assert!(content.contains("agpm.private.toml"));
assert!(content.contains("agpm.private.lock"));
assert!(content.contains("# AGPM"));
}
#[tokio::test]
async fn test_init_doesnt_duplicate_gitignore_entry() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".gitignore");
fs::write(&gitignore_path, ".agpm/backups/\nagpm.private.toml\nagpm.private.lock\n")
.unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&gitignore_path).unwrap();
assert_eq!(content.matches(".agpm/backups/").count(), 1);
assert_eq!(content.matches("agpm.private.toml").count(), 1);
assert_eq!(content.matches("agpm.private.lock").count(), 1);
}
#[tokio::test]
async fn test_init_gitignore_with_no_trailing_newline() {
let temp_dir = TempDir::new().unwrap();
let gitignore_path = temp_dir.path().join(".gitignore");
fs::write(&gitignore_path, "node_modules/").unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: false,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&gitignore_path).unwrap();
assert!(content.contains("node_modules/"));
assert!(content.contains(".agpm/backups/"));
assert!(content.contains("agpm.private.toml"));
assert!(content.contains("agpm.private.lock"));
let lines: Vec<&str> = content.lines().collect();
assert!(lines.contains(&"node_modules/"));
assert!(lines.contains(&".agpm/backups/"));
assert!(lines.contains(&"agpm.private.toml"));
assert!(lines.contains(&"agpm.private.lock"));
}
#[tokio::test]
async fn test_init_defaults_preserves_comments() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
let manifest_content = r#"
# My custom comment about sources
[sources]
community = "https://github.com/example/repo.git"
# Note: I only use Claude Code
[agents]
my-agent = { source = "community", path = "agents/my-agent.md", version = "v1.0.0" }
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("# My custom comment about sources"));
assert!(content.contains("# Note: I only use Claude Code"));
assert!(content.contains("community"));
assert!(content.contains("my-agent"));
assert!(content.contains("[tools.claude-code]"));
assert!(content.contains("[tools.opencode]"));
assert!(content.contains("[tools.agpm]"));
}
#[tokio::test]
async fn test_init_defaults_adds_missing_sections() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
let manifest_content = r#"
[sources]
community = "https://github.com/example/repo.git"
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("[tools.claude-code]"));
assert!(content.contains("[tools.opencode]"));
assert!(content.contains("[tools.agpm]"));
assert!(content.contains("community"));
assert!(content.contains("https://github.com/example/repo.git"));
assert!(content.contains("[agents]"));
assert!(content.contains("[snippets]"));
assert!(content.contains("[commands]"));
}
#[tokio::test]
async fn test_init_defaults_preserves_existing_tools() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
let manifest_content = r#"
[sources]
community = "https://github.com/example/repo.git"
[tools.claude-code]
path = ".my-custom-claude"
resources = { agents = { path = "my-agents" } }
[agents]
my-agent = { source = "community", path = "agents/my-agent.md" }
"#;
fs::write(&manifest_path, manifest_content).unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains(".my-custom-claude"));
assert!(content.contains("my-agents"));
assert!(content.contains("[tools.opencode]"));
assert!(content.contains("[tools.agpm]"));
assert!(content.contains("my-agent"));
}
#[tokio::test]
async fn test_init_defaults_fails_if_no_manifest() {
let temp_dir = TempDir::new().unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result = cmd.execute().await;
assert!(result.is_err());
let error_msg = result.unwrap_err().to_string();
assert!(error_msg.contains("No manifest found"));
assert!(error_msg.contains("agpm init"));
}
#[tokio::test]
async fn test_init_defaults_idempotent() {
let temp_dir = TempDir::new().unwrap();
let manifest_path = temp_dir.path().join("agpm.toml");
fs::write(&manifest_path, DEFAULT_MANIFEST_TEMPLATE).unwrap();
let cmd = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result = cmd.execute().await;
assert!(result.is_ok());
let content = fs::read_to_string(&manifest_path).unwrap();
assert!(content.contains("[tools.claude-code]"));
assert!(content.contains("[tools.opencode]"));
assert!(content.contains("[tools.agpm]"));
let cmd2 = InitCommand {
path: Some(temp_dir.path().to_path_buf()),
force: false,
defaults: true,
};
let result2 = cmd2.execute().await;
assert!(result2.is_ok());
}
}