use anyhow::Result;
use colored::Colorize;
use std::collections::{BTreeMap, HashMap};
use crate::cache::Cache;
use crate::lockfile::LockFile;
use crate::lockfile::patch_display::extract_patch_displays;
#[derive(Debug, Clone)]
pub struct OutputConfig {
pub title: String,
pub format: String,
pub files: bool,
pub detailed: bool,
pub verbose: bool,
pub should_show_agents: bool,
pub should_show_snippets: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
title: "Installed Resources".to_string(),
format: "table".to_string(),
files: false,
detailed: false,
verbose: false,
should_show_agents: true,
should_show_snippets: true,
}
}
}
#[derive(Debug, Clone)]
pub struct ListItem {
pub name: String,
pub source: Option<String>,
pub version: Option<String>,
pub path: Option<String>,
pub resource_type: String,
pub installed_at: Option<String>,
pub checksum: Option<String>,
pub resolved_commit: Option<String>,
pub tool: Option<String>,
pub applied_patches: std::collections::BTreeMap<String, toml::Value>,
pub approximate_token_count: Option<u64>,
}
pub fn output_items(items: &[ListItem], config: &OutputConfig) -> Result<()> {
if items.is_empty() {
if config.format == "json" {
println!("{{}}");
} else {
println!("No installed resources found.");
}
return Ok(());
}
match config.format.as_str() {
"json" => output_json(items)?,
"yaml" => output_yaml(items)?,
"compact" => output_compact(items),
"simple" => output_simple(items),
_ => output_table(items, config),
}
Ok(())
}
pub async fn output_items_detailed(
items: &[ListItem],
title: &str,
lockfile: &LockFile,
cache: Option<&Cache>,
should_show_agents: bool,
should_show_snippets: bool,
) -> Result<()> {
if items.is_empty() {
println!("{{}}");
return Ok(());
}
println!("{}", title.bold());
println!();
if should_show_agents {
let agents: Vec<_> = items.iter().filter(|i| i.resource_type == "agent").collect();
if !agents.is_empty() {
println!("{}:", "Agents".cyan().bold());
for item in agents {
print_item_detailed(item, lockfile, cache).await;
}
println!();
}
}
if should_show_snippets {
let snippets: Vec<_> = items.iter().filter(|i| i.resource_type == "snippet").collect();
if !snippets.is_empty() {
println!("{}:", "Snippets".cyan().bold());
for item in snippets {
print_item_detailed(item, lockfile, cache).await;
}
}
}
let total_tokens: u64 = items.iter().filter_map(|i| i.approximate_token_count).sum();
if total_tokens > 0 {
let formatted = crate::tokens::format_token_count(total_tokens as usize);
println!("{}: {} resources (~{} tokens)", "Total".green().bold(), items.len(), formatted);
} else {
println!("{}: {} resources", "Total".green().bold(), items.len());
}
Ok(())
}
fn output_json(items: &[ListItem]) -> Result<()> {
let json_items: Vec<serde_json::Value> = items
.iter()
.map(|item| {
let mut obj = serde_json::json!({
"name": item.name,
"type": item.resource_type,
"tool": item.tool
});
if let Some(ref source) = item.source {
obj["source"] = serde_json::Value::String(source.clone());
}
if let Some(ref version) = item.version {
obj["version"] = serde_json::Value::String(version.clone());
}
if let Some(ref path) = item.path {
obj["path"] = serde_json::Value::String(path.clone());
}
if let Some(ref installed_at) = item.installed_at {
obj["installed_at"] = serde_json::Value::String(installed_at.clone());
}
if let Some(ref checksum) = item.checksum {
obj["checksum"] = serde_json::Value::String(checksum.clone());
}
if let Some(token_count) = item.approximate_token_count {
obj["approximate_token_count"] = serde_json::Value::Number(token_count.into());
}
obj
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_items)?);
Ok(())
}
fn output_yaml(items: &[ListItem]) -> Result<()> {
let yaml_items: Vec<HashMap<String, serde_yaml::Value>> = items
.iter()
.map(|item| {
let mut obj = HashMap::new();
obj.insert("name".to_string(), serde_yaml::Value::String(item.name.clone()));
obj.insert("type".to_string(), serde_yaml::Value::String(item.resource_type.clone()));
obj.insert(
"tool".to_string(),
serde_yaml::Value::String(item.tool.clone().expect("Tool should always be set")),
);
if let Some(ref source) = item.source {
obj.insert("source".to_string(), serde_yaml::Value::String(source.clone()));
}
if let Some(ref version) = item.version {
obj.insert("version".to_string(), serde_yaml::Value::String(version.clone()));
}
if let Some(ref path) = item.path {
obj.insert("path".to_string(), serde_yaml::Value::String(path.clone()));
}
if let Some(ref installed_at) = item.installed_at {
obj.insert(
"installed_at".to_string(),
serde_yaml::Value::String(installed_at.clone()),
);
}
if let Some(token_count) = item.approximate_token_count {
obj.insert(
"approximate_token_count".to_string(),
serde_yaml::Value::Number(token_count.into()),
);
}
obj
})
.collect();
println!("{}", serde_yaml::to_string(&yaml_items)?);
Ok(())
}
fn output_compact(items: &[ListItem]) {
for item in items {
let source = item.source.as_deref().unwrap_or("local");
let version = item.version.as_deref().unwrap_or("latest");
println!("{} {} {}", item.name, version, source);
}
}
fn output_simple(items: &[ListItem]) {
for item in items {
println!("{} ({}))", item.name, item.resource_type);
}
}
struct ColumnWidths {
name: usize,
version: usize,
source: usize,
resource_type: usize,
tool: usize,
}
impl ColumnWidths {
fn calculate(items: &[ListItem]) -> Self {
Self {
name: items
.iter()
.map(|i| {
if i.applied_patches.is_empty() {
i.name.len()
} else {
i.name.len() + 10 }
})
.max()
.unwrap_or(4)
.max(4), version: items
.iter()
.map(|i| i.version.as_deref().unwrap_or("latest").len())
.max()
.unwrap_or(7)
.max(7), source: items
.iter()
.map(|i| i.source.as_deref().unwrap_or("local").len())
.max()
.unwrap_or(6)
.max(6), resource_type: items.iter().map(|i| i.resource_type.len()).max().unwrap_or(4).max(4), tool: items
.iter()
.map(|i| i.tool.as_deref().unwrap_or("claude-code").len())
.max()
.unwrap_or(8)
.max(8), }
}
fn total_width(&self) -> usize {
self.name + 1 + self.version + 1 + self.source + 1 + self.resource_type + 1 + self.tool
}
}
fn output_table(items: &[ListItem], config: &OutputConfig) {
println!("{}", config.title.bold());
println!();
let widths = ColumnWidths::calculate(items);
let mut sorted_items: Vec<_> = items.to_vec();
sorted_items.sort_by(|a, b| {
a.resource_type
.cmp(&b.resource_type)
.then_with(|| a.tool.as_deref().unwrap_or("").cmp(b.tool.as_deref().unwrap_or("")))
.then_with(|| a.name.cmp(&b.name))
});
if !sorted_items.is_empty() && config.format == "table" && !config.verbose {
println!(
"{:<name_w$} {:<ver_w$} {:<src_w$} {:<type_w$} {:<tool_w$}",
"Name".cyan().bold(),
"Version".cyan().bold(),
"Source".cyan().bold(),
"Type".cyan().bold(),
"Tool".cyan().bold(),
name_w = widths.name,
ver_w = widths.version,
src_w = widths.source,
type_w = widths.resource_type,
tool_w = widths.tool
);
println!("{}", "-".repeat(widths.total_width()).bright_black());
}
if config.format == "table" && !config.files && !config.detailed && !config.verbose {
for item in &sorted_items {
print_item_with_widths(item, &widths);
}
} else {
if config.should_show_agents {
let agents: Vec<_> = items.iter().filter(|i| i.resource_type == "agent").collect();
if !agents.is_empty() {
println!("{}:", "Agents".cyan().bold());
for item in agents {
print_item(item, &config.format, config.files, config.detailed);
}
println!();
}
}
if config.should_show_snippets {
let snippets: Vec<_> = items.iter().filter(|i| i.resource_type == "snippet").collect();
if !snippets.is_empty() {
println!("{}:", "Snippets".cyan().bold());
for item in snippets {
print_item(item, &config.format, config.files, config.detailed);
}
}
}
}
println!("{}: {} resources", "Total".green().bold(), items.len());
}
async fn print_item_detailed(item: &ListItem, lockfile: &LockFile, cache: Option<&Cache>) {
let source = item.source.as_deref().unwrap_or("local");
let version = item.version.as_deref().unwrap_or("latest");
println!(" {}", item.name.bright_white());
println!(" Source: {}", source.bright_black());
println!(" Version: {}", version.yellow());
if let Some(ref path) = item.path {
println!(" Path: {}", path.bright_black());
}
if let Some(ref installed_at) = item.installed_at {
println!(" Installed at: {}", installed_at.bright_black());
}
if let Some(ref checksum) = item.checksum {
println!(" Checksum: {}", checksum.bright_black());
}
if let Some(token_count) = item.approximate_token_count {
let formatted = crate::tokens::format_token_count(token_count as usize);
println!(" Tokens: ~{}", formatted.bright_black());
}
if !item.applied_patches.is_empty() {
println!(" Applied patches:");
if let Some(cache) = cache {
if let Some(locked_resource) = find_locked_resource(item, 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!(" • {}", line);
} else {
println!(" {}", line);
}
}
}
} else {
print_patches_fallback(&item.applied_patches);
}
} else {
print_patches_fallback(&item.applied_patches);
}
}
println!();
}
fn print_patches_fallback(patches: &BTreeMap<String, toml::Value>) {
let mut patch_keys: Vec<_> = patches.keys().collect();
patch_keys.sort();
for key in patch_keys {
let value = &patches[key];
let formatted_value = format_patch_value(value);
println!(" • {}: {}", key.blue(), formatted_value);
}
}
fn find_locked_resource<'a>(
item: &ListItem,
lockfile: &'a LockFile,
) -> Option<&'a crate::lockfile::LockedResource> {
let resource_type = match item.resource_type.as_str() {
"agent" => crate::core::ResourceType::Agent,
"snippet" => crate::core::ResourceType::Snippet,
"command" => crate::core::ResourceType::Command,
"script" => crate::core::ResourceType::Script,
"hook" => crate::core::ResourceType::Hook,
"mcp-server" => crate::core::ResourceType::McpServer,
_ => return None,
};
lockfile.get_resources(&resource_type).iter().find(|r| r.name == item.name)
}
fn print_item_with_widths(item: &ListItem, widths: &ColumnWidths) {
let source = item.source.as_deref().unwrap_or("local");
let version = item.version.as_deref().unwrap_or("latest");
let tool = item.tool.as_deref().unwrap_or("claude-code");
let name_with_indicator = if !item.applied_patches.is_empty() {
format!("{} (patched)", item.name)
} else {
item.name.clone()
};
let name_field = format!("{:<width$}", name_with_indicator, width = widths.name);
let version_field = format!("{:<width$}", version, width = widths.version);
let source_field = format!("{:<width$}", source, width = widths.source);
let type_field = format!("{:<width$}", item.resource_type, width = widths.resource_type);
let tool_field = format!("{:<width$}", tool, width = widths.tool);
println!(
"{} {} {} {} {}",
name_field.bright_white(),
version_field.yellow(),
source_field.bright_black(),
type_field.bright_white(),
tool_field.bright_black()
);
}
fn print_item(item: &ListItem, format: &str, files: bool, detailed: bool) {
let source = item.source.as_deref().unwrap_or("local");
let version = item.version.as_deref().unwrap_or("latest");
if format == "table" && !files && !detailed {
let name_with_indicator = if !item.applied_patches.is_empty() {
format!("{} (patched)", item.name)
} else {
item.name.clone()
};
let name_field = format!("{:<32}", name_with_indicator);
let colored_name = name_field.bright_white();
println!(
"{} {:<15} {:<15} {:<12} {:<15}",
colored_name,
version.yellow(),
source.bright_black(),
item.resource_type.bright_white(),
item.tool.clone().expect("Tool should always be set").bright_black()
);
} else if files {
if let Some(ref installed_at) = item.installed_at {
println!(" {}", installed_at.bright_black());
} else if let Some(ref path) = item.path {
println!(" {}", path.bright_black());
}
} else if detailed {
println!(" {}", item.name.bright_white());
println!(" Source: {}", source.bright_black());
println!(" Version: {}", version.yellow());
if let Some(ref path) = item.path {
println!(" Path: {}", path.bright_black());
}
if let Some(ref installed_at) = item.installed_at {
println!(" Installed at: {}", installed_at.bright_black());
}
if let Some(ref checksum) = item.checksum {
println!(" Checksum: {}", checksum.bright_black());
}
if let Some(token_count) = item.approximate_token_count {
let formatted = crate::tokens::format_token_count(token_count as usize);
println!(" Tokens: ~{}", formatted.bright_black());
}
if !item.applied_patches.is_empty() {
println!(" {}", "Patches:".cyan());
let mut patch_keys: Vec<_> = item.applied_patches.keys().collect();
patch_keys.sort(); for key in patch_keys {
let value = &item.applied_patches[key];
let formatted_value = format_patch_value(value);
println!(" {}: {}", key.yellow(), formatted_value.green());
}
}
println!();
} else {
let commit_info = if let Some(ref commit) = item.resolved_commit {
format!("@{}", &commit[..7.min(commit.len())])
} else {
String::new()
};
println!(
" {} {} {} {}",
item.name.bright_white(),
format!("({source}))").bright_black(),
version.yellow(),
commit_info.bright_black()
);
if let Some(ref installed_at) = item.installed_at {
println!(" → {}", installed_at.bright_black());
}
}
}
pub 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()
}
}
}