use crate::fs_paths::Paths;
use crate::git::{parse_repo_ref, RealGit};
use crate::profile::Profile;
use crate::{extends, pack, resolve};
use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::path::Path;
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[32m";
const BOLD: &str = "\x1b[1m";
const RESET: &str = "\x1b[0m";
fn use_color() -> bool {
std::env::var_os("NO_COLOR").is_none() && std::io::stdout().is_terminal()
}
fn item_line(installed: bool, text: &str, color: bool) -> String {
match (installed, color) {
(true, true) => format!(" {DIM}{text} (installed){RESET}"),
(true, false) => format!(" {text} (installed)"),
(false, true) => format!(" {GREEN}+ {text} (new){RESET}"),
(false, false) => format!(" + {text} (new)"),
}
}
fn heading(text: &str, color: bool) -> String {
if color { format!("{BOLD}{text}{RESET}") } else { text.to_string() }
}
pub fn format_show(
profile: &Profile,
author: Option<&str>,
source_label: &str,
installed_plugins: &BTreeSet<String>,
installed_mkts: &BTreeSet<String>,
color: bool,
) -> String {
let mut out = String::new();
out.push_str(&format!("{}\n", heading(&profile.name, color)));
out.push_str(&format!(
" {}\n\n",
profile.description.as_deref().unwrap_or("(no description)")
));
out.push_str(&format!(" Author: {}\n", author.unwrap_or("—")));
out.push_str(&format!(" Source: {source_label}\n"));
if let Some(ext) = &profile.extends {
out.push_str(&format!(" Extends: {ext} (not expanded)\n"));
}
if profile.bare {
out.push_str(" Mode: bare (API-key isolation)\n");
}
out.push_str(&format!("\n{}\n", heading(&format!("Marketplaces ({})", profile.marketplaces.len()), color)));
for (name, src) in &profile.marketplaces {
let text = format!("{name} ({src})");
out.push_str(&item_line(installed_mkts.contains(name), &text, color));
out.push('\n');
}
out.push_str(&format!("{}\n", heading(&format!("Plugins ({})", profile.plugins.len()), color)));
for id in &profile.plugins {
out.push_str(&item_line(installed_plugins.contains(id), id, color));
out.push('\n');
}
if let Some(map) = profile.mcp_servers.as_object() {
if !map.is_empty() {
out.push_str(&format!("{}\n", heading(&format!("MCP servers ({})", map.len()), color)));
for key in map.keys() {
out.push_str(&format!(" - {key}\n"));
}
}
}
if !profile.plugin_dirs.is_empty() {
out.push_str(&format!("{}\n", heading(&format!("Plugin dirs ({})", profile.plugin_dirs.len()), color)));
for d in &profile.plugin_dirs {
out.push_str(&format!(" - {d}\n"));
}
}
out
}
fn source_label(source: &resolve::ProfileSource) -> String {
match source {
resolve::ProfileSource::EnvDir => "env dir (CLAUDE_PROFILE_DIR)".into(),
resolve::ProfileSource::ProjectDir => "project".into(),
resolve::ProfileSource::UserDir => "personal (~/.claude-profiles)".into(),
resolve::ProfileSource::Pack(p) => format!("pack {p}"),
resolve::ProfileSource::BundledDir => "bundled".into(),
}
}
fn pack_owner(source: &resolve::ProfileSource) -> Option<String> {
match source {
resolve::ProfileSource::Pack(p) => p.split_once("--").map(|(o, _)| o.to_string()),
_ => None,
}
}
fn installed_sets(profile: &Profile, profile_key: &str, paths: &Paths) -> (BTreeSet<String>, BTreeSet<String>) {
let installed_mkts: BTreeSet<String> = profile
.marketplaces
.keys()
.filter(|name| paths.marketplace_clone_dir(name).is_dir())
.cloned()
.collect();
let vendor_dir = paths.profile_vendor_dir(profile_key);
let installed_plugins: BTreeSet<String> = std::fs::read_dir(&vendor_dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().to_string())
.collect();
(installed_mkts, installed_plugins)
}
pub fn run(
target: &str,
paths: &Paths,
cwd: &Path,
env: Option<&Path>,
bundled: &Path,
) -> anyhow::Result<()> {
let color = use_color();
if target.contains('/') {
let (_tmp, dir) = pack::fetch_ephemeral(&RealGit, target)?;
let profile = pack::read_default_profile(&dir)?;
let repo_owner = parse_repo_ref(target)?.owner;
let author = profile.author.clone().or(Some(repo_owner));
let key = pack::default_profile_name(&dir)?;
let (installed_mkts, installed_plugins) = installed_sets(&profile, &key, paths);
print!(
"{}",
format_show(&profile, author.as_deref(), target, &installed_plugins, &installed_mkts, color)
);
} else {
let resolved = resolve::resolve(target, paths, cwd, env, bundled)?;
let label = source_label(&resolved.source);
let author = resolved
.profile
.author
.clone()
.or_else(|| pack_owner(&resolved.source));
let profile = extends::resolve_extends(resolved.profile, &|parent| {
Ok(resolve::resolve(parent, paths, cwd, env, bundled)?.profile)
})?;
let (installed_mkts, installed_plugins) = installed_sets(&profile, target, paths);
print!(
"{}",
format_show(&profile, author.as_deref(), &label, &installed_plugins, &installed_mkts, color)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn prof(json: &str) -> Profile {
Profile::from_json_str(json).unwrap()
}
fn set(items: &[&str]) -> BTreeSet<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn installed_sets_reflects_marketplace_clones_and_vendored_plugins() {
let tmp = tempfile::tempdir().unwrap();
let paths = Paths::from_home(tmp.path().to_path_buf());
std::fs::create_dir_all(paths.marketplace_clone_dir("have")).unwrap();
std::fs::create_dir_all(paths.profile_vendor_dir("p").join("have@have")).unwrap();
let profile = prof(
r#"{"name":"p","marketplaces":{"have":"o/r","missing":"o/s"},"plugins":["have@have","new@have"]}"#,
);
let (installed_mkts, installed_plugins) = installed_sets(&profile, "p", &paths);
assert_eq!(installed_mkts, set(&["have"]));
assert_eq!(installed_plugins, set(&["have@have"]));
}
#[test]
fn marks_installed_and_new_plugins_without_color() {
let p = prof(r#"{"name":"x","description":"d","plugins":["have@m","new@m"]}"#);
let s = format_show(&p, Some("alice"), "personal", &set(&["have@m"]), &set(&[]), false);
assert!(s.contains("Author: alice"));
assert!(s.contains("have@m (installed)"));
assert!(s.contains("+ new@m (new)"));
assert!(!s.contains("\x1b[")); }
#[test]
fn author_falls_back_to_dash() {
let p = prof(r#"{"name":"x","plugins":[]}"#);
let s = format_show(&p, None, "personal", &set(&[]), &set(&[]), false);
assert!(s.contains("Author: —"));
assert!(s.contains("(no description)"));
}
#[test]
fn marks_marketplaces_and_notes_extends() {
let p = prof(r#"{"name":"x","extends":"base","marketplaces":{"m":"o/r"}}"#);
let s = format_show(&p, None, "personal", &set(&[]), &set(&["m"]), false);
assert!(s.contains("m (o/r) (installed)"));
assert!(s.contains("Extends: base (not expanded)"));
}
#[test]
fn color_output_uses_ansi() {
let p = prof(r#"{"name":"x","plugins":["new@m"]}"#);
let s = format_show(&p, None, "personal", &set(&[]), &set(&[]), true);
assert!(s.contains(GREEN));
assert!(s.contains(RESET));
}
#[test]
fn pack_owner_extracts_owner_half() {
assert_eq!(pack_owner(&resolve::ProfileSource::Pack("alice--tools".into())), Some("alice".to_string()));
assert_eq!(pack_owner(&resolve::ProfileSource::UserDir), None);
}
}