use std::collections::BTreeMap;
use clap::Args;
use miette::{IntoDiagnostic, Result};
use skill::SkillManager;
use skill::types::{AgentId, InstallScope, ListOptions};
use crate::ui::{self, BOLD, CYAN, DIM, RESET, YELLOW};
#[derive(Args)]
pub struct ListArgs {
#[arg(short, long)]
pub global: bool,
#[arg(short, long, num_args = 1..)]
pub agent: Option<Vec<String>>,
#[arg(long)]
pub json: bool,
}
pub async fn run(args: ListArgs) -> Result<()> {
let manager = SkillManager::builder().build();
let cwd = std::env::current_dir().into_diagnostic()?;
let scope = if args.global {
Some(InstallScope::Global)
} else {
Some(InstallScope::Project)
};
let agent_filter: Vec<AgentId> = args
.agent
.unwrap_or_default()
.into_iter()
.map(AgentId::new)
.collect();
let installed = manager
.list_installed(&ListOptions {
scope,
agent_filter,
cwd: Some(cwd.clone()),
})
.await
.map_err(|e| miette::miette!("{e}"))?;
if args.json {
let json_output: Vec<serde_json::Value> = installed
.iter()
.map(|s| {
let agents: Vec<String> = s
.agents
.iter()
.filter_map(|id| manager.agents().get(id).map(|c| c.display_name.clone()))
.collect();
serde_json::json!({
"name": s.name,
"path": s.canonical_path.to_string_lossy(),
"scope": format!("{:?}", s.scope).to_lowercase(),
"agents": agents,
})
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json_output).unwrap_or_default()
);
return Ok(());
}
let scope_label = if args.global { "Global" } else { "Project" };
if installed.is_empty() {
println!(
"{DIM}No {} skills found.{RESET}",
scope_label.to_lowercase()
);
if args.global {
println!("{DIM}Try listing project skills without -g{RESET}");
} else {
println!("{DIM}Try listing global skills with -g{RESET}");
}
return Ok(());
}
let lock =
skill::lock::read_skill_lock()
.await
.unwrap_or_else(|_| skill::lock::SkillLockFile {
version: 3,
skills: std::collections::HashMap::new(),
dismissed: None,
last_selected_agents: None,
});
let mut grouped: BTreeMap<String, Vec<&skill::types::InstalledSkill>> = BTreeMap::new();
let mut ungrouped: Vec<&skill::types::InstalledSkill> = Vec::new();
for s in &installed {
let plugin = lock
.skills
.get(&s.name)
.and_then(|e| e.plugin_name.clone())
.unwrap_or_default();
if plugin.is_empty() {
ungrouped.push(s);
} else {
grouped.entry(plugin).or_default().push(s);
}
}
let has_groups = !grouped.is_empty();
println!("{BOLD}{scope_label} Skills{RESET}");
println!();
let print_skill = |skill_item: &skill::types::InstalledSkill, indent: bool| {
let prefix = if indent { " " } else { "" };
let short_path = ui::shorten_path_with_cwd(&skill_item.canonical_path, &cwd);
let agent_names: Vec<String> = skill_item
.agents
.iter()
.filter_map(|id| manager.agents().get(id).map(|c| c.display_name.clone()))
.collect();
let agent_info = if agent_names.is_empty() {
format!("{YELLOW}not linked{RESET}")
} else {
ui::format_list(&agent_names)
};
println!(
"{prefix}{CYAN}{}{RESET} {DIM}{short_path}{RESET}",
skill_item.name
);
println!("{prefix} {DIM}Agents:{RESET} {agent_info}");
};
if has_groups {
for (plugin, skills) in &grouped {
let title: String = plugin
.split('-')
.map(|w| {
let mut c = w.chars();
c.next()
.map_or_else(String::new, |f| f.to_uppercase().to_string() + c.as_str())
})
.collect::<Vec<_>>()
.join(" ");
println!("{BOLD}{title}{RESET}");
for skill_item in skills {
print_skill(skill_item, true);
}
println!();
}
if !ungrouped.is_empty() {
println!("{BOLD}General{RESET}");
for skill_item in &ungrouped {
print_skill(skill_item, true);
}
println!();
}
} else {
for skill_item in &installed {
print_skill(skill_item, false);
}
println!();
}
Ok(())
}