use std::path::PathBuf;
use crate::core::agents::{AGENTS, Env, config_home, home, is_installed, is_native};
use crate::core::discover::{Skill, read_skill};
use crate::core::github::fetch_skill;
use crate::core::install::{ScannedSkill, install_skill, scan_disabled, scan_installed};
use crate::core::link::{is_agent_linked, link_agent, private_content, unlink_agent};
use crate::core::source::{SourceType, parse_source};
use crate::error::{Result, SkillsError};
use crate::manager::select::{SelectionOp, apply_selection, resolve_target_agents};
pub use crate::manager::types::{
AddOutcome, AddRequest, AgentLinkResult, AgentOutcome, AgentRequest, AgentStatus, ListedSkill,
SelectionOutcome, SelectionRequest,
};
mod select;
#[cfg(test)]
mod tests;
mod types;
fn reported_names(skills: &[ScannedSkill]) -> Vec<String> {
skills.iter().map(|s| s.name.clone()).collect()
}
pub struct Manager {
env: Env,
}
impl Manager {
pub fn new() -> Result<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 skill: Skill;
let _temp: Option<tempfile::TempDir>;
match parsed.ty {
SourceType::Local => {
if req.reference.is_some() {
return Err(SkillsError::msg(
"--ref can only pin a GitHub source (`owner/repo/slug`).",
));
}
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()
)));
}
if !path.join("SKILL.md").is_file() {
return Err(SkillsError::msg(format!(
"Not a skill directory: \"{}\" must directly contain a SKILL.md file.",
path.display()
)));
}
let Some(s) = read_skill(path) else {
return Err(SkillsError::msg(format!(
"Not a skill directory: \"{}\" — its SKILL.md must declare \
a non-empty `name` in the frontmatter.",
path.display()
)));
};
skill = s;
_temp = None;
}
SourceType::Github => {
let (tmp, s) = fetch_skill(
&parsed.owner,
&parsed.repo,
&parsed.slug,
req.reference.as_deref(),
)?;
skill = s;
_temp = Some(tmp);
}
}
let result = install_skill(&skill, req.force, &self.env)?;
Ok(AddOutcome {
source: parsed,
skill,
canonical_path: result.canonical_path,
skipped: result.skipped,
})
}
pub fn agent(&self, req: &AgentRequest) -> Result<AgentOutcome> {
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: if req.unlink {
unlink_agent(agent, &self.env)
} else {
link_agent(agent, &self.env)
},
})
.collect();
Ok(AgentOutcome { results })
}
pub fn agent_status(&self) -> Vec<AgentStatus> {
let mut statuses: Vec<AgentStatus> = AGENTS
.iter()
.filter(|a| {
is_installed(a, &self.env)
|| (!is_native(a, &self.env) && is_agent_linked(a, &self.env))
})
.map(|a| {
let canonical = is_native(a, &self.env);
let linked = is_agent_linked(a, &self.env);
let (internal_skills, internal_others) = if linked || canonical {
(Vec::new(), Vec::new())
} else {
private_content(a, &self.env)
};
AgentStatus {
name: a.name.to_string(),
display: a.display.to_string(),
linked,
canonical,
internal_skills,
internal_others,
}
})
.collect();
statuses.sort_by_key(|s| !s.canonical);
statuses
}
pub fn list(&self) -> Result<Vec<ListedSkill>> {
let mut out = Vec::new();
for (skill, enabled) in scan_installed(&self.env)
.into_iter()
.map(|s| (s, true))
.chain(scan_disabled(&self.env).into_iter().map(|s| (s, false)))
{
if skill.internal && !self.env.install_internal_skills() {
continue;
}
out.push(ListedSkill {
name: skill.name,
display_name: skill.display_name,
description: skill.description,
path: skill.path,
enabled,
installed_at: skill.installed_at,
});
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn skill_dir(&self, skill: &ListedSkill) -> PathBuf {
skill.path.clone()
}
pub fn disable(&self, req: &SelectionRequest) -> Result<SelectionOutcome> {
let installed = scan_installed(&self.env);
let disabled = scan_disabled(&self.env);
let available = reported_names(&installed);
if req.skills.is_empty() && !req.all {
return Ok(SelectionOutcome {
available,
requested: Vec::new(),
applied: Vec::new(),
already: Vec::new(),
missing: Vec::new(),
});
}
let requested: Vec<String> = if req.all {
available
} else {
req.skills.clone()
};
let (applied, already, missing) = apply_selection(
SelectionOp::Disable,
&requested,
&[&installed],
&disabled,
&self.env,
)?;
Ok(SelectionOutcome {
available: reported_names(&installed),
requested,
applied,
already,
missing,
})
}
pub fn enable(&self, req: &SelectionRequest) -> Result<SelectionOutcome> {
let disabled = scan_disabled(&self.env);
let installed = scan_installed(&self.env);
let available = reported_names(&disabled);
if req.skills.is_empty() && !req.all {
return Ok(SelectionOutcome {
available,
requested: Vec::new(),
applied: Vec::new(),
already: Vec::new(),
missing: Vec::new(),
});
}
let requested: Vec<String> = if req.all {
available
} else {
req.skills.clone()
};
let (applied, already, missing) = apply_selection(
SelectionOp::Enable,
&requested,
&[&disabled],
&installed,
&self.env,
)?;
Ok(SelectionOutcome {
available: reported_names(&disabled),
requested,
applied,
already,
missing,
})
}
pub fn remove(&self, req: &SelectionRequest) -> Result<SelectionOutcome> {
let installed = scan_installed(&self.env);
let disabled = scan_disabled(&self.env);
let available = reported_names(&installed);
if req.skills.is_empty() && !req.all {
return Ok(SelectionOutcome {
available,
requested: Vec::new(),
applied: Vec::new(),
already: Vec::new(),
missing: Vec::new(),
});
}
let requested: Vec<String> = if req.all {
installed
.iter()
.chain(disabled.iter())
.map(|s| s.name.clone())
.collect()
} else {
req.skills.clone()
};
let (applied, already, missing) = apply_selection(
SelectionOp::Remove,
&requested,
&[&installed, &disabled],
&[],
&self.env,
)?;
Ok(SelectionOutcome {
available: reported_names(&installed),
requested,
applied,
already,
missing,
})
}
}
#[derive(Default)]
pub struct ManagerBuilder {
home: Option<PathBuf>,
config: Option<PathBuf>,
cwd: Option<PathBuf>,
vars: std::collections::HashMap<String, String>,
probe_system_dirs: Option<bool>,
}
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 probe_system_dirs(mut self, probe: bool) -> Self {
self.probe_system_dirs = Some(probe);
self
}
pub fn build(self) -> Result<Manager> {
let cwd = self
.cwd
.or_else(|| std::env::current_dir().ok())
.unwrap_or_default();
let mut env = Env::new(
match self.home {
Some(home) => home,
None => home()?,
},
match self.config {
Some(config) => config,
None => config_home()?,
},
cwd,
);
if !self.vars.is_empty() {
env.set_vars(self.vars);
}
if let Some(v) = env.var("INSTALL_INTERNAL_SKILLS") {
env.set_install_internal_skills(v == "1" || v == "true");
}
if let Some(probe) = self.probe_system_dirs {
env.set_probe_system_dirs(probe);
}
Ok(Manager { env })
}
}