use anyhow::{Context, Result};
use clap::Args;
use colored::Colorize;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use crate::cache::Cache;
use crate::core::ResourceType;
use crate::lockfile::patch_display::extract_patch_displays;
use crate::lockfile::{LockFile, LockedResource};
use crate::manifest::find_manifest_with_optional;
#[derive(Args, Debug)]
pub struct TreeCommand {
#[arg(short = 'd', long)]
depth: Option<usize>,
#[arg(short = 'f', long, default_value = "tree")]
format: String,
#[arg(long)]
duplicates: bool,
#[arg(long)]
no_dedupe: bool,
#[arg(short = 'p', long)]
package: Option<String>,
#[arg(long)]
agents: bool,
#[arg(long)]
snippets: bool,
#[arg(long)]
commands: bool,
#[arg(long)]
scripts: bool,
#[arg(long)]
hooks: bool,
#[arg(long, name = "mcp-servers")]
mcp_servers: bool,
#[arg(short = 'i', long)]
invert: bool,
#[arg(long)]
detailed: bool,
}
impl TreeCommand {
pub async fn execute_with_manifest_path(self, manifest_path: Option<PathBuf>) -> Result<()> {
self.validate_arguments()?;
let manifest_path = find_manifest_with_optional(manifest_path)
.context("No agpm.toml found. Please create one to define your dependencies.")?;
self.execute_from_path(manifest_path).await
}
async fn execute_from_path(self, manifest_path: PathBuf) -> Result<()> {
self.validate_arguments()?;
if !manifest_path.exists() {
return Err(anyhow::anyhow!("Manifest file {} not found", manifest_path.display()));
}
let project_dir = manifest_path.parent().unwrap();
let lockfile_path = project_dir.join("agpm.lock");
let project_name =
project_dir.file_name().and_then(|n| n.to_str()).unwrap_or("project").to_string();
if !lockfile_path.exists() {
if self.format == "json" {
println!("{{}}");
} else {
println!("No lockfile found.");
println!("⚠️ Run 'agpm install' first to generate agpm.lock");
}
return Ok(());
}
let manifest_path = project_dir.join("agpm.toml");
let manifest = crate::manifest::Manifest::load(&manifest_path)?;
let command_context =
crate::cli::common::CommandContext::new(manifest, project_dir.to_path_buf())?;
let lockfile = match command_context.load_lockfile_with_regeneration(true, "tree")? {
Some(lockfile) => lockfile,
None => {
if self.format == "json" {
println!("{{}}");
} else {
println!("No lockfile found.");
println!(
"⚠️ Lockfile was invalid and has been removed. Run 'agpm install' to regenerate it."
);
}
return Ok(());
}
};
let cache = if self.detailed {
Some(Cache::new().context("Failed to initialize cache")?)
} else {
None
};
let builder = TreeBuilder::new(&lockfile, project_name);
let tree = builder.build(&self)?;
match self.format.as_str() {
"json" => self.output_json(&tree)?,
"text" => self.output_text(&tree),
_ => self.output_tree_with_cache(&tree, &lockfile, cache.as_ref()).await,
}
Ok(())
}
fn validate_arguments(&self) -> Result<()> {
match self.format.as_str() {
"tree" | "json" | "text" => {}
_ => {
return Err(anyhow::anyhow!(
"Invalid format '{}'. Valid formats are: tree, json, text",
self.format
));
}
}
if let Some(depth) = self.depth
&& depth == 0
{
return Err(anyhow::anyhow!("Depth must be at least 1"));
}
Ok(())
}
const fn should_show_resource_type(&self, resource_type: ResourceType) -> bool {
let any_filter = self.agents
|| self.snippets
|| self.commands
|| self.scripts
|| self.hooks
|| self.mcp_servers;
if !any_filter {
return true;
}
match resource_type {
ResourceType::Agent => self.agents,
ResourceType::Snippet => self.snippets,
ResourceType::Command => self.commands,
ResourceType::Script => self.scripts,
ResourceType::Hook => self.hooks,
ResourceType::McpServer => self.mcp_servers,
ResourceType::Skill => true, }
}
async fn output_tree_with_cache(
&self,
tree: &DependencyTree,
lockfile: &LockFile,
cache: Option<&Cache>,
) {
if tree.roots.is_empty() {
println!("No dependencies found.");
return;
}
println!("{}", tree.project_name.cyan().bold());
let mut displayed = HashSet::new();
for (i, root) in tree.roots.iter().enumerate() {
let is_last = i == tree.roots.len() - 1;
self.print_node_with_cache(root, "", is_last, &mut displayed, tree, 0, lockfile, cache)
.await;
}
if !self.no_dedupe && tree.has_duplicates() {
println!();
println!("{}", "(*) = duplicate dependency (already shown above)".blue());
}
}
#[allow(clippy::too_many_arguments)]
fn print_node_with_cache<'a>(
&'a self,
node: &'a TreeNode,
prefix: &'a str,
is_last: bool,
displayed: &'a mut HashSet<String>,
tree: &'a DependencyTree,
current_depth: usize,
lockfile: &'a LockFile,
cache: Option<&'a Cache>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + 'a>> {
Box::pin(async move {
if let Some(max_depth) = self.depth
&& current_depth >= max_depth
{
return;
}
let node_id = format!("{}/{}", node.resource_type, node.name);
let is_duplicate = !self.no_dedupe && displayed.contains(&node_id);
let connector = if is_last {
"└── "
} else {
"├── "
};
let type_str = format!("{}", node.resource_type).blue();
let name_str = node.name.cyan();
let version_str =
node.version.as_deref().map(|v| format!(" {}", v.blue())).unwrap_or_default();
let source_str = node
.source
.as_deref()
.map_or_else(|| " (local)".blue().to_string(), |s| format!(" ({})", s.blue()));
let tool_str = node
.tool
.as_deref()
.map(|tool| format!(" [{}]", tool.bright_yellow()))
.unwrap_or_default();
let patch_marker = if node.has_patches {
format!(" {}", "(patched)".blue())
} else {
String::new()
};
let dup_marker = if is_duplicate {
" (*)".blue().to_string()
} else {
String::new()
};
let token_str = node
.approximate_token_count
.map(|c| {
format!(" (~{} tok)", crate::tokens::format_token_count(c as usize))
.bright_black()
.to_string()
})
.unwrap_or_default();
println!(
"{prefix}{connector}{type_str}/{name_str}{version_str}{source_str}{tool_str}{patch_marker}{dup_marker}{token_str}"
);
if self.detailed && !is_duplicate {
let detail_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
if !node.installed_at.is_empty() {
println!(
"{} {}: {}",
detail_prefix,
"Installed at".bright_yellow(),
node.installed_at.bright_black()
);
}
if let Some(token_count) = node.approximate_token_count {
let formatted = crate::tokens::format_token_count(token_count as usize);
println!(
"{} {}: ~{}",
detail_prefix,
"Tokens".bright_yellow(),
formatted.bright_black()
);
}
if !node.applied_patches.is_empty() {
println!("{} {}", detail_prefix, "Applied patches:".bright_yellow());
if let Some(cache) = cache {
if let Some(locked_resource) =
self.find_locked_resource_for_node(node, lockfile)
{
let patch_displays =
extract_patch_displays(locked_resource, cache).await;
for display in patch_displays {
let formatted = display.format();
for (i, line) in formatted.lines().enumerate() {
if i == 0 {
println!("{} • {}", detail_prefix, line);
} else {
println!("{} {}", detail_prefix, line);
}
}
}
} else {
self.print_patches_fallback_tree(&node.applied_patches, &detail_prefix);
}
} else {
self.print_patches_fallback_tree(&node.applied_patches, &detail_prefix);
}
}
}
if is_duplicate {
return;
}
displayed.insert(node_id);
if !node.dependencies.is_empty() {
let child_prefix = if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
for (i, dep_id) in node.dependencies.iter().enumerate() {
if let Some(child_node) = tree.nodes.get(dep_id) {
let is_last_child = i == node.dependencies.len() - 1;
self.print_node_with_cache(
child_node,
&child_prefix,
is_last_child,
displayed,
tree,
current_depth + 1,
lockfile,
cache,
)
.await;
}
}
}
})
}
fn print_patches_fallback_tree(&self, patches: &BTreeMap<String, toml::Value>, prefix: &str) {
let mut patch_keys: Vec<_> = patches.keys().collect();
patch_keys.sort();
for key in patch_keys {
let value = &patches[key];
let value_str = format_patch_value(value);
println!("{} • {}: {}", prefix, key.blue(), value_str);
}
}
fn find_locked_resource_for_node<'a>(
&self,
node: &TreeNode,
lockfile: &'a LockFile,
) -> Option<&'a LockedResource> {
lockfile.get_resources(&node.resource_type).iter().find(|r| {
let display_name = TreeBuilder::extract_display_name(&r.name);
display_name == node.name && r.source == node.source && r.version == node.version
})
}
fn output_json(&self, tree: &DependencyTree) -> Result<()> {
let json = serde_json::json!({
"project": tree.project_name,
"roots": tree.roots.iter().map(|n| self.node_to_json(n, tree, 0)).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&json)?);
Ok(())
}
fn node_to_json(
&self,
node: &TreeNode,
tree: &DependencyTree,
depth: usize,
) -> serde_json::Value {
let children = if let Some(max_depth) = self.depth {
if depth >= max_depth {
vec![]
} else {
node.dependencies
.iter()
.filter_map(|id| tree.nodes.get(id))
.map(|child| self.node_to_json(child, tree, depth + 1))
.collect()
}
} else {
node.dependencies
.iter()
.filter_map(|id| tree.nodes.get(id))
.map(|child| self.node_to_json(child, tree, depth + 1))
.collect()
};
serde_json::json!({
"name": node.name,
"type": node.resource_type.to_string(),
"version": node.version,
"source": node.source,
"tool": node.tool.as_deref().unwrap_or("claude-code"),
"has_patches": node.has_patches,
"dependencies": children,
})
}
fn output_text(&self, tree: &DependencyTree) {
if tree.roots.is_empty() {
println!("No dependencies found.");
return;
}
println!("{}", tree.project_name);
let mut displayed = HashSet::new();
for root in &tree.roots {
self.print_text_node(root, 0, &mut displayed, tree, 0);
}
}
fn print_text_node(
&self,
node: &TreeNode,
indent: usize,
displayed: &mut HashSet<String>,
tree: &DependencyTree,
current_depth: usize,
) {
if let Some(max_depth) = self.depth
&& current_depth >= max_depth
{
return;
}
let node_id = format!("{}/{}", node.resource_type, node.name);
let is_duplicate = !self.no_dedupe && displayed.contains(&node_id);
let indent_str = " ".repeat(indent);
let version_str = node.version.as_deref().unwrap_or("latest");
let source_str = node.source.as_deref().unwrap_or("local");
let patch_marker = if node.has_patches {
format!(" {}", "(patched)".blue())
} else {
String::new()
};
let dup_marker = if is_duplicate {
" (*)"
} else {
""
};
println!(
"{}{}/{} {} ({}) {}{}{}",
indent_str,
node.resource_type,
node.name,
version_str,
source_str,
node.tool.as_deref().map(|tool| format!("[{}] ", tool)).unwrap_or_default(),
patch_marker,
dup_marker
);
if is_duplicate {
return;
}
displayed.insert(node_id);
for dep_id in &node.dependencies {
if let Some(child_node) = tree.nodes.get(dep_id) {
self.print_text_node(child_node, indent + 1, displayed, tree, current_depth + 1);
}
}
}
}
#[derive(Debug, Clone)]
struct TreeNode {
name: String,
resource_type: ResourceType,
version: Option<String>,
source: Option<String>,
tool: Option<String>,
dependencies: Vec<String>, has_patches: bool, installed_at: String, applied_patches: std::collections::BTreeMap<String, toml::Value>, approximate_token_count: Option<u64>, }
#[derive(Debug)]
struct DependencyTree {
project_name: String,
nodes: HashMap<String, TreeNode>,
roots: Vec<TreeNode>,
}
impl DependencyTree {
fn has_duplicates(&self) -> bool {
let mut seen = HashSet::new();
for root in &self.roots {
if self.has_duplicates_recursive(root, &mut seen) {
return true;
}
}
false
}
fn has_duplicates_recursive(&self, node: &TreeNode, seen: &mut HashSet<String>) -> bool {
let node_id = format!("{}/{}", node.resource_type, node.name);
if !seen.insert(node_id) {
return true;
}
for dep_id in &node.dependencies {
if let Some(child) = self.nodes.get(dep_id)
&& self.has_duplicates_recursive(child, seen)
{
return true;
}
}
false
}
}
struct TreeBuilder<'a> {
lockfile: &'a LockFile,
project_name: String,
}
impl<'a> TreeBuilder<'a> {
const fn new(lockfile: &'a LockFile, project_name: String) -> Self {
Self {
lockfile,
project_name,
}
}
fn build(&self, cmd: &TreeCommand) -> Result<DependencyTree> {
let mut nodes = HashMap::new();
let mut roots = Vec::new();
if let Some(ref package_name) = cmd.package {
let found = self.find_package(package_name)?;
let node = self.build_node(found, cmd)?;
let node_id = self.node_id(&node);
nodes.insert(node_id, node.clone());
self.build_dependencies(&node, &mut nodes, cmd)?;
roots.push(node);
} else {
for resource_type in ResourceType::all() {
if !cmd.should_show_resource_type(*resource_type) {
continue;
}
for resource in self.lockfile.get_resources(resource_type) {
let node = self.build_node(resource, cmd)?;
let node_id = self.node_id(&node);
nodes.insert(node_id.clone(), node.clone());
self.build_dependencies(&node, &mut nodes, cmd)?;
}
}
let has_type_filter = cmd.agents
|| cmd.snippets
|| cmd.commands
|| cmd.scripts
|| cmd.hooks
|| cmd.mcp_servers;
if has_type_filter {
for node in nodes.values() {
if cmd.should_show_resource_type(node.resource_type) {
roots.push(node.clone());
}
}
} else {
let mut all_dependencies = HashSet::new();
for resource_type in ResourceType::all() {
for resource in self.lockfile.get_resources(resource_type) {
for dep_id in &resource.dependencies {
all_dependencies.insert(dep_id.clone());
}
}
}
for node in nodes.values() {
let simple_id = format!("{}/{}", node.resource_type, node.name);
if !all_dependencies.contains(&simple_id) {
roots.push(node.clone());
}
}
}
roots.sort_by(|a, b| {
a.tool
.cmp(&b.tool)
.then_with(|| a.resource_type.to_string().cmp(&b.resource_type.to_string()))
.then_with(|| a.name.cmp(&b.name))
});
}
if cmd.duplicates {
let duplicate_ids = self.find_duplicates(&roots, &nodes);
roots.retain(|n| duplicate_ids.contains(&self.node_id(n)));
}
Ok(DependencyTree {
project_name: self.project_name.clone(),
nodes,
roots,
})
}
fn find_package(&self, name: &str) -> Result<&LockedResource> {
for resource_type in ResourceType::all() {
for resource in self.lockfile.get_resources(resource_type) {
if resource.name == name {
return Ok(resource);
}
}
}
Err(anyhow::anyhow!("Package '{name}' not found in lockfile"))
}
fn build_node(&self, resource: &LockedResource, _cmd: &TreeCommand) -> Result<TreeNode> {
let display_name = Self::extract_display_name(&resource.name);
let dependency_node_ids: Vec<String> = resource
.dependencies
.iter()
.filter_map(|dep_id| {
if let Some(dep_resource) =
self.find_resource_by_id(dep_id, resource.source.as_deref())
{
let dep_node = TreeNode {
name: Self::extract_display_name(&dep_resource.name),
resource_type: dep_resource.resource_type,
version: dep_resource.version.clone(),
source: dep_resource.source.clone(),
tool: dep_resource.tool.clone(),
dependencies: vec![], has_patches: !dep_resource.applied_patches.is_empty(),
installed_at: dep_resource.installed_at.clone(),
applied_patches: dep_resource.applied_patches.clone(),
approximate_token_count: dep_resource.approximate_token_count,
};
Some(self.node_id(&dep_node))
} else {
None
}
})
.collect();
Ok(TreeNode {
name: display_name,
resource_type: resource.resource_type,
version: resource.version.clone(),
source: resource.source.clone(),
tool: resource.tool.clone(),
dependencies: dependency_node_ids,
has_patches: !resource.applied_patches.is_empty(),
installed_at: resource.installed_at.clone(),
applied_patches: resource.applied_patches.clone(),
approximate_token_count: resource.approximate_token_count,
})
}
pub fn extract_display_name(unique_name: &str) -> String {
let after_source = if let Some((_source, rest)) = unique_name.split_once(':') {
rest
} else {
unique_name
};
if let Some((name, _version)) = after_source.split_once('@') {
name.to_string()
} else {
after_source.to_string()
}
}
fn build_dependencies(
&self,
node: &TreeNode,
nodes: &mut HashMap<String, TreeNode>,
cmd: &TreeCommand,
) -> Result<()> {
for dep_node_id in &node.dependencies {
if nodes.contains_key(dep_node_id) {
continue; }
if let Some(dep_resource) =
self.find_resource_by_id(dep_node_id, node.source.as_deref())
{
let dep_node = self.build_node(dep_resource, cmd)?;
let actual_dep_node_id = self.node_id(&dep_node);
nodes.insert(actual_dep_node_id.clone(), dep_node.clone());
self.build_dependencies(&dep_node, nodes, cmd)?;
}
}
Ok(())
}
fn find_resource_by_id(
&self,
id: &str,
preferred_source: Option<&str>,
) -> Option<&LockedResource> {
use std::str::FromStr;
let dep_ref =
crate::lockfile::lockfile_dependency_ref::LockfileDependencyRef::from_str(id).ok()?;
let resource_type = dep_ref.resource_type;
let name = &dep_ref.path;
let resources = self.lockfile.get_resources(&resource_type);
if let Some(source) = preferred_source {
for resource in resources {
let display_name = Self::extract_display_name(&resource.name);
if display_name == *name && resource.source.as_deref() == Some(source) {
return Some(resource);
}
}
}
for resource in resources {
let display_name = Self::extract_display_name(&resource.name);
if display_name == *name {
return Some(resource);
}
}
None
}
fn node_id(&self, node: &TreeNode) -> String {
match (&node.source, &node.version) {
(Some(source), Some(version)) if version != "local" => {
format!("{}:{}@{}", source, node.name, version)
}
(Some(source), _) => {
format!("{}:{}", source, node.name)
}
(None, Some(version)) if version != "local" => {
format!("{}@{}", node.name, version)
}
(None, _) => {
node.name.clone()
}
}
}
fn find_duplicates(
&self,
roots: &[TreeNode],
nodes: &HashMap<String, TreeNode>,
) -> HashSet<String> {
let mut counts: HashMap<String, usize> = HashMap::new();
let mut seen = HashSet::new();
for root in roots {
self.count_occurrences(root, &mut counts, &mut seen, nodes);
}
counts.iter().filter(|&(_, &count)| count > 1).map(|(id, _)| id.clone()).collect()
}
fn count_occurrences(
&self,
node: &TreeNode,
counts: &mut HashMap<String, usize>,
seen: &mut HashSet<String>,
nodes: &HashMap<String, TreeNode>,
) {
let node_id = self.node_id(node);
*counts.entry(node_id.clone()).or_insert(0) += 1;
if seen.contains(&node_id) {
return; }
seen.insert(node_id);
for dep_id in &node.dependencies {
if let Some(child) = nodes.get(dep_id) {
self.count_occurrences(child, counts, seen, nodes);
}
}
}
}
fn format_patch_value(value: &toml::Value) -> String {
match value {
toml::Value::String(s) => format!("\"{}\"", s),
toml::Value::Integer(i) => i.to_string(),
toml::Value::Float(f) => f.to_string(),
toml::Value::Boolean(b) => b.to_string(),
toml::Value::Array(arr) => {
let elements: Vec<String> = arr.iter().map(format_patch_value).collect();
format!("[{}]", elements.join(", "))
}
toml::Value::Table(_) | toml::Value::Datetime(_) => {
value.to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn create_default_command() -> TreeCommand {
TreeCommand {
depth: None,
format: "tree".to_string(),
duplicates: false,
no_dedupe: false,
package: None,
agents: false,
snippets: false,
commands: false,
scripts: false,
hooks: false,
mcp_servers: false,
invert: false,
detailed: false,
}
}
#[test]
fn test_validate_arguments_valid_format() -> Result<()> {
let valid_formats = ["tree", "json", "text"];
for format in valid_formats {
let cmd = TreeCommand {
format: format.to_string(),
..create_default_command()
};
cmd.validate_arguments()?;
}
Ok(())
}
#[test]
fn test_validate_arguments_invalid_format() {
let cmd = TreeCommand {
format: "invalid".to_string(),
..create_default_command()
};
let result = cmd.validate_arguments();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Invalid format"));
}
#[test]
fn test_validate_arguments_zero_depth() {
let cmd = TreeCommand {
depth: Some(0),
..create_default_command()
};
let result = cmd.validate_arguments();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("must be at least 1"));
}
#[test]
fn test_should_show_resource_type_no_filters() {
let cmd = create_default_command();
assert!(cmd.should_show_resource_type(ResourceType::Agent));
assert!(cmd.should_show_resource_type(ResourceType::Snippet));
assert!(cmd.should_show_resource_type(ResourceType::Command));
}
#[test]
fn test_should_show_resource_type_with_filters() {
let cmd = TreeCommand {
agents: true,
..create_default_command()
};
assert!(cmd.should_show_resource_type(ResourceType::Agent));
assert!(!cmd.should_show_resource_type(ResourceType::Snippet));
assert!(!cmd.should_show_resource_type(ResourceType::Command));
}
#[test]
fn test_node_id() {
let lockfile = LockFile::new();
let builder = TreeBuilder::new(&lockfile, "test-project".to_string());
let node = TreeNode {
name: "test-agent".to_string(),
resource_type: ResourceType::Agent,
version: Some("v1.0.0".to_string()),
source: Some("community".to_string()),
tool: Some("claude-code".to_string()),
dependencies: vec![],
has_patches: false,
installed_at: ".claude/agents/test-agent.md".to_string(),
applied_patches: BTreeMap::new(),
approximate_token_count: None,
};
assert_eq!(builder.node_id(&node), "community:test-agent@v1.0.0");
let node_local_source = TreeNode {
name: "local-agent".to_string(),
resource_type: ResourceType::Agent,
version: Some("local".to_string()),
source: Some("local-deps".to_string()),
tool: Some("claude-code".to_string()),
dependencies: vec![],
has_patches: false,
installed_at: ".claude/agents/local-agent.md".to_string(),
applied_patches: BTreeMap::new(),
approximate_token_count: None,
};
assert_eq!(builder.node_id(&node_local_source), "local-deps:local-agent");
let node_local = TreeNode {
name: "local-agent".to_string(),
resource_type: ResourceType::Agent,
version: Some("local".to_string()),
source: None,
tool: Some("claude-code".to_string()),
dependencies: vec![],
has_patches: false,
installed_at: ".claude/agents/local-agent.md".to_string(),
applied_patches: BTreeMap::new(),
approximate_token_count: None,
};
assert_eq!(builder.node_id(&node_local), "local-agent");
let node_no_version = TreeNode {
name: "test-agent".to_string(),
resource_type: ResourceType::Agent,
version: None,
source: Some("community".to_string()),
tool: Some("claude-code".to_string()),
dependencies: vec![],
has_patches: false,
installed_at: ".claude/agents/test-agent.md".to_string(),
applied_patches: BTreeMap::new(),
approximate_token_count: None,
};
assert_eq!(builder.node_id(&node_no_version), "community:test-agent");
}
}