use std::cell::OnceCell;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::{Component, Path};
use jiff::Timestamp;
use sha1::Sha1;
use sha2::{Digest, Sha256};
use crate::archive::{self, Limits};
use crate::bail;
use crate::error::{Context, Error, HASH, MANIFEST, NETWORK, Result};
use crate::github;
use crate::http::Http;
use crate::lock::{Dist, Lock, LockedSkill, Package};
use crate::manifest::{self, GitHub, Manifest, Repository, SkillReq};
use crate::project::{self, LinkState, STORE};
use crate::registry::{self, Candidate, Registry};
use crate::scope::Scope;
use crate::skill::{self, FileEntry};
use crate::version::{self, Constraint};
pub struct Ctx {
pub s: Scope,
pub verbose: bool,
pub limits: Limits,
pub interactive: bool,
http: OnceCell<Http>,
}
struct Fetched {
dir: tempfile::TempDir,
entries: Vec<FileEntry>,
links: Vec<archive::Link>,
skipped: Vec<archive::Skipped>,
}
impl Fetched {
fn skill_files(&self, package: &str, skill: &str, dir: &str) -> Result<Vec<FileEntry>> {
let prefix = if dir.is_empty() { String::new() } else { format!("{}/", dir.trim_end_matches('/')) };
if let Some(x) = self.skipped.iter().find(|x| x.path.starts_with(&prefix)) {
bail!(crate::error::IO, "{package}: {}", x.why);
}
if let Some(l) = archive::bad_link_in(&self.links, dir) {
bail!(
crate::error::IO,
"archive: {package}: skill {skill} contains a symlink {} that does not point to a file inside the skill",
l.path
);
}
Ok(archive::subtree(&self.entries, dir))
}
fn renamed_files(&self, mut files: Vec<FileEntry>, dir: &str, to: &str) -> Result<(Vec<FileEntry>, Option<String>)> {
let Some(e) = files.iter_mut().find(|e| e.path == skill::SKILL_MD) else {
return Ok((files, None));
};
let new = skill::rename_frontmatter(&std::fs::read_to_string(self.dir.path().join(dir).join(skill::SKILL_MD))?, to);
*e = FileEntry::new(e.path.clone(), e.exec, new.as_bytes());
Ok((files, Some(new)))
}
}
impl Ctx {
pub fn new(s: Scope, verbose: bool, limits: Limits, interactive: bool) -> Ctx {
Ctx { s, verbose, limits, interactive, http: OnceCell::new() }
}
fn http(&self) -> Result<&Http> {
if let Some(h) = self.http.get() {
return Ok(h);
}
let h = Http::new(&self.s.config, self.verbose)?;
Ok(self.http.get_or_init(|| h))
}
fn manifest_or_default(&self) -> Result<Manifest> {
if self.s.config.join(manifest::FILE).exists() {
Manifest::load(&self.s.config, self.s.global)
} else {
manifest::parse(manifest::template(self.s.global), self.s.global)
}
}
fn fetch(&self, name: &str, dist: &Dist, expected: Option<&str>, shasum: Option<&str>) -> Result<(String, Fetched)> {
if dist.kind != "zip" {
bail!(MANIFEST, "{name}: dist type {:?} is not supported, only zip", dist.kind);
}
let bytes = self
.http()?
.get(&dist.url, self.limits.download)?
.ok_or_else(|| Error::new(NETWORK, format!("{name}: dist archive not found (404)")))?;
let sha = hex::encode(Sha256::digest(&bytes));
if let Some(exp) = expected
&& exp != sha
{
bail!(HASH, "{name}: downloaded archive has sha256 {sha}, skills.lock expects {exp}");
}
if let Some(s) = shasum {
let sha1 = hex::encode(Sha1::digest(&bytes));
if !s.eq_ignore_ascii_case(&sha1) {
bail!(HASH, "{name}: downloaded archive has sha1 {sha1}, the repository metadata says {s}");
}
}
let dir = project::temp_dir(&self.s)?;
let x = archive::extract(&bytes, dir.path(), &self.limits).ctx(name)?;
Ok((sha, Fetched { dir, entries: x.files, links: x.links, skipped: x.skipped }))
}
fn resolve(&self, m: &Manifest, req: &SkillReq, locked: Option<&Package>, prepared: &mut HashMap<String, Fetched>) -> Result<Package> {
if req.github.is_some() {
let c = github::resolve(self.http()?, req, m.policy.min_release_age, Timestamp::now())?;
return self.lock_candidate(req, &github::repository(&req.name), &c, None, prepared);
}
let (repo, cands) = find(&Registry::new(self.http()?), m, req, locked)?;
let c = registry::select(&cands, &req.name, &req.constraint, m.policy.min_release_age, Timestamp::now())?;
self.lock_candidate(req, &repo, &c, None, prepared)
}
fn relock(
&self,
m: &Manifest,
req: &SkillReq,
p: Option<&Package>,
prepared: &mut HashMap<String, Fetched>,
) -> Result<Option<Package>> {
let Some(p) = p.filter(|p| !source_changed(m, req, p)) else { return Ok(None) };
let fits = || {
let c = Constraint::parse(&req.constraint).ok();
c.zip(version::Version::parse(&p.version)).is_some_and(|(c, v)| c.allows(&v))
};
let c = match req.github {
Some(g) => {
let same_ref = g == GitHub::Ref && p.constraint == req.constraint;
if p.git_ref != Some(g == GitHub::Ref) || !(same_ref || g == GitHub::Tags && fits()) {
return Ok(None);
}
Candidate {
name: p.name.clone(),
version: p.version.clone(),
parsed: version::Version::parse(&p.version).or_else(|| version::Version::parse("0.0.0")).expect("valid"),
time: p.released,
dist: p.dist.clone(),
dist_shasum: None,
skill_paths: Vec::new(),
}
}
None => {
let Some(repo) = locked_repository(m, req, Some(p)).filter(|_| fits()) else { return Ok(None) };
let found =
Registry::new(self.http()?).versions(repo, &req.name)?.and_then(|cs| cs.into_iter().find(|c| c.version == p.version));
let Some(c) = found else { return Ok(None) };
Candidate { dist: p.dist.clone(), time: p.released, dist_shasum: None, ..c }
}
};
let repo = Repository { name: String::new(), url: p.repository.clone() };
self.lock_candidate(req, &repo, &c, Some(&p.dist_sha256), prepared).map(Some)
}
fn lock_candidate(
&self,
req: &SkillReq,
repo: &Repository,
c: &Candidate,
expected: Option<&str>,
prepared: &mut HashMap<String, Fetched>,
) -> Result<Package> {
let (sha, f) = self.fetch(&c.name, &c.dist, expected, c.dist_shasum.as_deref())?;
let discovered = c.skill_paths.is_empty();
let paths = if discovered { github::discover(&f.entries) } else { c.skill_paths.clone() };
let mut found: Vec<(String, String)> = Vec::new();
for p in &paths {
let p = p.trim_start_matches("./").trim_end_matches('/');
let rel = Path::new(p);
if p.is_empty() || p.contains('\\') || rel.components().any(|c| !matches!(c, Component::Normal(_))) {
bail!(MANIFEST, "{}: invalid skill path {p:?} in extra.ai-agent-skill", c.name);
}
let (dir, md) = if p.ends_with(".md") {
(p.rsplit_once('/').map_or("", |(d, _)| d).to_string(), p.to_string())
} else {
(p.to_string(), format!("{p}/{}", skill::SKILL_MD))
};
let text = std::fs::read_to_string(f.dir.path().join(&md)).map_err(|_| match f.skipped.iter().find(|x| x.path == md) {
Some(x) => Error::new(crate::error::IO, format!("{}: {}", c.name, x.why)),
None => Error::new(MANIFEST, format!("{} {}: {md} is missing from the package", c.name, c.version)),
})?;
let name = skill::frontmatter(&text).remove("name").filter(|n| skill::valid_name(n));
let name = if discovered {
let base = dir.rsplit('/').next().filter(|d| !d.is_empty()).unwrap_or_else(|| c.name.rsplit('/').next().unwrap_or(""));
match name.or_else(|| skill::valid_name(base).then(|| base.to_string())) {
Some(n) => n,
None => {
eprintln!("warning: {}: skipping {md}: no valid skill name ([a-z0-9][a-z0-9-]*)", c.name);
continue;
}
}
} else {
name.ok_or_else(|| {
Error::new(MANIFEST, format!("{} {}: {md} has no frontmatter name matching [a-z0-9][a-z0-9-]*", c.name, c.version))
})?
};
found.push((name, dir));
}
for k in req.rename.keys() {
if !found.iter().any(|(n, _)| n == k) {
let have: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
bail!(MANIFEST, "{}: rename: no skill {k:?} in {} (it has: {})", req.name, c.version, have.join(", "));
}
if req.skills.as_ref().is_some_and(|sel| !sel.contains(k)) {
bail!(MANIFEST, "{}: rename: skill {k:?} is not in skills = [...]", req.name);
}
}
if let Some(sel) = &req.skills {
for s in sel {
if !found.iter().any(|(n, _)| n == s) {
let have: Vec<&str> = found.iter().map(|(n, _)| n.as_str()).collect();
bail!(MANIFEST, "{}: no skill {s:?} in {} (it has: {})", req.name, c.version, have.join(", "));
}
}
found.retain(|(n, _)| sel.contains(n));
}
if found.is_empty() {
bail!(MANIFEST, "{} {}: no skills found", c.name, c.version);
}
let mut skills: Vec<LockedSkill> = Vec::new();
for (from, dir) in found {
let to = req.rename.get(&from);
let name = to.unwrap_or(&from).clone();
if let Some(other) = skills.iter().find(|s| s.name == name) {
bail!(MANIFEST, "{}: skill name {name:?} appears twice in the package ({} and {dir})", c.name, other.path);
}
let mut files = f.skill_files(&c.name, &from, &dir)?;
if to.is_some() {
files = f.renamed_files(files, &dir, &name)?.0;
}
let tree_sha256 = skill::tree_hash(&files);
skills.push(LockedSkill { name, from: to.map(|_| from), path: dir, tree_sha256 });
}
skills.sort_by(|a, b| a.name.cmp(&b.name));
prepared.insert(c.name.clone(), f);
Ok(Package {
name: c.name.clone(),
constraint: req.constraint.clone(),
git_ref: req.github.map(|g| g == GitHub::Ref),
version: c.version.clone(),
repository: repo.url.clone(),
dist: c.dist.clone(),
dist_sha256: sha,
released: c.time,
selected: req.skills.clone().map(|mut v| {
v.sort();
v
}),
skills,
})
}
fn preflight(&self, m: &Manifest, lock: &Lock, old: &Lock) -> Result<()> {
for p in m.targets.iter().map(String::as_str).chain([STORE, ".gitignore"]) {
project::no_symlink(&self.s, p)?;
}
if let Some(c) = self.conflicts(m, lock, old, &BTreeSet::new())?.first() {
let renames = m.skill(&c.package).map(|r| r.rename.clone()).unwrap_or_default();
bail!(MANIFEST, "{}", hint(c, &self.taken_names(m, lock)?, &renames));
}
let s = &self.s;
let old_managed = project::block_managed(s)?;
let locked = locked_names(lock)?;
let locals = project::local_names(s, &m.local)?;
let kept = kept(s, lock, old, &old_managed);
let hand = hand_written(s, lock, &old_managed.difference(&kept).cloned().collect(), &locals)?;
for (at, source, n) in link_plan(m, &locked, &hand, &locals) {
let removed = old_managed.contains(&n) && !kept.contains(&n) && !locked.contains_key(&n);
if !(at == STORE && removed) && not_ours(s, m, &at, &n, &project::link_state(s, &at, source, &n), &old_managed) {
bail!(MANIFEST, "{} exists and was not created by skillcask; move it away", s.show(&format!("{at}/{n}")));
}
}
Ok(())
}
fn conflicts(&self, m: &Manifest, lock: &Lock, old: &Lock, newcomers: &BTreeSet<String>) -> Result<Vec<Conflict>> {
let s = &self.s;
let mut out = Vec::new();
let conflict = |p: &Package, sk: &LockedSkill, msg: String| Conflict {
package: p.name.clone(),
upstream: sk.from.clone().unwrap_or_else(|| sk.name.clone()),
msg,
rename: true,
};
let mut all: Vec<(&Package, &LockedSkill)> = lock.skills().collect();
all.sort_by_key(|(p, _)| newcomers.contains(&p.name));
let mut locked: BTreeMap<&str, (&Package, &LockedSkill)> = BTreeMap::new();
for (p, sk) in all {
match locked.get(sk.name.as_str()) {
Some((first, _)) => {
out.push(conflict(p, sk, format!("skill {:?} is provided by both {} and {}", sk.name, first.name, p.name)))
}
None => {
locked.insert(&sk.name, (p, sk));
}
}
}
let locals = project::local_names(s, &m.local)?;
for (n, (p, sk)) in &locked {
if locals.contains(*n) {
out.push(conflict(p, sk, format!("skill {n:?} is both a project skill in {} and provided by {}", m.local, p.name)));
}
}
let old_managed = project::block_managed(s)?;
let stale = project::stale_local_links(s, &m.local, &locals)?;
let owned = match project::installed_here(s)? {
Some(names) => Owned::Names(names),
None => {
let mut hashes = project::lock_history_hashes(s);
hashes.extend(lock.skills().chain(old.skills()).map(|(_, sk)| sk.tree_sha256.clone()));
Owned::Hashes(hashes)
}
};
for (n, (p, sk)) in &locked {
let present = project::store_dir(s, n).symlink_metadata().is_ok();
if present
&& !stale.iter().any(|x| x == n)
&& let Some((msg, rename)) = store_conflict(s, p, sk, old, &owned)
{
out.push(Conflict { rename, ..conflict(p, sk, msg) });
}
}
for (n, (p, sk)) in &locked {
for t in &m.targets {
if not_ours(s, m, t, n, &project::link_state(s, t, STORE, n), &old_managed) {
out.push(conflict(
p,
sk,
format!("{} exists and was not created by skillcask; move it away", s.show(&format!("{t}/{n}"))),
));
}
}
}
Ok(out)
}
fn taken_names(&self, m: &Manifest, lock: &Lock) -> Result<BTreeSet<String>> {
let s = &self.s;
let mut out: BTreeSet<String> = lock.skills().map(|(_, sk)| sk.name.clone()).collect();
out.extend(project::local_names(s, &m.local)?);
out.extend(project::store_names(s)?);
for t in &m.targets {
if let Ok(rd) = std::fs::read_dir(s.root.join(t)) {
out.extend(rd.flatten().map(|e| e.file_name().to_string_lossy().into_owned()));
}
}
Ok(out)
}
fn rename_locked(&self, lock: &mut Lock, prepared: &HashMap<String, Fetched>, pkg: &str, upstream: &str, to: &str) -> Result<()> {
let f = prepared.get(pkg).expect("newcomers are prepared");
let p = lock.packages.iter_mut().find(|p| p.name == pkg).expect("conflicts name locked packages");
let s = p.skills.iter_mut().find(|s| s.from.as_deref().unwrap_or(&s.name) == upstream).expect("conflicts name locked skills");
let (files, _) = f.renamed_files(f.skill_files(pkg, upstream, &s.path)?, &s.path, to)?;
s.tree_sha256 = skill::tree_hash(&files);
s.from = Some(upstream.to_string());
s.name = to.to_string();
p.skills.sort_by(|a, b| a.name.cmp(&b.name));
Ok(())
}
fn settle_conflicts(
&self,
m: &Manifest,
lock: &mut Lock,
old: &Lock,
prepared: &HashMap<String, Fetched>,
newcomers: &BTreeSet<String>,
shadows_too: bool,
) -> Result<Vec<SkillReq>> {
if !self.interactive {
return Ok(Vec::new());
}
let mut changed: BTreeMap<String, SkillReq> = BTreeMap::new();
let mut rename = |lock: &mut Lock, pkg: &str, upstream: &str, to: String| -> Result<()> {
self.rename_locked(lock, prepared, pkg, upstream, &to)?;
let req = changed.entry(pkg.to_string()).or_insert_with(|| m.skill(pkg).cloned().expect("newcomers are in the manifest"));
req.rename.insert(upstream.to_string(), to);
Ok(())
};
let mut last: Option<(String, String)> = None;
loop {
let taken = self.taken_names(m, lock)?;
let Some(c) = self.conflicts(m, lock, old, newcomers)?.into_iter().find(|c| c.rename && newcomers.contains(&c.package)) else {
break;
};
if last.as_ref().is_some_and(|(p, u)| *p == c.package && *u == c.upstream) {
break;
}
let Some(to) = ask(&question(&c.msg, &c.package), suggest(&c.package, &c.upstream, &taken).as_deref(), &taken)? else {
break;
};
rename(lock, &c.package, &c.upstream, to)?;
last = Some((c.package, c.upstream));
}
if shadows_too {
let news: Vec<(String, LockedSkill)> =
lock.skills().filter(|(p, _)| newcomers.contains(&p.name)).map(|(p, s)| (p.name.clone(), s.clone())).collect();
for (p, s) in news {
let Some(w) = shadows(&self.s, m, [&s.name]).into_iter().next() else { continue };
let taken = self.taken_names(m, lock)?;
let upstream = s.from.clone().unwrap_or(s.name.clone());
let mut suggestion = suggest(&p, &upstream, &taken);
while let Some(to) = ask(&question(&w, &p), suggestion.as_deref(), &taken)? {
if let Some(why) = shadows(&self.s, m, [&to]).into_iter().next() {
eprintln!("{why}");
suggestion = None;
continue;
}
rename(lock, &p, &upstream, to)?;
break;
}
}
}
Ok(changed.into_values().collect())
}
fn materialize(&self, m: &Manifest, lock: &Lock, old: &Lock, mut prepared: HashMap<String, Fetched>) -> Result<()> {
self.preflight(m, lock, old)?;
let s = &self.s;
project::clean_leftovers(s, &prepared.values().map(|f| f.dir.path()).collect::<Vec<_>>())?;
let old_managed = project::block_managed(s)?;
let old_block = project::read_block(s)?;
let locked = locked_names(lock)?;
let locals = project::local_names(s, &m.local)?;
for n in project::stale_local_links(s, &m.local, &locals)? {
project::unlink(s, STORE, &n)?;
}
let kept = kept(s, lock, old, &old_managed);
let hand = hand_written(s, lock, &old_managed.difference(&kept).cloned().collect(), &locals)?;
let mut modes = project::read_modes(s);
for p in &lock.packages {
let need: Vec<&LockedSkill> = p.skills.iter().filter(|sk| !installed_ok(s, sk)).collect();
if need.is_empty() {
continue;
}
let f = match prepared.remove(&p.name) {
Some(f) => f,
None => self.fetch(&p.name, &p.dist, Some(&p.dist_sha256), None)?.1,
};
for sk in need {
let mut files = f.skill_files(&p.name, sk.from.as_ref().unwrap_or(&sk.name), &sk.path)?;
let mut skill_md = None;
if sk.from.is_some() {
(files, skill_md) = f.renamed_files(files, &sk.path, &sk.name)?;
}
let got = skill::tree_hash(&files);
if got != sk.tree_sha256 {
bail!(
HASH,
"{} {}: skill {} has tree-sha256 {got}, skills.lock expects {}",
p.name,
p.version,
sk.name,
sk.tree_sha256
);
}
project::place(s, &sk.name, &f.dir.path().join(&sk.path))?;
if let Some(md) = skill_md {
std::fs::write(project::store_dir(s, &sk.name).join(skill::SKILL_MD), md)?;
}
set_modes(&mut modes, &sk.name, &files);
println!("installed {} ({} {})", sk.name, p.name, p.version);
}
}
for n in old_managed.iter().filter(|n| !locked.contains_key(*n)) {
for t in &m.targets {
project::unlink(s, t, n)?;
}
set_modes(&mut modes, n, &[]);
if kept.contains(n) {
eprintln!("warning: kept {}: modified or not installed by skillcask", s.show(&format!("{STORE}/{n}")));
continue;
}
let dir = project::store_dir(s, n);
if dir.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink()) {
std::fs::remove_file(&dir)?;
} else if dir.is_dir() {
std::fs::remove_dir_all(dir)?;
}
println!("removed {n}");
}
project::write_modes(s, &modes)?;
let plan = link_plan(m, &locked, &hand, &locals);
if !s.global {
let names: BTreeSet<&String> = plan.iter().map(|(_, _, n)| n).collect();
for t in &m.targets {
for e in std::fs::read_dir(s.root.join(t)).into_iter().flatten().flatten() {
let n = e.file_name().to_string_lossy().into_owned();
if !names.contains(&n) && project::made_by_skillcask(s, t, STORE, &n, &old_block) {
project::unlink(s, t, &n)?;
}
}
}
}
let mut copies = BTreeSet::new();
for (at, source, n) in plan {
if project::link(s, &at, source, &n)? && !locked.contains_key(&n) {
copies.insert(format!("/{at}/{n}"));
}
}
let managed: BTreeSet<String> = locked.keys().cloned().collect();
project::write_block(s, &project::block_lines(&managed, &m.targets, &copies))?;
project::write_installed_here(s, &managed)?;
let tracked = project::git_tracked(s, &managed_paths(&managed, &m.targets));
if !tracked.is_empty() {
project::git_untrack(s, &tracked)?;
eprintln!("warning: removed managed paths from the git index (files kept): {}", tracked.join(", "));
}
for w in shadows(s, m, locked.keys().chain(&hand).chain(&locals)) {
eprintln!("warning: {w}");
}
Ok(())
}
}
struct Conflict {
package: String,
upstream: String,
msg: String,
rename: bool,
}
fn suggest(package: &str, upstream: &str, taken: &BTreeSet<String>) -> Option<String> {
let vendor: String = package.split('/').next().unwrap_or("").chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).collect();
let name = format!("{vendor}-{upstream}");
(skill::valid_name(&name) && !taken.contains(&name)).then_some(name)
}
fn hint(c: &Conflict, taken: &BTreeSet<String>, renames: &BTreeMap<String, String>) -> String {
if !c.rename {
return c.msg.clone();
}
let to = suggest(&c.package, &c.upstream, taken).unwrap_or_else(|| "<new-name>".into());
let mut all = renames.clone();
all.insert(c.upstream.clone(), to);
let pairs: Vec<String> = all.iter().map(|(k, v)| format!("{k} = \"{v}\"")).collect();
format!("{}\n to install it under another name, set in its entry in {}:\n rename = {{ {} }}", c.msg, manifest::FILE, pairs.join(", "))
}
fn question(msg: &str, package: &str) -> String {
if msg.contains(package) { msg.to_string() } else { format!("{msg} ({package})") }
}
#[derive(Debug, PartialEq)]
enum Answer {
Use(String),
Decline,
Retry(String),
}
fn answer(input: &str, suggestion: Option<&str>, taken: &BTreeSet<String>) -> Answer {
match input.trim() {
"" | "y" | "Y" | "yes" => match suggestion {
Some(s) => Answer::Use(s.into()),
None => Answer::Retry("type a name".into()),
},
"n" | "N" | "no" => Answer::Decline,
n if !skill::valid_name(n) => Answer::Retry(format!("{n:?} is not a valid skill name ([a-z0-9][a-z0-9-]*)")),
n if taken.contains(n) => Answer::Retry(format!("{n:?} is taken too")),
n => Answer::Use(n.into()),
}
}
fn ask(question: &str, suggestion: Option<&str>, taken: &BTreeSet<String>) -> Result<Option<String>> {
loop {
match suggestion {
Some(s) => eprint!("{question}\nInstall it as {s}? [Y/n/<other name>] "),
None => eprint!("{question}\nInstall it under which name? (empty line: no) "),
}
let mut line = String::new();
if std::io::stdin().read_line(&mut line)? == 0 {
eprintln!();
return Ok(None);
}
if suggestion.is_none() && line.trim().is_empty() {
return Ok(None);
}
match answer(&line, suggestion, taken) {
Answer::Use(n) => return Ok(Some(n)),
Answer::Decline => return Ok(None),
Answer::Retry(why) => eprintln!("{why}"),
}
}
}
fn shadows<'a>(s: &Scope, m: &Manifest, names: impl IntoIterator<Item = &'a String>) -> Vec<String> {
let Some(home) = crate::scope::home().filter(|_| !s.global) else { return Vec::new() };
let mut out = Vec::new();
for n in names {
for d in std::iter::once(STORE).chain(m.targets.iter().map(String::as_str)) {
if home.join(d).join(n).symlink_metadata().is_ok() {
out.push(if d == ".claude/skills" {
format!("{n}: ~/{d}/{n} shadows the project skill in Claude Code")
} else {
format!("{n}: ~/{d}/{n} also exists; the agent may show both")
});
}
}
}
out
}
fn locked_names(lock: &Lock) -> Result<BTreeMap<String, (&Package, &LockedSkill)>> {
let mut out: BTreeMap<String, (&Package, &LockedSkill)> = BTreeMap::new();
for (p, s) in lock.skills() {
if let Some((other, _)) = out.insert(s.name.clone(), (p, s)) {
bail!(MANIFEST, "skill {:?} is provided by both {} and {}", s.name, other.name, p.name);
}
}
Ok(out)
}
fn not_ours(s: &Scope, m: &Manifest, at: &str, n: &str, state: &LinkState, old_managed: &BTreeSet<String>) -> bool {
match state {
LinkState::Foreign => true,
LinkState::Wrong => {
let path = s.root.join(at).join(n);
path.symlink_metadata().is_ok_and(|m| m.file_type().is_symlink())
&& !old_managed.contains(n)
&& !project::points_into(s, &path, STORE)
&& (s.global || !project::points_into(s, &path, &m.local))
}
_ => false,
}
}
fn hand_written(s: &Scope, lock: &Lock, old_managed: &BTreeSet<String>, locals: &BTreeSet<String>) -> Result<BTreeSet<String>> {
if s.global {
return Ok(BTreeSet::new());
}
let locked: BTreeSet<&str> = lock.skills().map(|(_, sk)| sk.name.as_str()).collect();
Ok(project::store_names(s)?
.into_iter()
.filter(|n| !locked.contains(n.as_str()) && !old_managed.contains(n) && !locals.contains(n))
.collect())
}
fn link_plan<'a>(
m: &'a Manifest,
locked: &BTreeMap<String, (&Package, &LockedSkill)>,
hand: &BTreeSet<String>,
locals: &BTreeSet<String>,
) -> Vec<(String, &'a str, String)> {
let mut plan: Vec<(String, &str, String)> = locals.iter().map(|n| (STORE.to_string(), m.local.as_str(), n.clone())).collect();
for n in locked.keys().chain(hand).chain(locals) {
plan.extend(m.targets.iter().map(|t| (t.clone(), STORE, n.clone())));
}
plan
}
fn locked_skill<'a>(lock: &'a Lock, name: &str) -> Option<(&'a Package, &'a LockedSkill)> {
lock.skills().find(|(_, sk)| sk.name == name)
}
enum Owned {
Names(BTreeSet<String>),
Hashes(BTreeSet<String>),
}
fn store_conflict(s: &Scope, p: &Package, sk: &LockedSkill, old: &Lock, owned: &Owned) -> Option<(String, bool)> {
let shown = s.show(&format!("{STORE}/{}", sk.name));
let h = tree_on_disk(s, &sk.name);
if h.as_ref().is_some_and(|h| *h == sk.tree_sha256 || matches!(owned, Owned::Hashes(hs) if hs.contains(h))) {
return None;
}
let (Some(h), Some((op, os))) = (h, locked_skill(old, &sk.name)) else {
return Some((format!("{shown} exists and is not managed by skillcask, but {} provides a skill with that name", p.name), true));
};
let away = "move it away (or delete it), then run `skillcask install`";
if h == os.tree_sha256 {
None
} else if !matches!(owned, Owned::Names(n) if n.contains(&sk.name)) {
Some((format!("{shown} exists and was not installed by skillcask on this machine; {away}"), false))
} else if op.version == p.version && os.tree_sha256 == sk.tree_sha256 {
None
} else {
Some((format!("{shown} was modified after skillcask installed it; {away}"), false))
}
}
fn kept(s: &Scope, lock: &Lock, old: &Lock, old_managed: &BTreeSet<String>) -> BTreeSet<String> {
old_managed
.iter()
.filter(|n| locked_skill(lock, n).is_none())
.filter(|n| project::store_dir(s, n).symlink_metadata().is_ok_and(|m| !m.file_type().is_symlink()))
.filter(|n| tree_on_disk(s, n) != locked_skill(old, n).map(|(_, sk)| sk.tree_sha256.clone()))
.cloned()
.collect()
}
fn installed_ok(s: &Scope, sk: &LockedSkill) -> bool {
tree_on_disk(s, &sk.name).is_some_and(|h| h == sk.tree_sha256)
}
fn tree_on_disk(s: &Scope, name: &str) -> Option<String> {
let dir = project::store_dir(s, name);
let mut entries = skill::scan_dir(&dir).ok().filter(|_| dir.is_dir())?;
if !cfg!(unix) {
let modes = project::read_modes(s);
for e in &mut entries {
e.exec = modes.contains(&format!("{name}/{}", e.path));
}
}
Some(skill::tree_hash(&entries))
}
fn set_modes(modes: &mut BTreeSet<String>, name: &str, files: &[FileEntry]) {
let prefix = format!("{name}/");
modes.retain(|l| !l.starts_with(&prefix));
modes.extend(files.iter().filter(|f| f.exec).map(|f| format!("{prefix}{}", f.path)));
}
fn managed_paths(managed: &BTreeSet<String>, targets: &[String]) -> Vec<String> {
managed.iter().flat_map(|n| std::iter::once(format!("{STORE}/{n}")).chain(targets.iter().map(move |t| format!("{t}/{n}")))).collect()
}
fn reconcile(m: &Manifest, lock: &Lock) -> (Vec<SkillReq>, Vec<String>, Vec<String>) {
let (mut needs, mut stale, mut why) = (Vec::new(), Vec::new(), Vec::new());
for req in &m.skills {
if let Some(r) = drift(m, req, lock.get(&req.name)) {
why.push(format!("{}: {r}", req.name));
needs.push(req.clone());
}
}
for p in &lock.packages {
if m.skill(&p.name).is_none() {
why.push(format!("{}: in skills.lock but not in skills.toml", p.name));
stale.push(p.name.clone());
}
}
(needs, stale, why)
}
fn drift(m: &Manifest, req: &SkillReq, p: Option<&Package>) -> Option<String> {
let Some(p) = p else { return Some("not in skills.lock".into()) };
if p.constraint != req.constraint {
return Some(format!("constraint changed from {:?} to {:?}", p.constraint, req.constraint));
}
if source_changed(m, req, p) {
return Some("repository changed".into());
}
let mode = |r: bool| if r { "ref" } else { "version" };
if let (Some(g), Some(r)) = (req.github, p.git_ref)
&& r != (g == GitHub::Ref)
{
return Some(format!("changed from {} to {}", mode(r), mode(!r)));
}
let upstream: BTreeSet<&String> = p.skills.iter().map(|s| s.from.as_ref().unwrap_or(&s.name)).collect();
let selection_changed = match &req.skills {
Some(sel) => sel.iter().collect::<BTreeSet<_>>() != upstream,
None => p.selected.is_some(),
};
if selection_changed {
return Some("skill selection changed".into());
}
let renamed: BTreeMap<&String, &String> = p.skills.iter().filter_map(|s| s.from.as_ref().map(|f| (f, &s.name))).collect();
if renamed != req.rename.iter().collect() {
return Some("rename changed".into());
}
None
}
fn locked_repository<'a>(m: &'a Manifest, req: &SkillReq, locked: Option<&Package>) -> Option<&'a Repository> {
let p = locked.filter(|p| req.github.is_none() && !source_changed(m, req, p))?;
m.repositories.iter().find(|r| r.url == p.repository)
}
fn find(reg: &Registry, m: &Manifest, req: &SkillReq, locked: Option<&Package>) -> Result<(Repository, Vec<Candidate>)> {
let Some(repo) = locked_repository(m, req, locked) else {
return reg.find(&m.repositories, &req.name, req.repository.as_deref());
};
match reg.versions(repo, &req.name)? {
Some(c) if !c.is_empty() => Ok((repo.clone(), c)),
Some(_) => bail!(MANIFEST, "{} in repository {} has no installable ai-agent-skill versions", req.name, repo.name),
None => {
let url = url::Url::parse(&repo.url).map_or_else(|_| repo.name.clone(), |u| crate::http::redact(&u));
bail!(
MANIFEST,
"{}: not found in its locked repository {url}; if it moved on purpose, set repository = \"…\" in {}",
req.name,
manifest::FILE
)
}
}
}
fn default_constraint(c: &Candidate, allow_ranges: bool) -> String {
let v = c.version.trim_start_matches(['v', 'V']);
if allow_ranges {
c.parsed.major_minor()
} else if version::is_exact(v) {
v.to_string()
} else {
let [a, b, p, _] = c.parsed.parts;
format!("{a}.{b}.{p}")
}
}
fn source_changed(m: &Manifest, req: &SkillReq, p: &Package) -> bool {
match (req.github, &req.repository) {
(Some(_), _) => !p.repository.ends_with(&format!("/repos/{}", req.name)),
(None, Some(r)) => m.repository(r).is_some_and(|r| r.url != p.repository),
(None, None) => !m.repositories.iter().any(|r| r.url == p.repository),
}
}
pub fn init(ctx: &Ctx) -> Result<()> {
let path = ctx.s.config.join(manifest::FILE);
let shown = if ctx.s.global { path.display().to_string() } else { manifest::FILE.into() };
if path.exists() {
bail!(MANIFEST, "{shown} already exists");
}
std::fs::create_dir_all(&ctx.s.config)?;
std::fs::write(&path, manifest::template(ctx.s.global))?;
println!("created {shown}");
Ok(())
}
pub fn add(ctx: &Ctx, spec: &str, repository: Option<String>) -> Result<()> {
if let Some(rest) = spec.strip_prefix("github:") {
if repository.is_some() {
bail!(MANIFEST, "--repository does not apply to github: sources");
}
return add_github(ctx, rest);
}
let (name, constraint) = match spec.split_once('@') {
Some((n, c)) => (n.to_string(), Some(c.to_string())),
None => (spec.to_string(), None),
};
if !manifest::valid_package_name(&name) {
bail!(MANIFEST, "{name:?} is not a valid package name (vendor/name, lowercase)");
}
let m = ctx.manifest_or_default()?;
if let Some(r) = &repository
&& m.repository(r).is_none()
{
bail!(MANIFEST, "unknown repository {r:?}; declare it under [repositories] in {}", manifest::FILE);
}
let existing = m.skill(&name).cloned();
if existing.as_ref().is_some_and(|e| e.github.is_some()) && repository.is_none() {
return add_github(ctx, spec);
}
if let Some(c) = &constraint {
Constraint::parse(c).map_err(|e| Error::new(MANIFEST, e))?;
if !m.policy.allow_ranges && !version::is_exact(c) {
bail!(MANIFEST, "policy.allow-ranges = false requires an exact version, got {c:?}");
}
}
let repository = repository.or_else(|| existing.as_ref().and_then(|e| e.repository.clone()));
let constraint = constraint.or_else(|| existing.as_ref().map(|e| e.constraint.clone()));
let lock = Lock::load(&ctx.s.config)?;
let probe = SkillReq { name: name.clone(), repository: repository.clone(), ..Default::default() };
let (repo, cands) = find(&Registry::new(ctx.http()?), &m, &probe, lock.get(&name))?;
let c = registry::select(&cands, &name, constraint.as_deref().unwrap_or("*"), m.policy.min_release_age, Timestamp::now())?;
let constraint = constraint.unwrap_or_else(|| default_constraint(&c, m.policy.allow_ranges));
let (skills, rename) = existing.map(|e| (e.skills, e.rename)).unwrap_or_default();
let req = SkillReq { name, constraint, repository, skills, rename, ..Default::default() };
finish_add(ctx, &m, &req, &repo, &c, None)
}
fn add_github(ctx: &Ctx, spec: &str) -> Result<()> {
let (path, at) = match spec.split_once('@') {
Some((p, a)) => (p, Some(a.to_string())),
None => (spec, None),
};
let mut parts = path.splitn(3, '/');
let name = format!("{}/{}", parts.next().unwrap_or(""), parts.next().unwrap_or("")).to_lowercase();
if !manifest::valid_package_name(&name) {
bail!(MANIFEST, "{spec:?}: expected github:owner/repo[/skill][@version-or-ref]");
}
let skill = parts.next().map(String::from);
let must_have = skill.clone();
if let Some(s) = &skill
&& !skill::valid_name(s)
{
bail!(MANIFEST, "{s:?} is not a valid skill name ([a-z0-9][a-z0-9-]*)");
}
let m = ctx.manifest_or_default()?;
let existing = m.skill(&name).filter(|e| e.github.is_some()).cloned();
let skills = match (skill, existing.as_ref().map(|e| e.skills.clone())) {
(None, have) => have.flatten(),
(Some(_), Some(None)) => None,
(Some(s), Some(Some(mut have))) => {
if !have.contains(&s) {
have.push(s);
}
Some(have)
}
(Some(s), None) => Some(vec![s]),
};
let (given, mode) = match (at, &existing) {
(Some(a), _) => {
let mode = if Constraint::parse(&a).is_ok() { GitHub::Tags } else { GitHub::Ref };
(Some(a), mode)
}
(None, Some(e)) => (Some(e.constraint.clone()), e.github.expect("filtered to GitHub entries")),
(None, None) if github::tags(ctx.http()?, &name)?.is_empty() => (None, GitHub::Ref),
(None, None) => (None, GitHub::Tags),
};
let any = if mode == GitHub::Tags { "*" } else { manifest::DEFAULT_REF };
let rename = existing.map(|e| e.rename).unwrap_or_default();
let mut req = SkillReq {
name,
constraint: given.clone().unwrap_or_else(|| any.into()),
skills,
github: Some(mode),
rename,
..Default::default()
};
if given.is_some() {
manifest::check_constraint(&req, &m.policy).map_err(|e| Error::new(MANIFEST, format!("{}: {e}", req.name)))?;
}
let c = github::resolve(ctx.http()?, &req, m.policy.min_release_age, Timestamp::now())?;
if given.is_none() {
req.constraint = match (mode, m.policy.allow_ranges) {
(GitHub::Tags, allow_ranges) => default_constraint(&c, allow_ranges),
(GitHub::Ref, true) => manifest::DEFAULT_REF.into(),
(GitHub::Ref, false) => c.dist.reference.clone().expect("GitHub candidates carry their sha"),
};
}
finish_add(ctx, &m, &req, &github::repository(&req.name), &c, must_have.as_deref())
}
fn finish_add(ctx: &Ctx, m: &Manifest, req: &SkillReq, repo: &Repository, c: &Candidate, must_have: Option<&str>) -> Result<()> {
let mut prepared = HashMap::new();
let pkg = ctx.lock_candidate(req, repo, c, None, &mut prepared)?;
if let Some(s) = must_have {
let have: Vec<&str> = pkg.skills.iter().map(|k| k.from.as_deref().unwrap_or(&k.name)).collect();
if !have.contains(&s) {
bail!(MANIFEST, "{}: no skill {s:?} in {} (it has: {})", req.name, c.version, have.join(", "));
}
}
let mut lock = Lock::load(&ctx.s.config)?;
let old = lock.clone();
lock.upsert(pkg);
let mut view = m.clone();
view.skills.retain(|r| r.name != req.name);
view.skills.push(req.clone());
let changed = ctx.settle_conflicts(&view, &mut lock, &old, &prepared, &BTreeSet::from([req.name.clone()]), true)?;
let req = &changed.into_iter().find(|r| r.name == req.name).unwrap_or_else(|| req.clone());
ctx.preflight(&view, &lock, &old)?;
manifest::write_skill(&ctx.s.config, ctx.s.global, req)?;
lock.save(&ctx.s.config)?;
println!("added {} = {:?} ({})", req.name, req.constraint, c.version);
ctx.materialize(&Manifest::load(&ctx.s.config, ctx.s.global)?, &lock, &old, prepared)
}
pub fn remove(ctx: &Ctx, names: &[String]) -> Result<()> {
let m = Manifest::load(&ctx.s.config, ctx.s.global)?;
let mut lock = Lock::load(&ctx.s.config)?;
let old = lock.clone();
for n in names {
if m.skill(n).is_none() && lock.get(n).is_none() {
bail!(MANIFEST, "{n} is not in {} or {}", manifest::FILE, crate::lock::FILE);
}
}
lock.packages.retain(|p| !names.contains(&p.name));
let mut view = m.clone();
view.skills.retain(|r| !names.contains(&r.name));
ctx.preflight(&view, &lock, &old)?;
manifest::remove_skills(&ctx.s.config, ctx.s.global, names)?;
lock.save(&ctx.s.config)?;
ctx.materialize(&Manifest::load(&ctx.s.config, ctx.s.global)?, &lock, &old, HashMap::new())
}
pub fn install(ctx: &Ctx, frozen: bool) -> Result<()> {
let mut m = Manifest::load(&ctx.s.config, ctx.s.global)?;
let mut lock = Lock::load(&ctx.s.config)?;
let old = lock.clone();
let (needs, stale, why) = reconcile(&m, &lock);
let filled = !frozen && fill_new_keys(&m, &mut lock);
let mut prepared = HashMap::new();
if !why.is_empty() {
if frozen {
bail!(
MANIFEST,
"skills.lock is out of date with skills.toml (frozen install):\n {}\nrun `skillcask install` locally and commit skills.lock",
why.join("\n ")
);
}
for req in &needs {
let p = match ctx.relock(&m, req, old.get(&req.name), &mut prepared)? {
Some(p) => p,
None => ctx.resolve(&m, req, old.get(&req.name), &mut prepared)?,
};
lock.upsert(p);
}
lock.packages.retain(|p| !stale.contains(&p.name));
let newcomers = needs.iter().map(|r| r.name.clone()).collect();
m = settle_and_save(ctx, m, &mut lock, &old, &prepared, &newcomers)?;
}
ctx.materialize(&m, &lock, &old, prepared)?;
if filled && why.is_empty() {
lock.save(&ctx.s.config)?;
}
Ok(())
}
fn fill_new_keys(m: &Manifest, lock: &mut Lock) -> bool {
let mut filled = false;
for p in &mut lock.packages {
let Some(req) = m.skill(&p.name).filter(|r| drift(m, r, Some(p)).is_none()) else { continue };
if p.selected.is_none()
&& let Some(sel) = &req.skills
{
let mut sel = sel.clone();
sel.sort();
p.selected = Some(sel);
filled = true;
}
if p.git_ref.is_none()
&& let Some(g) = req.github
{
p.git_ref = Some(g == GitHub::Ref);
filled = true;
}
}
filled
}
pub fn update(ctx: &Ctx, names: &[String]) -> Result<()> {
let m = Manifest::load(&ctx.s.config, ctx.s.global)?;
let mut lock = Lock::load(&ctx.s.config)?;
let old = lock.clone();
for n in names {
if m.skill(n).is_none() {
bail!(MANIFEST, "{n} is not in {}", manifest::FILE);
}
}
let mut prepared = HashMap::new();
let mut newcomers = BTreeSet::new();
let mut report = Vec::new();
for req in m.skills.iter().filter(|r| names.is_empty() || names.contains(&r.name) || old.get(&r.name).is_none()) {
newcomers.insert(req.name.clone());
let new = ctx.resolve(&m, req, old.get(&req.name), &mut prepared)?;
match old.get(&req.name) {
Some(o) if o.version == new.version && o.dist_sha256 == new.dist_sha256 => {}
Some(o) => report.push(format!("updated {} {} -> {}", new.name, o.version, new.version)),
None => report.push(format!("locked {} {}", new.name, new.version)),
}
lock.upsert(new);
}
for req in m.skills.iter().filter(|r| !newcomers.contains(&r.name)) {
if let Some(why) = drift(&m, req, old.get(&req.name)) {
eprintln!("warning: {}: {why}; `skillcask install` updates its lock entry", req.name);
}
}
let (_, stale, _) = reconcile(&m, &lock);
lock.packages.retain(|p| !stale.contains(&p.name));
let m = settle_and_save(ctx, m, &mut lock, &old, &prepared, &newcomers)?;
for r in report {
println!("{r}");
}
ctx.materialize(&m, &lock, &old, prepared)
}
fn settle_and_save(
ctx: &Ctx,
m: Manifest,
lock: &mut Lock,
old: &Lock,
prepared: &HashMap<String, Fetched>,
newcomers: &BTreeSet<String>,
) -> Result<Manifest> {
let changed = ctx.settle_conflicts(&m, lock, old, prepared, newcomers, false)?;
ctx.preflight(&m, lock, old)?;
for r in &changed {
manifest::write_skill(&ctx.s.config, ctx.s.global, r)?;
}
lock.save(&ctx.s.config)?;
if changed.is_empty() { Ok(m) } else { Manifest::load(&ctx.s.config, ctx.s.global) }
}
pub fn outdated(ctx: &Ctx) -> Result<()> {
let m = Manifest::load(&ctx.s.config, ctx.s.global)?;
let lock = Lock::load(&ctx.s.config)?;
let reg = Registry::new(ctx.http()?);
let now = Timestamp::now();
let mut rows = vec![["package", "locked", "within constraint", "latest", "note"].map(String::from)];
for req in &m.skills {
let locked = lock.get(&req.name).map_or("-".into(), |p| p.version.clone());
if req.github.is_some() {
let (within, latest) = github::newest(ctx.http()?, req)?;
let dash = |v: Option<String>| v.unwrap_or_else(|| "-".into());
rows.push([req.name.clone(), locked, dash(within), dash(latest), String::new()]);
continue;
}
let (_, cands) = reg.find(&m.repositories, &req.name, req.repository.as_deref())?;
let c = Constraint::parse(&req.constraint).map_err(|e| Error::new(MANIFEST, e))?;
let within = registry::newest(&cands, Some(&c));
let note = match within.and_then(|w| w.time.map(|t| (w, t))) {
Some((w, t)) if now.checked_sub(m.policy.min_release_age).ok().is_none_or(|c| t > c) => {
let eligible = t.checked_add(m.policy.min_release_age).unwrap_or(Timestamp::MAX);
format!("{} eligible {}", w.version, eligible.round(jiff::Unit::Second).unwrap_or(eligible))
}
_ => String::new(),
};
let v = |c: Option<&Candidate>| c.map_or("-".to_string(), |c| c.version.clone());
rows.push([req.name.clone(), locked, v(within), v(registry::newest(&cands, None)), note]);
}
print_table(&rows);
Ok(())
}
pub fn status(ctx: &Ctx) -> Result<()> {
let m = Manifest::load(&ctx.s.config, ctx.s.global)?;
let lock = Lock::load(&ctx.s.config)?;
let mut rows = vec![["package", "version", "skill", "state"].map(String::from)];
let mut bad = 0;
let mut row = |rows: &mut Vec<[String; 4]>, p: &str, v: &str, s: &str, state: &str| {
if !matches!(state, "ok" | "hand-written" | "project") {
bad += 1;
}
rows.push([p, v, s, state].map(String::from));
};
for req in &m.skills {
let p = lock.get(&req.name);
if let Some(why) = drift(&m, req, p) {
row(&mut rows, &req.name, p.map_or("-", |p| &p.version), "-", &why);
continue;
}
let Some(p) = p else { continue };
for sk in &p.skills {
let state = match tree_on_disk(&ctx.s, &sk.name) {
None => "not installed",
Some(h) if h != sk.tree_sha256 => "modified",
Some(_) => "ok",
};
row(&mut rows, &p.name, &p.version, &sk.name, state);
}
}
let mut providers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for (p, sk) in lock.skills() {
providers.entry(&sk.name).or_default().push(&p.name);
}
for (n, ps) in providers.iter().filter(|(_, ps)| ps.len() > 1) {
row(&mut rows, "-", "-", n, &format!("skill {n:?} is provided by both {}", ps.join(" and ")));
}
for p in lock.packages.iter().filter(|p| m.skill(&p.name).is_none()) {
row(&mut rows, &p.name, &p.version, "-", "not in skills.toml");
}
let locals = project::local_names(&ctx.s, &m.local)?;
for h in hand_written(&ctx.s, &lock, &project::block_managed(&ctx.s)?, &locals)? {
row(&mut rows, "-", "-", &h, "hand-written");
}
for l in &locals {
let state = if project::link_state(&ctx.s, STORE, &m.local, l) == LinkState::Ok { "project" } else { "not linked" };
row(&mut rows, "-", "-", l, state);
}
print_table(&rows);
if bad > 0 {
bail!(MANIFEST, "{bad} problem(s); run `skillcask install`");
}
Ok(())
}
pub fn doctor(ctx: &Ctx, fix: bool) -> Result<()> {
let s = &ctx.s;
let m = Manifest::load(&s.config, s.global)?;
let lock = Lock::load(&s.config)?;
for p in m.targets.iter().map(String::as_str).chain([STORE, ".gitignore"]) {
project::no_symlink(s, p)?;
}
let locked = locked_names(&lock)?;
let locals = project::local_names(s, &m.local)?;
let old_managed = project::block_managed(s)?;
let hand = hand_written(s, &lock, &old_managed, &locals)?;
let mut open = Vec::new();
let mut fixed = Vec::new();
let mut copies = BTreeSet::new();
for (at, source, n) in link_plan(&m, &locked, &hand, &locals) {
let state = project::link_state(s, &at, source, &n);
let what = format!("{}: link {}", s.show(&format!("{at}/{n}")), format!("{state:?}").to_lowercase());
if state != LinkState::Foreign && not_ours(s, &m, &at, &n, &state, &old_managed) {
open.push(format!("{} exists and was not created by skillcask; move it away", s.show(&format!("{at}/{n}"))));
} else if state != LinkState::Ok {
if fix && s.root.join(source).join(&n).is_dir() && state != LinkState::Foreign {
project::link(s, &at, source, &n)?;
fixed.push(what);
} else {
open.push(what);
}
}
let p = s.root.join(&at).join(&n);
let is_copy = p.symlink_metadata().is_ok_and(|m| m.is_dir()) && p.join(project::COPY_MARKER).is_file();
if !locked.contains_key(&n) && project::link_state(s, &at, source, &n) == LinkState::Ok && is_copy {
copies.insert(format!("/{at}/{n}"));
}
}
let managed: BTreeSet<String> = locked.keys().cloned().collect();
let tracked = project::git_tracked(s, &managed_paths(&managed, &m.targets));
if !tracked.is_empty() {
let what = format!("managed paths committed to git: {}", tracked.join(", "));
if fix {
project::git_untrack(s, &tracked)?;
fixed.push(what);
} else {
open.push(what);
}
}
let block_file = project::block_file(s);
let lines = project::block_lines(&managed, &m.targets, &copies);
if project::read_block(s)? != lines {
let what = format!("{block_file}: stale managed block");
if fix {
project::write_block(s, &lines)?;
fixed.push(what);
} else {
open.push(what);
}
}
for (n, (p, sk)) in &locked {
match tree_on_disk(s, n) {
None => open.push(format!("{}: not installed ({}); run `skillcask install`", s.show(&format!("{STORE}/{n}")), p.name)),
Some(h) if h != sk.tree_sha256 => {
open.push(format!("{}: modified ({}); run `skillcask install` to restore", s.show(&format!("{STORE}/{n}")), p.name))
}
Some(_) => {}
}
}
for w in shadows(s, &m, locked.keys().chain(&hand).chain(&locals)) {
println!("warning: {w}");
}
for f in &fixed {
println!("fixed: {f}");
}
for o in &open {
println!("problem: {o}");
}
if !open.is_empty() {
bail!(
MANIFEST,
"{} problem(s){}",
open.len(),
if fix {
String::new()
} else if s.global {
format!("; `skillcask doctor --fix` repairs links and {block_file}")
} else {
"; `skillcask doctor --fix` repairs links and the gitignore block".into()
}
);
}
if fixed.is_empty() {
println!("no problems found");
}
Ok(())
}
pub fn search(ctx: &Ctx, query: &str) -> Result<()> {
let m = ctx.manifest_or_default()?;
let results = Registry::new(ctx.http()?).search(&m.repositories, query)?;
if results.is_empty() {
println!("no skill packages match {query:?}");
}
let width = results.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (n, d) in results {
println!("{n:width$} {d}");
}
Ok(())
}
fn print_table<const N: usize>(rows: &[[String; N]]) {
let mut w = [0; N];
for r in rows {
for (i, c) in r.iter().enumerate() {
w[i] = w[i].max(c.len());
}
}
for r in rows {
let line: Vec<String> = r.iter().enumerate().map(|(i, c)| format!("{c:w$}", w = w[i])).collect();
println!("{}", line.join(" ").trim_end());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn suggestion_and_answers() {
let taken: BTreeSet<String> = ["code-review".into(), "mattpocock-taken".into()].into();
assert_eq!(suggest("mattpocock/skills", "code-review", &taken).as_deref(), Some("mattpocock-code-review"));
assert_eq!(suggest("my_org.x/skills", "a", &taken).as_deref(), Some("my-org-x-a"));
assert_eq!(suggest("mattpocock/skills", "taken", &taken), None);
let s = Some("mp-x");
assert_eq!(answer("", s, &taken), Answer::Use("mp-x".into()));
assert_eq!(answer("Y", s, &taken), Answer::Use("mp-x".into()));
assert_eq!(answer("n", s, &taken), Answer::Decline);
assert_eq!(answer("mine", s, &taken), Answer::Use("mine".into()));
assert!(matches!(answer("", None, &taken), Answer::Retry(_)));
assert!(matches!(answer("Bad Name", s, &taken), Answer::Retry(_)));
assert!(matches!(answer("code-review", s, &taken), Answer::Retry(_)));
let c = Conflict {
package: "acme/a".into(),
upstream: "one".into(),
msg: "skill \"one\" is provided by both x and acme/a".into(),
rename: true,
};
assert_eq!(question(&c.msg, &c.package), c.msg);
assert_eq!(question("one: ~/.claude/skills/one shadows", "acme/a"), "one: ~/.claude/skills/one shadows (acme/a)");
let renames = BTreeMap::from([("a".to_string(), "x".to_string())]);
assert!(hint(&c, &taken, &renames).ends_with("rename = { a = \"x\", one = \"acme-one\" }"), "{}", hint(&c, &taken, &renames));
}
}