use std::path::{Path, PathBuf};
use super::statuslinejson::{self, SlotShape};
use super::{AgentBackend, BackendState};
use crate::cli::{ClaudeCli, version_lt};
use crate::doctor::{DoctorCheck, DoctorReport};
use crate::error::{Error, Result};
use crate::host::{Capabilities, Desired, Outcome, Plugin, Scope, Source};
use crate::manifest::{MarketplaceEntry, PluginEntry};
use crate::materialize::{TreeSource, materialize};
const STATUSLINE_SLOT: &[&str] = &["statusLine"];
const STATUSLINE_SHAPE: SlotShape = SlotShape::typed_command();
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,
statusline: true,
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 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 registry = if files_ok && monotonic_current { BackendState::Healthy } else { BackendState::NeedsRepair };
Ok(match statusline_state(plugin, scope)? {
None | Some(BackendState::Healthy) => registry,
Some(_) => BackendState::NeedsRepair,
})
}
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 forget(&self, plugin: &Plugin, scope: &Scope) -> Result<()> {
statusline_remove(plugin, scope).map(|_| ())
}
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)))
}
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> {
ensure_statusline_resolves(plugin, scope)?;
match reconcile_registry(plugin, desired, scope)? {
RegistryOutcome::Frozen => Ok(Outcome::NoOp),
RegistryOutcome::Converged(outcome) => {
let changed = statusline_reconcile(plugin, desired, scope)?;
Ok(match (outcome, changed) {
(Outcome::NoOp, true) => Outcome::Repaired,
(outcome, _) => outcome,
})
}
}
}
enum RegistryOutcome {
Converged(Outcome),
Frozen,
}
fn reconcile_registry(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<RegistryOutcome> {
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 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)?;
return Ok(RegistryOutcome::Converged(Outcome::Installed));
};
if entry.enabled == Some(false) {
if !desired.reenable {
return Ok(RegistryOutcome::Frozen);
}
cli.ensure_min_version()?;
plugin_enable(&cli, &id, scope)?;
return Ok(RegistryOutcome::Converged(Outcome::Repaired));
}
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 structural_ok = structural_ok(&entry, marketplace.as_ref(), &desired.source);
if newer {
return Ok(RegistryOutcome::Frozen);
}
if structural_ok && !stale {
return Ok(RegistryOutcome::Converged(Outcome::NoOp));
}
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)?;
Ok(RegistryOutcome::Converged(Outcome::Updated { from: installed, to: plugin.version.to_string() }))
} else {
let _ = plugin_uninstall(&cli, &id, scope);
plugin_install(&cli, &id, scope)?;
verify_present(&cli, scope, plugin)?;
Ok(RegistryOutcome::Converged(Outcome::Repaired))
}
}
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) {
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>) -> MarketplaceOp {
match (source, present) {
(_, None) => MarketplaceOp::Add,
(Source::GitHub { ref_, .. }, Some(entry)) if entry.ref_.as_deref() != Some(*ref_) => MarketplaceOp::Add,
(_, Some(_)) => MarketplaceOp::Update,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarketplaceHealth {
Healthy,
Absent,
Dangling,
}
pub(crate) fn marketplace_health(marketplace: Option<&MarketplaceEntry>, source: &Source) -> MarketplaceHealth {
let Some(m) = marketplace else {
return MarketplaceHealth::Absent;
};
match source {
Source::Embedded | Source::Path(_) => {
if m.path.as_ref().is_none_or(|p| Path::new(p).exists()) {
MarketplaceHealth::Healthy
} else {
MarketplaceHealth::Dangling
}
}
Source::GitHub { .. } => MarketplaceHealth::Healthy,
}
}
fn structural_ok(entry: &PluginEntry, marketplace: Option<&MarketplaceEntry>, source: &Source) -> 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), MarketplaceHealth::Healthy);
files_ok && marketplace_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> {
ensure_statusline_resolves(plugin, scope)?;
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);
}
statusline_remove(plugin, scope)?;
Ok(Outcome::Removed)
}
fn settings_file(scope: &Scope) -> Result<PathBuf> {
match scope {
Scope::User => Ok(cc_config_dir()?.join("settings.json")),
Scope::Project { path } => Ok(path.join(".claude").join("settings.json")),
}
}
fn cc_config_dir() -> Result<PathBuf> {
if let Some(dir) = super::config_dir_override("CLAUDE_CONFIG_DIR")? {
return Ok(dir);
}
dirs::home_dir()
.map(|home| home.join(".claude"))
.ok_or_else(|| Error::Tree("no home directory (HOME unset); cannot locate ~/.claude".into()))
}
fn statusline_target(plugin: &Plugin, scope: &Scope) -> Result<Option<PathBuf>> {
statuslinejson::target(plugin, ClaudeBackend.id(), STATUSLINE_SHAPE, || settings_file(scope))
}
fn ensure_statusline_resolves(plugin: &Plugin, scope: &Scope) -> Result<()> {
statusline_target(plugin, scope)?;
Ok(())
}
fn statusline_reconcile(plugin: &Plugin, desired: &Desired, scope: &Scope) -> Result<bool> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(false);
};
statuslinejson::reconcile(&path, STATUSLINE_SLOT, plugin, &desired.source, scope, ClaudeBackend.id(), STATUSLINE_SHAPE)
}
fn statusline_remove(plugin: &Plugin, scope: &Scope) -> Result<bool> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(false);
};
statuslinejson::remove(&path, STATUSLINE_SLOT, plugin, scope, ClaudeBackend.id(), STATUSLINE_SHAPE)
}
fn statusline_state(plugin: &Plugin, scope: &Scope) -> Result<Option<BackendState>> {
let Some(path) = statusline_target(plugin, scope)? else {
return Ok(None);
};
statuslinejson::state(&path, STATUSLINE_SLOT, plugin, scope, ClaudeBackend.id(), STATUSLINE_SHAPE)
}
pub(crate) fn statusline_check(plugin: &Plugin) -> Option<DoctorCheck> {
statuslinejson::check(STATUSLINE_SLOT, plugin, &Scope::User, ClaudeBackend.id(), STATUSLINE_SHAPE, "Claude Code", settings_file)
}
#[cfg(test)]
#[path = "../../tests/unit/claude.rs"]
mod claude_tests;