Skip to main content

agentgear/
host.rs

1//! The public surface a host binary drives: the [`PluginHost`] trait (the derive
2//! implements it) plus the value types its lifecycle methods speak in.
3
4use std::path::{Path, PathBuf};
5
6use crate::doctor::DoctorReport;
7use crate::error::Result;
8
9/// Where a plugin is installed. `Local` is intentionally absent (design §API):
10/// binary-driven install of user-wide tooling has no coherent local-scope story.
11/// `non_exhaustive` so a future variant lands without a semver major.
12#[non_exhaustive]
13#[derive(Debug, Clone)]
14pub enum Scope {
15    /// User-wide install (CC's `--scope user`).
16    User,
17    /// Install into one project's settings; the CLI keys off its working directory,
18    /// so calls run with cwd set to `path`.
19    Project {
20        /// The project directory.
21        path: PathBuf,
22    },
23}
24
25impl Scope {
26    /// The `--scope` value CC expects.
27    pub(crate) fn as_cli(&self) -> &'static str {
28        match self {
29            Scope::User => "user",
30            Scope::Project { .. } => "project",
31        }
32    }
33
34    /// A project scope targets CC's project settings for a given directory; the
35    /// CLI resolves that from its working directory, so calls run with cwd here.
36    pub(crate) fn cwd(&self) -> Option<&Path> {
37        match self {
38            Scope::User => None,
39            Scope::Project { path } => Some(path),
40        }
41    }
42
43    /// Stable key fragment for the stamp marker hash. A project path is
44    /// canonicalized first so the same project reached via a symlink and via its
45    /// realpath key the same marker instead of double-installing; falls back to
46    /// the raw path when canonicalize fails (e.g. the project dir doesn't exist
47    /// yet).
48    pub(crate) fn key(&self) -> String {
49        match self {
50            Scope::User => "user".to_string(),
51            Scope::Project { path } => {
52                let resolved = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone());
53                format!("project:{}", resolved.display())
54            }
55        }
56    }
57}
58
59/// Runtime origin of the plugin tree, defaulted from the derive attrs but
60/// overridable per call. One binary can ship an embedded tree yet still let users
61/// track a GitHub ref so `claude plugin update` pulls new plugin versions without
62/// waiting on a binary release.
63///
64/// Not `Copy`: [`Source::Path`] carries a `PathBuf`.
65#[non_exhaustive]
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub enum Source {
68    /// The compile-time blob baked into the binary (needs the `embed` feature +
69    /// derive attr). Decompressed and materialized locally.
70    Embedded,
71    /// A GitHub-hosted marketplace the `claude` CLI fetches directly, letting users
72    /// track a ref for plugin updates without a new binary release. Non-plugin-native
73    /// backends have no local tree here, so they skip (design §API).
74    GitHub {
75        /// `"owner/repo"`.
76        repo: &'static str,
77        /// The tracked git ref (the derive uses `v<version>`).
78        ref_: &'static str,
79    },
80    /// An on-disk plugin tree (a dir holding `.claude-plugin/plugin.json`),
81    /// materialized like [`Source::Embedded`] but read from `path` at runtime. Lets
82    /// a `default-features = false` host with no baked blob still install.
83    Path(PathBuf),
84}
85
86/// What a reconcile should converge to. Held separately from [`Scope`] because a
87/// backend converges the same desired state across scopes.
88#[derive(Debug, Clone)]
89pub struct Desired {
90    /// Where the plugin tree comes from for this reconcile.
91    pub source: Source,
92    /// `true` for an explicit `install`/`update` (the design says install flips
93    /// enable state), `false` for self_heal/adopt (never re-enable a deliberate
94    /// disable).
95    pub reenable: bool,
96}
97
98/// What a lifecycle op actually did. `non_exhaustive`: adding a case is additive.
99#[non_exhaustive]
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum Outcome {
102    /// Already converged; nothing changed.
103    NoOp,
104    /// A fresh install landed.
105    Installed,
106    /// The install moved to a new version.
107    Updated {
108        /// The prior version, when it could be read.
109        from: Option<String>,
110        /// The version now installed.
111        to: String,
112    },
113    /// A broken/partial state was reconciled back to healthy.
114    Repaired,
115    /// self_heal found a healthy install with no marker and wrote one.
116    Adopted,
117    /// The install was removed.
118    Removed,
119    /// self_heal found a cleanly-uninstalled plugin and cleared the stale marker.
120    Cleared,
121}
122
123/// End-user wording (`installed`, `updated (0.1.0 -> 0.2.0)`), so a host can
124/// print an outcome in a `setup` summary instead of exposing `{:?}`.
125impl std::fmt::Display for Outcome {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        match self {
128            Outcome::NoOp => f.write_str("no changes needed"),
129            Outcome::Installed => f.write_str("installed"),
130            Outcome::Updated { from: Some(from), to } => write!(f, "updated ({from} -> {to})"),
131            Outcome::Updated { from: None, to } => write!(f, "updated (to {to})"),
132            Outcome::Repaired => f.write_str("repaired"),
133            Outcome::Adopted => f.write_str("adopted existing install"),
134            Outcome::Removed => f.write_str("removed"),
135            Outcome::Cleared => f.write_str("cleared stale marker"),
136        }
137    }
138}
139
140/// Per-agent results of one lifecycle fan-out, one entry per configured agent in
141/// `plugin.agents` order (agents excluded by an explicit `install_into` filter get
142/// no entry — they were never asked for). The merged-[`Outcome`] lifecycle methods
143/// collapse this to first-change-wins; a host that wants to tell its user which
144/// agents were installed, skipped, or failed reads the `*_report` variants and
145/// prints this (its `Display` is a ready `setup` summary, one line per agent).
146#[derive(Debug, Clone, PartialEq, Eq)]
147#[non_exhaustive]
148pub struct AgentReport {
149    /// One entry per configured agent that was asked to run, in `plugin.agents` order.
150    pub results: Vec<AgentResult>,
151}
152
153/// One agent's slice of a lifecycle fan-out.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct AgentResult {
156    /// The backend id (`"claude"`, `"codex"`, …).
157    pub agent: &'static str,
158    /// What that backend did.
159    pub status: AgentStatus,
160}
161
162/// What one agent's slice of the fan-out did.
163#[derive(Debug, Clone, PartialEq, Eq)]
164#[non_exhaustive]
165pub enum AgentStatus {
166    /// The backend ran; this is its own outcome (not the merged one).
167    Converged(Outcome),
168    /// The backend was skipped before it could write anything.
169    Skipped(SkipReason),
170    /// The backend failed, rendered for the user. The fan-out continued past it,
171    /// so sibling entries still reflect real per-agent results.
172    Failed(String),
173}
174
175/// Why an agent was skipped rather than converged.
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177#[non_exhaustive]
178pub enum SkipReason {
179    /// `detect()` returned false — the tool is not on this machine.
180    NotDetected,
181    /// The backend has no config surface at the requested scope.
182    ScopeUnsupported,
183    /// The resolved source cannot serve this backend (a GitHub source needs a
184    /// plugin-native backend; config-merge backends have no local tree to render).
185    SourceUnsupported,
186}
187
188impl AgentReport {
189    pub(crate) fn new() -> Self {
190        Self { results: Vec::new() }
191    }
192
193    pub(crate) fn push(&mut self, agent: &'static str, status: AgentStatus) {
194        self.results.push(AgentResult { agent, status });
195    }
196
197    /// The first real change across the agents (a change outranks a no-op);
198    /// [`Outcome::NoOp`] when nothing changed. This is exactly what the merged
199    /// lifecycle methods ([`PluginHost::install`], …) return on success.
200    pub fn merged(&self) -> Outcome {
201        self.results
202            .iter()
203            .find_map(|result| match &result.status {
204                AgentStatus::Converged(outcome) if *outcome != Outcome::NoOp => Some(outcome.clone()),
205                _ => None,
206            })
207            .unwrap_or(Outcome::NoOp)
208    }
209
210    /// True when no agent failed (skips are not failures).
211    pub fn is_healthy(&self) -> bool {
212        !self.results.iter().any(|result| matches!(result.status, AgentStatus::Failed(_)))
213    }
214
215    /// The legacy single-`Outcome` collapse: every agent already ran, so this is
216    /// fail-at-end — the first failed agent decides the `Err` (as
217    /// [`Error::Backend`](crate::Error::Backend)), else the merged outcome.
218    pub(crate) fn into_merged(self) -> Result<Outcome> {
219        for result in &self.results {
220            if let AgentStatus::Failed(detail) = &result.status {
221                return Err(crate::error::Error::Backend { agent: result.agent.into(), detail: detail.clone() });
222            }
223        }
224        Ok(self.merged())
225    }
226
227    /// The `Converged` outcome of one agent, if it ran.
228    pub(crate) fn outcome_of(&self, agent: &str) -> Option<&Outcome> {
229        self.results.iter().find_map(|result| match &result.status {
230            AgentStatus::Converged(outcome) if result.agent == agent => Some(outcome),
231            _ => None,
232        })
233    }
234}
235
236impl std::fmt::Display for AgentReport {
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        for result in &self.results {
239            writeln!(f, "{}: {}", result.agent, result.status)?;
240        }
241        Ok(())
242    }
243}
244
245impl std::fmt::Display for AgentStatus {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        match self {
248            AgentStatus::Converged(outcome) => outcome.fmt(f),
249            AgentStatus::Skipped(reason) => write!(f, "skipped ({reason})"),
250            AgentStatus::Failed(detail) => write!(f, "failed: {detail}"),
251        }
252    }
253}
254
255impl std::fmt::Display for SkipReason {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        match self {
258            SkipReason::NotDetected => f.write_str("not installed on this machine"),
259            SkipReason::ScopeUnsupported => f.write_str("no config surface at this scope"),
260            SkipReason::SourceUnsupported => f.write_str("cannot serve a github source; use an embedded or path source"),
261        }
262    }
263}
264
265/// What an agent backend can host. Each surface flag is `true` iff the backend's
266/// `reconcile` actually writes/manages that surface for a plugin declaring it
267/// (a conditionally-gated surface — e.g. one a native registry may already cover —
268/// still counts, since the backend *can* translate it). Lets `setup` report
269/// "codex: mcp only" instead of silently dropping features, and is the runtime
270/// truth behind the README's supported-agents matrix.
271#[derive(Debug, Clone)]
272pub struct Capabilities {
273    /// Native plugin install (copies the whole CC tree); implies every surface
274    /// except `instructions`, which is a non-CC context-file surface (a CC host
275    /// delivers its guidance through the MCP `instructions` channel, not a file).
276    pub plugins: bool,
277    /// Manages MCP servers.
278    pub mcp: bool,
279    /// Manages hook bindings.
280    pub hooks: bool,
281    /// Manages slash commands.
282    pub commands: bool,
283    /// Manages subagent definitions.
284    pub agents: bool,
285    /// Manages skill directories.
286    pub skills: bool,
287    /// Host-authored always-loaded guidance written to the harness's native
288    /// context channel (a dedicated instructions file + any registration).
289    pub instructions: bool,
290    /// The scope ids this backend supports (e.g. `["user", "project"]`).
291    pub scopes: &'static [&'static str],
292}
293
294/// A resolved plugin descriptor. Built by [`PluginHost::descriptor`] from the
295/// derive-emitted metadata; passed to backends.
296#[derive(Clone)]
297pub struct Plugin {
298    /// Plugin name (`plugin.json`'s `name`).
299    pub name: &'static str,
300    /// Marketplace id in `<name>@<marketplace>`.
301    pub marketplace: &'static str,
302    /// The plugin version.
303    pub version: &'static str,
304    /// The configured backend ids this plugin fans out to.
305    pub agents: &'static [&'static str],
306    /// Host-authored always-loaded guidance ([`PluginHost::instructions`]); each
307    /// non-CC backend writes it to its native context channel. `None` writes nothing.
308    pub instructions: Option<String>,
309    /// The plugin tree baked in as a compressed `.tar.br` (empty when the derive's
310    /// `embed` attr is off). Decompressed by `materialize` for [`Source::Embedded`].
311    pub(crate) blob: &'static [u8],
312}
313
314impl std::fmt::Debug for Plugin {
315    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
316        f.debug_struct("Plugin")
317            .field("name", &self.name)
318            .field("marketplace", &self.marketplace)
319            .field("version", &self.version)
320            .field("agents", &self.agents)
321            .field("instructions", &self.instructions)
322            .finish_non_exhaustive()
323    }
324}
325
326impl Plugin {
327    /// `<name>@<marketplace>`, the id every `claude plugin` call uses.
328    pub fn id(&self) -> String {
329        format!("{}@{}", self.name, self.marketplace)
330    }
331
332    pub(crate) fn blob(&self) -> &'static [u8] {
333        self.blob
334    }
335
336    /// The harness-agnostic components IR for this plugin's tree. Public so an
337    /// external [`AgentBackend`](crate::AgentBackend) can render from the same parsed
338    /// IR the in-crate backends use instead of re-parsing the tree by hand.
339    /// `Source::GitHub` has no local tree and errors out (non-CC backends cannot
340    /// serve a github-source host).
341    pub fn components(&self, source: &Source) -> Result<crate::components::PluginComponents> {
342        crate::components::PluginComponents::parse(&crate::materialize::entries_for(self, source)?)
343    }
344}
345
346/// Implemented by the `#[derive(PluginHost)]` macro. The consts carry the
347/// compile-time metadata; the provided methods are the lifecycle the host calls.
348pub trait PluginHost {
349    /// Plugin name; must equal `plugin.json`'s `name` (the derive checks it).
350    const NAME: &'static str;
351    /// Marketplace id in `<name>@<marketplace>` (defaults to [`NAME`](Self::NAME)).
352    const MARKETPLACE: &'static str;
353    /// Plugin version; the host's `build.rs` pins it to `plugin.json`'s `version`.
354    const VERSION: &'static str;
355    /// The source a no-argument lifecycle call (`update`/`uninstall`/`self_heal`/`doctor`) uses.
356    const DEFAULT_SOURCE: Source;
357    /// The backend ids this host fans out to (the derive's `agents` list).
358    const AGENTS: &'static [&'static str];
359
360    /// The plugin tree baked into the host crate as a compressed `.tar.br` blob
361    /// (the derive's `include_bytes!`). Empty when the derive's `embed` attr is
362    /// off; [`Source::Embedded`] then errors at materialize.
363    fn embedded_blob() -> &'static [u8];
364
365    /// Host-authored always-loaded guidance merged into each non-CC harness's native
366    /// instructions channel. `None` (the default) writes no instructions surface. A
367    /// deriving host supplies it with `#[plugin(instructions_fn = <path>)]`, since the
368    /// derive owns the sole `impl PluginHost` block and this is the only override seam.
369    fn instructions() -> Option<String> {
370        None
371    }
372
373    /// The resolved [`Plugin`] descriptor built from this host's consts, passed to
374    /// the backends. Rarely overridden.
375    fn descriptor() -> Plugin {
376        Plugin {
377            name: Self::NAME,
378            marketplace: Self::MARKETPLACE,
379            version: Self::VERSION,
380            agents: Self::AGENTS,
381            instructions: Self::instructions(),
382            blob: Self::embedded_blob(),
383        }
384    }
385
386    /// Idempotent: ensure the plugin is installed at the embedded version.
387    /// Collapses [`PluginHost::install_report`] to one merged [`Outcome`]
388    /// (first real change wins); a failed agent surfaces as `Err` after the
389    /// whole fan-out ran.
390    fn install(scope: Scope, source: Source) -> Result<Outcome> {
391        Self::install_report(scope, source)?.into_merged()
392    }
393
394    /// [`PluginHost::install`] with per-agent results: which agents converged
395    /// (and how), which were skipped (and why), which failed. `Err` only on a
396    /// fatal precondition — the shared lock, or no usable data root (`HOME` and
397    /// `XDG_DATA_HOME` both unset, so no agent could stamp a marker); per-agent
398    /// failures live in the report so one bad agent never hides the rest.
399    fn install_report(scope: Scope, source: Source) -> Result<AgentReport> {
400        crate::install::install_report(&Self::descriptor(), scope, source, &[])
401    }
402
403    /// Like [`PluginHost::install`] but only into the `AGENTS` whose id is in
404    /// `agents` (an empty slice = all of `AGENTS`). Lets a host target one backend
405    /// (`setup --agent gemini`) without touching the others.
406    fn install_into(scope: Scope, source: Source, agents: &[&str]) -> Result<Outcome> {
407        Self::install_into_report(scope, source, agents)?.into_merged()
408    }
409
410    /// [`PluginHost::install_into`] with per-agent results; filtered-out agents
411    /// get no entry.
412    fn install_into_report(scope: Scope, source: Source, agents: &[&str]) -> Result<AgentReport> {
413        crate::install::install_report(&Self::descriptor(), scope, source, agents)
414    }
415
416    /// Materialize a new versioned tree, then update the marketplace + plugin.
417    /// Collapses [`PluginHost::update_report`] like [`PluginHost::install`].
418    fn update(scope: Scope) -> Result<Outcome> {
419        Self::update_report(scope)?.into_merged()
420    }
421
422    /// [`PluginHost::update`] with per-agent results. Same `Err` contract as
423    /// [`PluginHost::install_report`]: only the lock or a missing data root.
424    fn update_report(scope: Scope) -> Result<AgentReport> {
425        crate::install::update_report(&Self::descriptor(), scope, Self::DEFAULT_SOURCE)
426    }
427
428    /// Uninstall, then refcount-gated marketplace remove; clears the marker.
429    /// Collapses [`PluginHost::uninstall_report`] like [`PluginHost::install`].
430    fn uninstall(scope: Scope) -> Result<Outcome> {
431        Self::uninstall_report(scope)?.into_merged()
432    }
433
434    /// [`PluginHost::uninstall`] with per-agent results. Same `Err` contract as
435    /// [`PluginHost::install_report`]: only the lock or a missing data root.
436    fn uninstall_report(scope: Scope) -> Result<AgentReport> {
437        crate::install::uninstall_report(&Self::descriptor(), scope, Self::DEFAULT_SOURCE)
438    }
439
440    /// SessionStart entrypoint. Repairs broken installs, never resurrects a
441    /// deliberate uninstall, never downgrades, never re-enables (design §6).
442    /// Collapses [`PluginHost::self_heal_report`] like [`PluginHost::install`].
443    fn self_heal() -> Result<Outcome> {
444        Self::self_heal_report()?.into_merged()
445    }
446
447    /// [`PluginHost::self_heal`] with per-agent results. Same `Err` contract as
448    /// [`PluginHost::install_report`]: only the lock or a missing data root.
449    fn self_heal_report() -> Result<AgentReport> {
450        crate::selfheal::self_heal_report(&Self::descriptor(), Self::DEFAULT_SOURCE)
451    }
452
453    /// `Some(message)` when an update landed that the running CC session has not
454    /// loaded yet (CC reads plugin contents at session start, no mid-session
455    /// hot-reload). A `UserPromptSubmit` hook in the plugin tree calls a host
456    /// `check-restart` subcommand that prints this; the model then asks the user to
457    /// restart Claude Code. Disk errors collapse to `None`: a per-prompt hook must
458    /// stay benign, and a real disk failure surfaces through the mutate paths.
459    fn restart_pending() -> Option<String> {
460        let plugin = Self::descriptor();
461        crate::restart::pending(&plugin).ok().flatten().map(|()| crate::restart::message(Self::NAME, Self::VERSION))
462    }
463
464    /// A structured health report: the host binary on `PATH`, then each configured
465    /// agent's own checks. Reads state, never mutates.
466    fn doctor() -> Result<DoctorReport> {
467        crate::doctor::doctor(&Self::descriptor(), &Self::DEFAULT_SOURCE)
468    }
469}
470
471/// Per-crate data root: `${XDG_DATA_HOME:-~/.local/share}/<plugin-name>/`, holding
472/// `versions/`, the `current` pointer, and `markers/`.
473pub(crate) fn data_root(plugin: &Plugin) -> Result<PathBuf> {
474    data_root_for(plugin.name)
475}
476
477/// [`data_root`] for a host that only holds the plugin name.
478pub(crate) fn data_root_for(plugin_name: &str) -> Result<PathBuf> {
479    let base = dirs::data_dir().ok_or_else(|| crate::error::Error::Tree("no data directory (XDG_DATA_HOME and HOME both unset)".into()))?;
480    Ok(base.join(plugin_name))
481}
482
483/// The `current@<client>` pointer path [`crate::materialize`] publishes for
484/// `plugin_name`, resolved like [`data_root`] but without materializing anything.
485/// A host reads its registered marketplace source and compares it against this to
486/// spot a divergent registration with a plain filesystem read, never a CLI spawn.
487pub fn current_pointer(plugin_name: &str, client: &str) -> Result<PathBuf> {
488    Ok(data_root_for(plugin_name)?.join(format!("current@{client}")))
489}
490
491#[cfg(test)]
492#[path = "../tests/unit/host.rs"]
493mod host_tests;