use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use serde::Serialize;
use crate::core::agents::{
AGENTS, Agent, Env, agent_display, config_home, detect_installed_agents,
ensure_universal_agents, get_agent, home, is_installed,
};
use crate::core::discover::{Skill, discover_skills, filter_skills};
use crate::core::fetch::{clone_repo, download_and_extract};
use crate::core::install::{
get_canonical_path, install_skill, list_installed_skills, sanitize_name, scan_installed,
};
use crate::core::link::{LinkOutcome, UnlinkOutcome, is_agent_linked, link_agent, unlink_agent};
use crate::core::lock::{
LockEntry, compute_folder_hash, find_lock_entry, global_lock_path, local_lock_path,
lock_fields, read_local_lock, write_local_lock,
};
use crate::core::source::{Source, SourceType, parse_source};
use crate::error::{Result, SkillsError};
pub struct Manager {
env: Env,
}
impl Default for Manager {
fn default() -> Self {
Self::new()
}
}
impl Manager {
pub fn new() -> Self {
Self::builder().build()
}
pub fn builder() -> ManagerBuilder {
ManagerBuilder::default()
}
pub fn env(&self) -> &Env {
&self.env
}
pub fn add(&self, req: &AddRequest) -> Result<AddOutcome> {
let parsed = parse_source(&req.source)?;
let include_internal = !req.skills.is_empty();
let skills: Vec<Skill>;
let _temp: Option<tempfile::TempDir>;
match parsed.ty {
SourceType::Local => {
let path = parsed
.local_path
.as_ref()
.ok_or_else(|| SkillsError::msg("local source missing path"))?;
if !path.exists() {
return Err(SkillsError::msg(format!(
"Local path does not exist: {}",
path.display()
)));
}
skills = discover_skills(
path,
parsed.subpath.as_deref(),
req.full_depth,
include_internal,
)?;
_temp = None;
}
SourceType::Download | SourceType::WellKnown => {
let (t, root) = download_and_extract(&parsed.url)?;
skills = discover_skills(
&root,
parsed.subpath.as_deref(),
req.full_depth,
include_internal,
)?;
_temp = Some(t);
}
_ => {
let tmp = clone_repo(&parsed.url, parsed.r#ref.as_deref())?;
skills = discover_skills(
tmp.path(),
parsed.subpath.as_deref(),
req.full_depth,
include_internal,
)?;
_temp = Some(tmp);
}
}
if skills.is_empty() {
return Err(SkillsError::msg(
"No valid skills found. Skills require a SKILL.md with name and description.",
));
}
if req.list_only {
return Ok(AddOutcome {
source: parsed,
skills,
selected: Vec::new(),
installed: Vec::new(),
failed: Vec::new(),
list_only: true,
});
}
let selected: Vec<Skill> = if req.skills.iter().any(|s| s == "*") {
skills.clone()
} else if !req.skills.is_empty() {
filter_skills(&skills, &req.skills)
} else {
skills.clone()
};
let mut installed: Vec<InstallSuccess> = Vec::new();
let mut failed: Vec<InstallFailure> = Vec::new();
for skill in &selected {
let r = install_skill(skill, req.global, &self.env);
if r.success && !r.skipped {
installed.push(InstallSuccess {
name: skill.name.clone(),
canonical_path: r.canonical_path,
});
} else if !r.success {
failed.push(InstallFailure {
skill: skill.name.clone(),
error: r.error.unwrap_or_default(),
});
}
}
if !installed.is_empty() {
write_lock(&parsed, &selected, &installed, req.global, &self.env)?;
}
Ok(AddOutcome {
source: parsed,
skills,
selected,
installed,
failed,
list_only: false,
})
}
pub fn add_source(&self, source: impl Into<String>) -> Result<AddOutcome> {
self.add(&AddRequest::new(source))
}
pub fn link(&self, req: &LinkRequest) -> Result<LinkManagerOutcome> {
let target_agents = resolve_target_agents(&req.agents, &self.env)?;
let results = target_agents
.iter()
.map(|agent| AgentLinkResult {
agent: agent.name.to_string(),
display: agent.display.to_string(),
outcome: link_agent(agent, req.global, &self.env, req.migrate),
})
.collect();
Ok(LinkManagerOutcome {
global: req.global,
results,
})
}
pub fn unlink(&self, req: &UnlinkRequest) -> Result<UnlinkManagerOutcome> {
let target_agents = resolve_target_agents(&req.agents, &self.env)?;
let results = target_agents
.iter()
.map(|agent| AgentUnlinkResult {
agent: agent.name.to_string(),
display: agent.display.to_string(),
outcome: unlink_agent(agent, req.global, &self.env),
})
.collect();
Ok(UnlinkManagerOutcome {
global: req.global,
results,
})
}
pub fn link_status(&self, global: bool) -> Vec<LinkStatus> {
AGENTS
.iter()
.filter(|a| {
a.is_universal()
|| is_installed(a, &self.env)
|| is_agent_linked(a, global, &self.env)
})
.map(|a| LinkStatus {
name: a.name.to_string(),
display: a.display.to_string(),
linked: is_agent_linked(a, global, &self.env),
})
.collect()
}
pub fn list(&self, req: &ListRequest) -> Result<Vec<ListedSkill>> {
let invalid: Vec<String> = req
.agents
.iter()
.filter(|a| get_agent(a).is_none())
.cloned()
.collect();
if !invalid.is_empty() {
return Err(SkillsError::InvalidAgents(invalid.join(", ")));
}
let installed = list_installed_skills(&self.env, req.global, &req.agents);
let lock = read_local_lock(&lock_path(&self.env, req.global));
let mut out = Vec::new();
for s in &installed {
let entry = find_lock_entry(&lock, &s.name);
out.push(ListedSkill {
name: s.name.clone(),
path: s.canonical_path.clone(),
scope: s.scope.clone(),
agents: s.agents.iter().map(|a| agent_display(a)).collect(),
source: entry.map(|e| e.source.clone()),
source_url: entry.and_then(|e| e.source_url.clone()),
source_type: entry.map(|e| e.source_type.clone()),
});
}
Ok(out)
}
pub fn remove(&self, req: &RemoveRequest) -> Result<RemoveOutcome> {
let global = req.global;
let installed = scan_installed(&self.env, global);
if req.skills.is_empty() && !req.all {
return Ok(RemoveOutcome {
installed,
requested: Vec::new(),
removed: Vec::new(),
});
}
let lock = read_local_lock(&lock_path(&self.env, global));
let lock_keys: Vec<String> = lock.skills.keys().cloned().collect();
let requested: Vec<String> = if req.all {
installed.iter().chain(lock_keys.iter()).cloned().collect()
} else {
req.skills.clone()
};
if requested.is_empty() {
return Ok(RemoveOutcome {
installed,
requested: Vec::new(),
removed: Vec::new(),
});
}
let selected = resolve_to_remove(&requested, &installed, &lock_keys);
if selected.is_empty() {
return Ok(RemoveOutcome {
installed,
requested,
removed: Vec::new(),
});
}
let mut removed: Vec<String> = Vec::new();
for name in &selected {
let canonical = get_canonical_path(name, global, &self.env);
let sanitized = sanitize_name(name);
let _ = std::fs::remove_dir_all(&canonical);
let mut lock = read_local_lock(&lock_path(&self.env, global));
lock.version = 1;
lock.skills.remove(name);
lock.skills.remove(&sanitized);
if let Some(parent) = lock_path(&self.env, global).parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = write_local_lock(&lock, &lock_path(&self.env, global));
removed.push(name.clone());
}
Ok(RemoveOutcome {
installed,
requested,
removed,
})
}
pub fn update(&self, req: &UpdateRequest) -> Result<UpdateOutcome> {
let global = resolve_scope(req, &self.env);
let lock_path = lock_path(&self.env, global);
let lock = read_local_lock(&lock_path);
let skills: Vec<(String, LockEntry)> = lock
.skills
.iter()
.filter(|(name, entry)| {
matches_skill(name, &req.skills) && entry.source_type != "local"
})
.map(|(n, e)| (n.clone(), e.clone()))
.collect();
if skills.is_empty() {
return Ok(UpdateOutcome {
global,
..Default::default()
});
}
let mut by_source: BTreeMap<String, Vec<(String, LockEntry)>> = BTreeMap::new();
for (name, entry) in skills {
by_source
.entry(entry.source.clone())
.or_default()
.push((name, entry));
}
let mut outcome = UpdateOutcome {
global,
..Default::default()
};
for (source, items) in &by_source {
let first = &items[0].1;
let clone_url = first.source_url.clone().unwrap_or_else(|| source.clone());
let r#ref = first.r#ref.clone();
let parsed = parse_source(&clone_url)?;
let tmp = match clone_repo(&parsed.url, r#ref.as_deref()) {
Ok(t) => t,
Err(e) => {
for (name, _) in items {
outcome.failures.push(format!("{name}: {e}"));
outcome.failed += 1;
}
continue;
}
};
let discovered = discover_skills(tmp.path(), parsed.subpath.as_deref(), true, true)
.unwrap_or_default();
for (name, entry) in items {
let target = find_skill(&discovered, name, entry.skill_path.as_deref());
let Some(skill) = target else {
outcome
.failures
.push(format!("Skill '{name}' not found in {source}"));
outcome.failed += 1;
continue;
};
let r = install_skill(skill, global, &self.env);
if r.success {
outcome.updated += 1;
} else {
outcome.failed += 1;
}
outcome.updated_names.push(name.clone());
}
}
Ok(outcome)
}
}
#[derive(Default)]
pub struct ManagerBuilder {
home: Option<PathBuf>,
config: Option<PathBuf>,
cwd: Option<PathBuf>,
vars: std::collections::HashMap<String, String>,
}
impl ManagerBuilder {
pub fn home(mut self, p: impl Into<PathBuf>) -> Self {
self.home = Some(p.into());
self
}
pub fn config(mut self, p: impl Into<PathBuf>) -> Self {
self.config = Some(p.into());
self
}
pub fn cwd(mut self, p: impl Into<PathBuf>) -> Self {
self.cwd = Some(p.into());
self
}
pub fn env_var(mut self, k: impl Into<String>, v: impl Into<String>) -> Self {
self.vars.insert(k.into(), v.into());
self
}
pub fn build(self) -> Manager {
let cwd = self
.cwd
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default();
let mut env = Env::new(
self.home.unwrap_or_else(home),
self.config.unwrap_or_else(config_home),
cwd,
);
if !self.vars.is_empty() {
env.set_vars(self.vars);
}
Manager { env }
}
}
#[derive(Debug, Clone, Default)]
pub struct AddRequest {
pub source: String,
pub global: bool,
pub skills: Vec<String>,
pub list_only: bool,
pub full_depth: bool,
}
impl AddRequest {
pub fn new(source: impl Into<String>) -> Self {
AddRequest {
source: source.into(),
..Default::default()
}
}
}
#[derive(Debug, Clone, Default)]
pub struct ListRequest {
pub global: bool,
pub agents: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct RemoveRequest {
pub skills: Vec<String>,
pub global: bool,
pub all: bool,
}
#[derive(Debug, Clone, Default)]
pub struct LinkRequest {
pub agents: Vec<String>,
pub global: bool,
pub migrate: bool,
}
#[derive(Debug, Clone, Default)]
pub struct UnlinkRequest {
pub agents: Vec<String>,
pub global: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Scope {
#[default]
Auto,
Global,
Project,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateRequest {
pub skills: Vec<String>,
pub scope: Scope,
}
#[derive(Debug)]
pub struct AddOutcome {
pub source: Source,
pub skills: Vec<Skill>,
pub selected: Vec<Skill>,
pub installed: Vec<InstallSuccess>,
pub failed: Vec<InstallFailure>,
pub list_only: bool,
}
#[derive(Debug)]
pub struct InstallSuccess {
pub name: String,
pub canonical_path: PathBuf,
}
#[derive(Debug)]
pub struct InstallFailure {
pub skill: String,
pub error: String,
}
#[derive(Debug)]
pub struct AgentLinkResult {
pub agent: String,
pub display: String,
pub outcome: LinkOutcome,
}
#[derive(Debug)]
pub struct AgentUnlinkResult {
pub agent: String,
pub display: String,
pub outcome: UnlinkOutcome,
}
#[derive(Debug)]
pub struct LinkStatus {
pub name: String,
pub display: String,
pub linked: bool,
}
#[derive(Debug)]
pub struct LinkManagerOutcome {
pub global: bool,
pub results: Vec<AgentLinkResult>,
}
#[derive(Debug)]
pub struct UnlinkManagerOutcome {
pub global: bool,
pub results: Vec<AgentUnlinkResult>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ListedSkill {
pub name: String,
pub path: PathBuf,
pub scope: String,
pub agents: Vec<String>,
pub source: Option<String>,
pub source_url: Option<String>,
pub source_type: Option<String>,
}
#[derive(Debug)]
pub struct RemoveOutcome {
pub installed: Vec<String>,
pub requested: Vec<String>,
pub removed: Vec<String>,
}
#[derive(Debug, Default)]
pub struct UpdateOutcome {
pub global: bool,
pub updated: usize,
pub failed: usize,
pub updated_names: Vec<String>,
pub failures: Vec<String>,
}
fn resolve_target_agents(names: &[String], env: &Env) -> Result<Vec<&'static Agent>> {
if names.iter().any(|a| a == "*") {
return Ok(AGENTS.iter().collect());
}
if !names.is_empty() {
let mut agents = Vec::new();
let mut invalid = Vec::new();
for name in names {
match get_agent(name) {
Some(a) => agents.push(a),
None => invalid.push(name.clone()),
}
}
if !invalid.is_empty() {
return Err(SkillsError::InvalidAgents(invalid.join(", ")));
}
return Ok(agents);
}
let installed = detect_installed_agents(env);
Ok(ensure_universal_agents(installed))
}
fn find_skill<'a>(
discovered: &'a [Skill],
name: &str,
skill_path: Option<&str>,
) -> Option<&'a Skill> {
let sanitized = sanitize_name(name);
if let Some(s) = discovered
.iter()
.find(|s| sanitize_name(&s.name) == sanitized)
{
return Some(s);
}
if let Some(sp) = skill_path
&& let Some(dn) = sp.split('/').rfind(|p| !p.is_empty())
&& let Some(s) = discovered.iter().find(|s| {
s.dir
.file_name()
.map(|f| f.to_string_lossy() == dn)
.unwrap_or(false)
})
{
return Some(s);
}
discovered.first().filter(|_| discovered.len() == 1)
}
fn matches_skill(name: &str, filter: &[String]) -> bool {
if filter.is_empty() {
return true;
}
let lower = name.to_lowercase();
filter.iter().any(|f| f.to_lowercase() == lower)
}
fn resolve_to_remove(
requested: &[String],
installed: &[String],
lock_keys: &[String],
) -> Vec<String> {
let mut identity: HashMap<String, String> = HashMap::new();
for folder in installed {
identity
.entry(sanitize_name(folder))
.or_insert_with(|| folder.clone());
}
for key in lock_keys {
identity.insert(sanitize_name(key), key.clone());
}
let mut matched = HashSet::new();
for name in requested {
if let Some(hit) = identity.get(&sanitize_name(name)) {
matched.insert(hit.clone());
}
}
let mut v: Vec<String> = matched.into_iter().collect();
v.sort();
v
}
fn lock_path(env: &Env, global: bool) -> PathBuf {
if global {
global_lock_path(&env.home)
} else {
local_lock_path(&env.cwd)
}
}
fn write_lock(
parsed: &Source,
selected: &[Skill],
successful: &[InstallSuccess],
global: bool,
env: &Env,
) -> Result<()> {
let lock_path = lock_path(env, global);
let mut lock = read_local_lock(&lock_path);
lock.version = 1;
let successful_names: HashSet<&str> = successful.iter().map(|s| s.name.as_str()).collect();
for skill in selected {
if !successful_names.contains(skill.name.as_str()) {
continue;
}
let hash = compute_folder_hash(&skill.dir).unwrap_or_default();
let (source, source_type, source_url, ref_, skill_path) = lock_fields(parsed);
let mut entry = LockEntry::new(&source, &source_type, hash);
entry.source_url = source_url;
entry.r#ref = ref_;
entry.skill_path = skill_path;
lock.skills.insert(sanitize_name(&skill.name), entry);
}
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
write_local_lock(&lock, &lock_path)
}
fn resolve_scope(req: &UpdateRequest, env: &Env) -> bool {
match req.scope {
Scope::Global => true,
Scope::Project => false,
Scope::Auto => !has_project_skills(env),
}
}
fn has_project_skills(env: &Env) -> bool {
if local_lock_path(&env.cwd).exists() {
return true;
}
env.cwd.join(".agents/skills").exists()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::test_utils::{env_at, write_and_parse_skill};
fn skills_with_dirs(pairs: &[(&str, &str)]) -> Vec<Skill> {
pairs
.iter()
.map(|(dir, name)| {
let mut s = write_and_parse_skill(std::path::Path::new(dir), name);
s.dir = std::path::PathBuf::from(dir);
s
})
.collect()
}
#[test]
fn find_skill_prefers_name_then_skill_path() {
let tmp = tempfile::TempDir::new().unwrap();
let a = tmp.path().join("dir-a");
let b = tmp.path().join("dir-b");
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
let skills = skills_with_dirs(&[
(a.to_str().unwrap(), "alpha"),
(b.to_str().unwrap(), "beta"),
]);
assert_eq!(find_skill(&skills, "Alpha", None).unwrap().name, "alpha");
assert_eq!(
find_skill(&skills, "missing", Some("x/dir-b"))
.unwrap()
.name,
"beta"
);
assert!(find_skill(&skills, "missing", None).is_none());
}
#[test]
fn matches_skill_filters_case_insensitively() {
let filter = vec!["PDF".to_string()];
assert!(matches_skill("pdf", &filter));
assert!(!matches_skill("git", &filter));
assert!(matches_skill("anything", &[]));
}
#[test]
fn resolve_to_remove_prefers_lock_keys() {
let installed = vec!["PDF".to_string()];
let lock_keys = vec!["pdf".to_string()];
let requested = vec!["pdf".to_string(), "unknown".to_string()];
assert_eq!(
resolve_to_remove(&requested, &installed, &lock_keys),
vec!["pdf"]
);
}
#[test]
fn resolve_target_agents_validates_names() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
assert!(resolve_target_agents(&["claude-code".to_string()], &env).is_ok());
assert!(matches!(
resolve_target_agents(&["nope".to_string()], &env),
Err(SkillsError::InvalidAgents(_))
));
}
}