use std::collections::{HashMap, HashSet};
use std::path::Path;
use clap::Args;
use miette::{IntoDiagnostic, Result, miette};
use skill::SkillManager;
use skill::installer::canonical_skills_dir;
use skill::types::{AgentId, InstallScope, RemoveOptions};
use crate::ui::{self, DIM, RESET};
#[derive(Args)]
pub struct RemoveArgs {
pub skills: Vec<String>,
#[arg(short, long)]
pub global: bool,
#[arg(short, long, num_args = 1..)]
pub agent: Option<Vec<String>>,
#[arg(short, long, num_args = 1..)]
pub skill: Option<Vec<String>>,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long)]
pub all: bool,
}
async fn scan_installed_skills(
manager: &SkillManager,
scope: InstallScope,
global: bool,
cwd: &Path,
) -> Vec<String> {
let canonical = canonical_skills_dir(scope, cwd);
let mut installed_names: HashSet<String> = HashSet::new();
if let Ok(mut entries) = tokio::fs::read_dir(&canonical).await {
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(ft) = entry.file_type().await
&& (ft.is_dir() || ft.is_symlink())
{
installed_names.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
for config in manager.agents().all_configs() {
let dir = if global {
config.global_skills_dir.clone()
} else {
Some(cwd.join(&config.skills_dir))
};
if let Some(dir) = dir
&& let Ok(mut entries) = tokio::fs::read_dir(&dir).await
{
while let Ok(Some(entry)) = entries.next_entry().await {
if let Ok(ft) = entry.file_type().await
&& (ft.is_dir() || ft.is_symlink())
{
installed_names.insert(entry.file_name().to_string_lossy().into_owned());
}
}
}
}
let mut installed: Vec<String> = installed_names.into_iter().collect();
installed.sort();
installed
}
fn validate_agents(manager: &SkillManager, agent_names: &[String]) -> Result<Vec<AgentId>> {
let all_ids = manager.agents().all_ids();
let mut result = Vec::new();
for name in agent_names {
if name == "*" {
return Ok(all_ids);
}
let id = AgentId::new(name);
if all_ids.contains(&id) {
result.push(id);
} else {
return Err(miette!(
"Unknown agent: \"{name}\". Available agents: {}",
all_ids
.iter()
.map(AgentId::as_str)
.collect::<Vec<_>>()
.join(", ")
));
}
}
Ok(result)
}
#[allow(clippy::cognitive_complexity)]
pub async fn run(mut args: RemoveArgs) -> Result<()> {
let manager = SkillManager::builder().build();
let scope = if args.global {
InstallScope::Global
} else {
InstallScope::Project
};
let cwd = std::env::current_dir().into_diagnostic()?;
if let Some(ref skill_names) = args.skill {
for name in skill_names {
if !args.skills.contains(name) {
args.skills.push(name.clone());
}
}
}
let target_agents: Vec<AgentId> = if let Some(ref agent_names) = args.agent {
validate_agents(&manager, agent_names)?
} else {
manager.agents().all_ids()
};
let spinner = cliclack::spinner();
spinner.start("Scanning for installed skills...");
let installed = scan_installed_skills(&manager, scope, args.global, &cwd).await;
spinner.stop(format!(
"Found {} unique installed skill(s)",
installed.len()
));
if installed.is_empty() {
let _ = cliclack::outro_cancel("\x1b[33mNo skills found to remove.\x1b[0m");
return Ok(());
}
#[allow(clippy::option_if_let_else, clippy::single_match_else)]
let selected: Vec<String> = if args.all {
installed.clone()
} else if !args.skills.is_empty() {
if args.skills.contains(&"*".to_owned()) {
installed.clone()
} else {
let names_lower: Vec<String> = args.skills.iter().map(|s| s.to_lowercase()).collect();
installed
.iter()
.filter(|s| names_lower.contains(&s.to_lowercase()))
.cloned()
.collect()
}
} else {
let mut prompt = cliclack::multiselect(format!(
"Select skills to remove {DIM}(space to toggle){RESET}"
));
for s in &installed {
prompt = prompt.item(s.clone(), s, "");
}
prompt = prompt.required(true);
ui::drain_input_events();
match prompt.interact() {
Ok(sel) => sel,
Err(_) => {
let _ = cliclack::outro_cancel("Removal cancelled");
std::process::exit(0);
}
}
};
if selected.is_empty() {
println!("{DIM}No matching skills found.{RESET}");
return Ok(());
}
if !args.yes && !args.all {
println!();
let _ = cliclack::log::info("Skills to remove:");
for s in &selected {
let _ = cliclack::log::remark(format!(" \x1b[31m\u{2022}\x1b[0m {s}"));
}
println!();
ui::drain_input_events();
let confirmed: bool = cliclack::confirm(format!(
"Are you sure you want to uninstall {} skill(s)?",
selected.len()
))
.initial_value(false)
.interact()
.into_diagnostic()?;
if !confirmed {
let _ = cliclack::outro_cancel("Removal cancelled");
std::process::exit(0);
}
}
let remove_spinner = cliclack::spinner();
remove_spinner.start("Removing skills...");
let agents_for_telemetry = target_agents.clone();
manager
.remove_skills(
&selected,
&RemoveOptions {
scope,
agents: target_agents,
cwd: Some(cwd.clone()),
},
)
.await
.map_err(|e| miette!("{e}"))?;
remove_spinner.stop("Removal process complete");
let _ = cliclack::log::success(format!(
"\x1b[32mSuccessfully removed {} skill(s)\x1b[0m",
selected.len()
));
if args.global {
for name in &selected {
let _ = skill::lock::remove_skill_from_lock(name).await;
}
} else {
for name in &selected {
let _ = skill::local_lock::remove_skill_from_local_lock(name, &cwd).await;
}
}
send_remove_telemetry(&selected, &agents_for_telemetry, args.global).await;
println!();
let _ = cliclack::outro("\x1b[32mDone!\x1b[0m");
Ok(())
}
async fn send_remove_telemetry(skills: &[String], agents: &[AgentId], global: bool) {
let lock = skill::lock::read_skill_lock().await.ok();
let mut by_source: HashMap<String, Vec<String>> = HashMap::new();
for name in skills {
let source = lock
.as_ref()
.and_then(|l| l.skills.get(name))
.map_or_else(|| "unknown".to_owned(), |e| e.source.clone());
by_source.entry(source).or_default().push(name.clone());
}
for (source, names) in &by_source {
let mut props = HashMap::new();
props.insert("source".to_owned(), source.clone());
props.insert("skills".to_owned(), names.join(","));
props.insert(
"agents".to_owned(),
agents
.iter()
.map(|a| a.as_str().to_owned())
.collect::<Vec<_>>()
.join(","),
);
if global {
props.insert("global".to_owned(), "1".to_owned());
}
skill::telemetry::track("remove", props);
}
}