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;