use serde::Serialize;
use std::path::Path;
use beck::agents::adapter::Adapter;
use beck::agents::manifest::{Entry, Manifest};
use beck::agents::paths::manifest_path;
use beck::agents::registry;
use beck::error::{CliError, Result};
#[derive(Debug, Clone)]
pub struct UnlinkArgs {
pub skill: Option<String>,
pub agent: Option<String>,
pub all: bool,
}
#[derive(Debug, Default, Serialize, PartialEq)]
pub struct UnlinkReport {
pub removed: Vec<RemovedItem>,
pub failed: Vec<FailedItem>,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct RemovedItem {
pub skill: String,
pub agent: String,
pub target: String,
}
#[derive(Debug, Serialize, PartialEq)]
pub struct FailedItem {
pub skill: String,
pub agent: String,
pub error: String,
}
pub async fn handle(
skill: Option<String>,
agent: Option<String>,
all: bool,
json: bool,
) -> Result<()> {
let adapters = registry::all_adapters();
let manifest_file = manifest_path()?;
let report = run_unlink(
UnlinkArgs { skill, agent, all },
&manifest_file,
adapters.iter().map(|a| a.as_ref()).collect::<Vec<_>>(),
)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&report).unwrap_or_default()
);
} else {
print_human_report(&report);
}
if !report.failed.is_empty() && report.removed.is_empty() {
return Err(CliError::Validation(
"all unlink attempts failed, see above".into(),
));
}
Ok(())
}
pub fn run_unlink(
args: UnlinkArgs,
manifest_file: &Path,
adapters: Vec<&dyn Adapter>,
) -> Result<UnlinkReport> {
if args.skill.is_none() && args.agent.is_none() && !args.all {
return Err(CliError::Validation(
"refusing to unlink without a scope, pass --skill, --agent, or --all".into(),
));
}
if !manifest_file.exists() {
return Ok(UnlinkReport::default());
}
let mut manifest = Manifest::load(manifest_file)?;
let to_remove: Vec<Entry> = manifest
.entries
.iter()
.filter(|e| {
let skill_ok = args.skill.as_deref().is_none_or(|s| e.skill == s);
let agent_ok = args.agent.as_deref().is_none_or(|a| e.agent == a);
skill_ok && agent_ok
})
.cloned()
.collect();
let mut report = UnlinkReport::default();
for entry in &to_remove {
let adapter = adapters.iter().copied().find(|a| a.name() == entry.agent);
let Some(adapter) = adapter else {
manifest.remove(&entry.skill, &entry.agent);
report.removed.push(RemovedItem {
skill: entry.skill.clone(),
agent: entry.agent.clone(),
target: format!(
"{} (no adapter registered, manifest entry pruned)",
entry.target.display()
),
});
continue;
};
match adapter.uninstall(entry) {
Ok(()) => {
manifest.remove(&entry.skill, &entry.agent);
report.removed.push(RemovedItem {
skill: entry.skill.clone(),
agent: entry.agent.clone(),
target: entry.target.display().to_string(),
});
}
Err(e) => {
report.failed.push(FailedItem {
skill: entry.skill.clone(),
agent: entry.agent.clone(),
error: format!("{e}"),
});
}
}
}
manifest.save(manifest_file)?;
Ok(report)
}
fn print_human_report(report: &UnlinkReport) {
if !report.removed.is_empty() {
println!("unlinked {}:", report.removed.len());
for item in &report.removed {
println!(" {}/{} -> {}", item.skill, item.agent, item.target);
}
}
if !report.failed.is_empty() {
eprintln!("failed {}:", report.failed.len());
for item in &report.failed {
eprintln!(" {}/{}: {}", item.skill, item.agent, item.error);
}
}
if report.removed.is_empty() && report.failed.is_empty() {
println!("nothing to unlink");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use beck::agents::manifest::InstallMode;
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn tempdir(name: &str) -> PathBuf {
let base = std::env::temp_dir().join(format!(
"beck-unlink-tests-{name}-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst)
));
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).unwrap();
base
}
fn write_manifest(beck: &std::path::Path, entries: Vec<Entry>) -> PathBuf {
let mut mf = Manifest::empty();
for e in entries {
mf.add(e);
}
let path = beck.join(".beck-manifest.json");
mf.save(&path).unwrap();
path
}
fn sample_entry(skill: &str, agent: &str) -> Entry {
Entry {
skill: skill.into(),
agent: agent.into(),
target: PathBuf::from(format!("/tmp/fake/{skill}/{agent}/SKILL.md")),
mode: InstallMode::Symlink,
sha256: "deadbeef".into(),
installed_at: "2026-04-11T00:00:00Z".into(),
}
}
struct TrackingAdapter {
name: &'static str,
calls: RefCell<Vec<(String, String)>>,
fail: bool,
}
unsafe impl Send for TrackingAdapter {}
unsafe impl Sync for TrackingAdapter {}
impl Adapter for TrackingAdapter {
fn name(&self) -> &'static str {
self.name
}
fn detect(&self) -> bool {
true
}
fn target_root(&self) -> Result<PathBuf> {
Ok(PathBuf::from("/tmp/mock"))
}
fn plan(
&self,
_skill: &beck::agents::skill::Skill,
) -> Result<beck::agents::adapter::InstallPlan> {
unreachable!("plan not used in unlink tests")
}
fn install(&self, _plan: &beck::agents::adapter::InstallPlan) -> Result<Entry> {
unreachable!("install not used in unlink tests")
}
fn uninstall(&self, entry: &Entry) -> Result<()> {
self.calls
.borrow_mut()
.push((entry.skill.clone(), entry.agent.clone()));
if self.fail {
return Err(CliError::Validation(format!(
"mock uninstall failure for {}",
entry.skill
)));
}
Ok(())
}
}
#[test]
fn refuses_without_scope() {
let beck = tempdir("unlink-no-scope");
let manifest_file = beck.join(".beck-manifest.json");
let err = run_unlink(
UnlinkArgs {
skill: None,
agent: None,
all: false,
},
&manifest_file,
vec![],
)
.expect_err("refuses");
assert!(matches!(err, CliError::Validation(_)));
}
#[test]
fn all_removes_every_entry() {
let beck = tempdir("unlink-all");
let manifest_file = write_manifest(
&beck,
vec![
sample_entry("alpha", "claude-code"),
sample_entry("beta", "claude-code"),
],
);
let adapter = TrackingAdapter {
name: "claude-code",
calls: RefCell::new(vec![]),
fail: false,
};
let report = run_unlink(
UnlinkArgs {
skill: None,
agent: None,
all: true,
},
&manifest_file,
vec![&adapter],
)
.unwrap();
assert_eq!(report.removed.len(), 2);
assert_eq!(adapter.calls.borrow().len(), 2);
let mf = Manifest::load(&manifest_file).unwrap();
assert!(mf.entries.is_empty());
}
#[test]
fn skill_filter_only_touches_that_skill() {
let beck = tempdir("unlink-by-skill");
let manifest_file = write_manifest(
&beck,
vec![
sample_entry("alpha", "claude-code"),
sample_entry("beta", "claude-code"),
],
);
let adapter = TrackingAdapter {
name: "claude-code",
calls: RefCell::new(vec![]),
fail: false,
};
let report = run_unlink(
UnlinkArgs {
skill: Some("beta".into()),
agent: None,
all: false,
},
&manifest_file,
vec![&adapter],
)
.unwrap();
assert_eq!(report.removed.len(), 1);
assert_eq!(report.removed[0].skill, "beta");
assert_eq!(adapter.calls.borrow().len(), 1);
assert_eq!(adapter.calls.borrow()[0].0, "beta");
let mf = Manifest::load(&manifest_file).unwrap();
assert_eq!(mf.entries.len(), 1);
assert_eq!(mf.entries[0].skill, "alpha");
}
#[test]
fn failing_uninstall_surfaces_in_report() {
let beck = tempdir("unlink-fail");
let manifest_file = write_manifest(&beck, vec![sample_entry("alpha", "claude-code")]);
let adapter = TrackingAdapter {
name: "claude-code",
calls: RefCell::new(vec![]),
fail: true,
};
let report = run_unlink(
UnlinkArgs {
skill: None,
agent: None,
all: true,
},
&manifest_file,
vec![&adapter],
)
.unwrap();
assert!(report.removed.is_empty());
assert_eq!(report.failed.len(), 1);
let mf = Manifest::load(&manifest_file).unwrap();
assert_eq!(mf.entries.len(), 1);
}
#[test]
fn missing_manifest_is_not_an_error() {
let beck = tempdir("unlink-no-manifest");
let manifest_file = beck.join(".beck-manifest.json");
let report = run_unlink(
UnlinkArgs {
skill: None,
agent: None,
all: true,
},
&manifest_file,
vec![],
)
.unwrap();
assert!(report.removed.is_empty());
assert!(report.failed.is_empty());
}
#[test]
fn unregistered_agent_prunes_without_calling_uninstall() {
let beck = tempdir("unlink-stale-agent");
let manifest_file = write_manifest(&beck, vec![sample_entry("alpha", "cursor-v0")]);
let adapter = TrackingAdapter {
name: "claude-code",
calls: RefCell::new(vec![]),
fail: false,
};
let report = run_unlink(
UnlinkArgs {
skill: None,
agent: None,
all: true,
},
&manifest_file,
vec![&adapter],
)
.unwrap();
assert_eq!(report.removed.len(), 1);
assert!(adapter.calls.borrow().is_empty());
let mf = Manifest::load(&manifest_file).unwrap();
assert!(mf.entries.is_empty());
}
#[test]
fn json_report_serializes_unlink_report() {
let report = UnlinkReport {
removed: vec![RemovedItem {
skill: "caveman".into(),
agent: "claude-code".into(),
target: "/tmp/x/SKILL.md".into(),
}],
failed: vec![],
};
let json = serde_json::to_string(&report).unwrap();
assert!(json.contains("\"removed\""));
assert!(json.contains("caveman"));
}
}