use std::path::Path;
use super::{AgentBackend, BackendState};
use crate::cli::{ClaudeCli, version_lt};
use crate::doctor::DoctorReport;
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
use crate::manifest::{MarketplaceEntry, PluginEntry};
use crate::materialize::{TreeSource, content_hash, materialize};
use crate::stamp;
pub(crate) struct ClaudeBackend;
impl AgentBackend for ClaudeBackend {
fn id(&self) -> &'static str {
"claude"
}
fn detect(&self) -> bool {
which::which("claude").is_ok()
}
fn capabilities(&self) -> Capabilities {
Capabilities {
plugins: true,
mcp: true,
hooks: true,
commands: true,
agents: true,
skills: true,
instructions: false,
scopes: &["user", "project"],
}
}
fn probe(&self, plugin: &Plugin, scope: &Scope, source: &Source) -> Result<BackendState> {
let cli = ClaudeCli::locate()?;
let Some(entry) = find_plugin(&cli, scope, plugin.name, plugin.marketplace)? else {
return Ok(BackendState::Absent);
};
if entry.enabled == Some(false) {
return Ok(BackendState::Disabled);
}
let errors_ok = entry.errors.as_ref().is_none_or(Vec::is_empty);
let files_ok = entry.install_path.as_ref().is_none_or(|p| Path::new(p).exists());
let monotonic_current = !version_lt(entry.version.as_deref(), plugin.version);
let newer = entry.version.as_deref().is_some_and(|v| version_lt(Some(plugin.version), v));
let registry = if errors_ok && files_ok && monotonic_current { BackendState::Healthy } else { BackendState::NeedsRepair };
let registry = if matches!(registry, BackendState::Healthy) {
let marketplace = find_marketplace(&cli, scope, plugin.marketplace)?;
let expected = crate::host::data_root(plugin)?.join(format!("current@{}", ClaudeBackend.id()));
let healthy = matches!(marketplace_health(marketplace.as_ref(), source, &expected), MarketplaceHealth::Healthy)
&& (newer || tree_is_current(plugin, scope, staged_tree_hash(plugin, source).as_deref())?);
if healthy { BackendState::Healthy } else { BackendState::NeedsRepair }
} else {
registry
};
Ok(registry)
}
fn reconcile(&self, plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
reconcile(plugin, desired, scope)
}
fn remove(&self, plugin: &Plugin, scope: &Scope, _source: &Source) -> Result<Outcome> {
remove(plugin, scope)
}
fn report(&self, plugin: &Plugin, source: &Source) -> DoctorReport {
crate::doctor::claude_report(plugin, source)
}
}
pub(crate) fn find_plugin(cli: &ClaudeCli, scope: &Scope, name: &str, marketplace: &str) -> Result<Option<PluginEntry>> {
let entries: Vec<PluginEntry> = cli.run_json(&["plugin", "list", "--json"], scope.cwd(), "plugin list --json")?;
Ok(entries.into_iter().find(|e| e.matches(name, marketplace) && e.at_scope(scope)))
}
pub(crate) fn find_marketplace(cli: &ClaudeCli, scope: &Scope, marketplace: &str) -> Result<Option<MarketplaceEntry>> {
let entries: Vec<MarketplaceEntry> =
cli.run_json(&["plugin", "marketplace", "list", "--json"], scope.cwd(), "marketplace list --json")?;
Ok(entries.into_iter().find(|m| m.name.as_deref() == Some(marketplace)))
}
fn marketplace_has_installed(cli: &ClaudeCli, scope: &Scope, marketplace: &str) -> Result<bool> {
let entries: Vec<PluginEntry> = cli.run_json(&["plugin", "list", "--json"], scope.cwd(), "plugin list --json")?;
Ok(entries.iter().any(|e| e.marketplace() == Some(marketplace)))
}
fn marketplace_add(cli: &ClaudeCli, source: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "marketplace", "add", source, "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
fn marketplace_update(cli: &ClaudeCli, marketplace: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "marketplace", "update", marketplace], scope.cwd())?;
Ok(())
}
fn marketplace_remove(cli: &ClaudeCli, marketplace: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "marketplace", "remove", marketplace, "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
fn plugin_install(cli: &ClaudeCli, id: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "install", id, "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
fn plugin_update(cli: &ClaudeCli, id: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "update", id, "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
fn plugin_uninstall(cli: &ClaudeCli, id: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "uninstall", id, "-y", "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
fn plugin_enable(cli: &ClaudeCli, id: &str, scope: &Scope) -> Result<()> {
cli.run(&["plugin", "enable", id, "--scope", scope.as_cli()], scope.cwd())?;
Ok(())
}
pub(crate) fn reconcile(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
reconcile_registry(plugin, desired, scope)
}
fn reconcile_registry(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<Outcome> {
let cli = ClaudeCli::locate()?;
let marketplace = find_marketplace(&cli, scope, plugin.marketplace)?;
let entry = find_plugin(&cli, scope, plugin.name, plugin.marketplace)?;
let id = plugin.id();
let staged = staged_tree_hash(plugin, &desired.source);
let Some(entry) = entry else {
cli.ensure_min_version()?;
ensure_marketplace(&cli, plugin, &desired.source, scope, marketplace.as_ref())?;
plugin_install(&cli, &id, scope)?;
verify_present(&cli, scope, plugin)?;
record_staged(plugin, desired, scope, staged.as_deref())?;
return Ok(Outcome::Installed);
};
let mut re_enabled = false;
if entry.enabled == Some(false) {
if !desired.reenable {
return Ok(Outcome::NoOp);
}
cli.ensure_min_version()?;
plugin_enable(&cli, &id, scope)?;
re_enabled = true;
}
let installed = entry.version.clone();
let stale = version_lt(installed.as_deref(), plugin.version);
let newer = installed.as_deref().is_some_and(|v| version_lt(Some(plugin.version), v));
let expected = crate::host::data_root(plugin)?.join(format!("current@{}", ClaudeBackend.id()));
let structural_ok = structural_ok(&entry, marketplace.as_ref(), &desired.source, &expected);
if newer {
return Ok(Outcome::NoOp);
}
if structural_ok && !stale && tree_is_current(plugin, scope, staged.as_deref())? {
let outcome = if re_enabled { Outcome::Repaired } else { Outcome::NoOp };
return Ok(outcome);
}
cli.ensure_min_version()?;
ensure_marketplace(&cli, plugin, &desired.source, scope, marketplace.as_ref())?;
if stale && structural_ok {
plugin_update(&cli, &id, scope)?;
verify_present(&cli, scope, plugin)?;
record_staged(plugin, desired, scope, staged.as_deref())?;
Ok(Outcome::Updated { from: installed, to: plugin.version.to_string() })
} else {
stamp::begin_reinstall(plugin, scope, &desired.source, ClaudeBackend.id())?;
let _ = plugin_uninstall(&cli, &id, scope);
plugin_install(&cli, &id, scope)?;
verify_present(&cli, scope, plugin)?;
record_staged(plugin, desired, scope, staged.as_deref())?;
Ok(Outcome::Repaired)
}
}
fn staged_tree_hash(plugin: &Plugin, source: &Source) -> Option<String> {
let client = ClaudeBackend.id();
match source {
Source::Embedded => content_hash(TreeSource::Blob(plugin.blob()), client).ok(),
Source::Path(p) => content_hash(TreeSource::Dir(p), client).ok(),
Source::GitHub { .. } => None,
}
}
fn tree_is_current(plugin: &Plugin, scope: &Scope, staged: Option<&str>) -> Result<bool> {
let Some(staged) = staged else {
return Ok(true);
};
let marker = stamp::read(plugin, scope, ClaudeBackend.id())?;
Ok(marker.and_then(|m| m.tree_hash).is_some_and(|recorded| recorded == staged))
}
fn record_staged(plugin: &Plugin, desired: &Desired, scope: &Scope, staged: Option<&str>) -> Result<()> {
stamp::record_converged(plugin, scope, &desired.source, ClaudeBackend.id(), staged)
}
fn ensure_marketplace(cli: &ClaudeCli, plugin: &Plugin, source: &Source, scope: &Scope, present: Option<&MarketplaceEntry>) -> Result<()> {
let client = ClaudeBackend.id();
let source_str = match source {
Source::Embedded => materialize(plugin, TreeSource::Blob(plugin.blob()), client)?.display().to_string(),
Source::Path(p) => materialize(plugin, TreeSource::Dir(p), client)?.display().to_string(),
Source::GitHub { repo, ref_ } => github_source(repo, ref_),
};
match marketplace_op(source, present, &source_str) {
MarketplaceOp::Update => marketplace_update(cli, plugin.marketplace, scope)?,
MarketplaceOp::Add => marketplace_add(cli, &source_str, scope)?,
}
Ok(())
}
fn github_source(repo: &str, ref_: &str) -> String {
format!("{repo}@{ref_}")
}
#[derive(Debug, PartialEq, Eq)]
enum MarketplaceOp {
Add,
Update,
}
fn marketplace_op(source: &Source, present: Option<&MarketplaceEntry>, expected: &str) -> MarketplaceOp {
match (source, present) {
(_, None) => MarketplaceOp::Add,
(Source::GitHub { ref_, .. }, Some(entry)) if entry.ref_.as_deref() != Some(*ref_) => MarketplaceOp::Add,
(Source::Embedded | Source::Path(_), Some(entry)) if local_entry_diverges(entry, expected) => MarketplaceOp::Add,
(_, Some(_)) => MarketplaceOp::Update,
}
}
fn local_entry_diverges(entry: &MarketplaceEntry, expected: &str) -> bool {
entry.source.as_deref() == Some("github") || entry.path.as_deref() != Some(expected)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarketplaceHealth {
Healthy,
Absent,
Dangling,
}
pub(crate) fn marketplace_health(marketplace: Option<&MarketplaceEntry>, source: &Source, expected: &Path) -> MarketplaceHealth {
let Some(m) = marketplace else {
return MarketplaceHealth::Absent;
};
match source {
Source::GitHub { .. } => MarketplaceHealth::Healthy,
Source::Embedded | Source::Path(_) => {
if m.source.as_deref() == Some("github") {
return MarketplaceHealth::Dangling;
}
let Some(path) = m.path.as_deref() else {
return MarketplaceHealth::Dangling;
};
if Path::new(path) != expected {
return MarketplaceHealth::Dangling;
}
if !Path::new(path).join(".claude-plugin").join("marketplace.json").exists() {
return MarketplaceHealth::Dangling;
}
MarketplaceHealth::Healthy
}
}
}
fn structural_ok(entry: &PluginEntry, marketplace: Option<&MarketplaceEntry>, source: &Source, expected: &Path) -> bool {
let files_ok = entry.install_path.as_ref().is_none_or(|p| Path::new(p).exists());
let marketplace_ok = matches!(marketplace_health(marketplace, source, expected), MarketplaceHealth::Healthy);
let errors_ok = entry.errors.as_ref().is_none_or(Vec::is_empty);
files_ok && marketplace_ok && errors_ok
}
fn verify_present(cli: &ClaudeCli, scope: &Scope, plugin: &Plugin) -> Result<()> {
match find_plugin(cli, scope, plugin.name, plugin.marketplace)? {
Some(e) if e.install_path.as_ref().is_none_or(|p| Path::new(p).exists()) => Ok(()),
Some(_) => Err(Error::Verify(format!("{} registered but its files are missing after the operation", plugin.id()))),
None => Err(Error::Verify(format!("{} absent from `plugin list --json` after the operation", plugin.id()))),
}
}
pub(crate) fn remove(plugin: &Plugin, scope: &Scope) -> Result<Outcome> {
let cli = ClaudeCli::locate()?;
let id = plugin.id();
if find_plugin(&cli, scope, plugin.name, plugin.marketplace)?.is_some() {
plugin_uninstall(&cli, &id, scope)?;
}
if !marketplace_has_installed(&cli, scope, plugin.marketplace)? && find_marketplace(&cli, scope, plugin.marketplace)?.is_some() {
let _ = marketplace_remove(&cli, plugin.marketplace, scope);
}
Ok(Outcome::Removed)
}
#[cfg(test)]
#[path = "../../tests/unit/claude.rs"]
mod claude_tests;