Skip to main content

agent_config/
integration.rs

1//! The contract every AI-harness integration implements.
2//!
3//! There are several distinct **surfaces** an integration may expose:
4//!
5//! - [`Integration`]: the hooks surface.
6//! - [`McpSurface`]: MCP server registration.
7//! - [`SkillSurface`]: skill installation.
8//! - [`InstructionSurface`]: standalone instruction files.
9//!
10//! Each surface is its own trait so callers cannot accidentally call
11//! `install_mcp` on a harness that does not support MCP, the type system
12//! forbids it. Use [`crate::registry::mcp_capable`] to enumerate the agents
13//! that implement `McpSurface`.
14
15use std::path::PathBuf;
16
17use crate::error::AgentConfigError;
18use crate::plan::{InstallPlan, PlanTarget, UninstallPlan};
19use crate::scope::{Scope, ScopeKind};
20use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
21use crate::status::{InstallStatus, StatusReport};
22use crate::validation::ValidationReport;
23
24/// One AI harness's hook installer.
25///
26/// The trait is intentionally narrow so adding a new harness is a small,
27/// mechanical exercise: implement `Integration`, then register it in
28/// [`crate::registry::all`].
29///
30/// All operations must be idempotent: calling `install` twice with the same
31/// spec, or `uninstall` twice with the same tag, must produce the same end
32/// state as calling once.
33pub trait Integration: Send + Sync {
34    /// Stable, kebab-case identifier (e.g., `"claude"`, `"cursor"`).
35    fn id(&self) -> &'static str;
36
37    /// Human-readable name (e.g., `"Claude Code"`).
38    fn display_name(&self) -> &'static str;
39
40    /// Which scopes this integration accepts.
41    fn supported_scopes(&self) -> &'static [ScopeKind];
42
43    /// Returns true if a hook with this `tag` is currently installed in this
44    /// scope. Used by CLI consumers to render install/uninstall state.
45    ///
46    /// Default impl matches on the richer [`status`](Integration::status)
47    /// result and treats [`InstallStatus::InstalledOwned`] and
48    /// [`InstallStatus::InstalledOtherOwner`] as installed; agents that have
49    /// already implemented `status` get this for free.
50    ///
51    /// # Errors
52    ///
53    /// Propagates whatever [`status`](Integration::status) returns: typically
54    /// [`AgentConfigError::PathResolution`], [`AgentConfigError::Io`],
55    /// [`AgentConfigError::JsonInvalid`], or
56    /// [`AgentConfigError::ConfigTooLarge`].
57    fn is_installed(&self, scope: &Scope, tag: &str) -> Result<bool, AgentConfigError> {
58        Ok(matches!(
59            self.status(scope, tag)?.status,
60            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
61        ))
62    }
63
64    /// Detailed installation state for the hook identified by `tag`.
65    ///
66    /// Distinguishes installed-by-us from installed-by-someone-else, surfaces
67    /// drift (parse failures, duplicate entries), and reports any pending
68    /// `.bak` files. See [`StatusReport`] for the full shape.
69    ///
70    /// # Errors
71    ///
72    /// - [`AgentConfigError::PathResolution`] when the harness config path
73    ///   cannot be resolved (e.g. `$HOME` missing).
74    /// - [`AgentConfigError::Io`] for unreadable files other than the
75    ///   "missing" case (which becomes [`InstallStatus::Absent`]).
76    /// - [`AgentConfigError::ConfigTooLarge`] when the config file exceeds
77    ///   the 8 MiB read cap.
78    ///
79    /// Parse failures are intentionally folded into a [`StatusReport`] with
80    /// [`crate::status::DriftIssue::InvalidConfig`] rather than surfaced as
81    /// errors.
82    fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError>;
83
84    /// Validate hook state without mutating user files.
85    ///
86    /// Unlike [`status`](Integration::status), this reports whether the
87    /// discovered state is internally consistent and safe to repair.
88    ///
89    /// # Errors
90    ///
91    /// - [`AgentConfigError::InvalidTag`] when `tag` is not a legal hook tag.
92    /// - Any error from [`status`](Integration::status) other than
93    ///   [`AgentConfigError::JsonInvalid`], which is folded into the
94    ///   returned [`ValidationReport`] as malformed-ledger output.
95    fn validate(&self, scope: &Scope, tag: &str) -> Result<ValidationReport, AgentConfigError> {
96        HookSpec::validate_tag(tag)?;
97        let target = PlanTarget::Hook {
98            integration_id: self.id(),
99            scope: scope.clone(),
100            tag: tag.to_string(),
101        };
102        let status = match self.status(scope, tag) {
103            Ok(status) => status,
104            Err(AgentConfigError::JsonInvalid { path, source }) => {
105                return Ok(crate::validation::malformed_ledger_report(
106                    target,
107                    path,
108                    source.to_string(),
109                ));
110            }
111            Err(e) => return Err(e),
112        };
113        Ok(crate::validation::hook_report_from_status(target, status))
114    }
115
116    /// Plan a hook install without mutating user files.
117    ///
118    /// # Errors
119    ///
120    /// - [`AgentConfigError::PathResolution`] when the target config path
121    ///   cannot be resolved or escapes the scope root.
122    /// - [`AgentConfigError::UnsupportedScope`] is encoded as
123    ///   [`crate::plan::PlanStatus::Refused`] rather than returned.
124    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
125    ///   when the existing config cannot be read.
126    /// - [`AgentConfigError::InvalidTag`] from spec re-validation.
127    fn plan_install(&self, scope: &Scope, spec: &HookSpec)
128        -> Result<InstallPlan, AgentConfigError>;
129
130    /// Plan a hook uninstall without mutating user files.
131    ///
132    /// # Errors
133    ///
134    /// Same envelope as [`plan_install`](Integration::plan_install).
135    /// Predictable refusals (unsupported scope, owner mismatch) are encoded
136    /// as [`crate::plan::PlanStatus::Refused`].
137    fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError>;
138
139    /// Install the hook. Repeated calls with the same `spec.tag` are a no-op
140    /// after the first (the on-disk state is reached, then preserved).
141    ///
142    /// # Errors
143    ///
144    /// - [`AgentConfigError::PathResolution`] when the target path escapes
145    ///   the scope root or contains a symlink component.
146    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
147    ///   [`supported_scopes`](Integration::supported_scopes).
148    /// - [`AgentConfigError::Io`] for filesystem failures, including
149    ///   permission denials and atomic-rename collisions.
150    /// - [`AgentConfigError::JsonInvalid`] / [`AgentConfigError::TomlInvalid`]
151    ///   when the existing config is unparseable and cannot be merged.
152    /// - [`AgentConfigError::ConfigTooLarge`] when the existing config
153    ///   exceeds the 8 MiB read cap.
154    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
155    ///   exists for a file we would otherwise back up.
156    /// - [`AgentConfigError::MissingSpecField`] /
157    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
158    fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError>;
159
160    /// Uninstall the hook identified by `tag`. Restores `.bak` files when
161    /// removing our content leaves the target file empty or pristine.
162    ///
163    /// # Errors
164    ///
165    /// Same envelope as [`install`](Integration::install). Hooks have no
166    /// separate ownership ledger (the tag is the owner), so there is no
167    /// [`AgentConfigError::NotOwnedByCaller`] arm here.
168    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError>;
169
170    /// Migrate any prior layout produced by an earlier version of the consumer
171    /// (e.g., remove a legacy shell-script wrapper that has since been
172    /// superseded by a native binary). Default impl is a no-op.
173    ///
174    /// # Errors
175    ///
176    /// Implementation-defined; the default returns [`MigrationReport::NoOp`]
177    /// unconditionally. Concrete impls typically return
178    /// [`AgentConfigError::Io`] or [`AgentConfigError::PathResolution`].
179    fn migrate(&self, _scope: &Scope, _tag: &str) -> Result<MigrationReport, AgentConfigError> {
180        Ok(MigrationReport::NoOp)
181    }
182}
183
184/// One AI harness's MCP-server installer.
185///
186/// Implemented by harnesses that load MCP server configs from a known file.
187/// Harnesses without a confirmed file-backed MCP contract do not implement
188/// this trait, so callers discover that at compile time or through
189/// [`crate::registry::mcp_capable`].
190///
191/// All operations must be idempotent: installing the same [`McpSpec`] twice
192/// reaches and preserves a single on-disk state. Uninstalls refuse to remove
193/// entries owned by another consumer (recorded in a sidecar ledger).
194pub trait McpSurface: Send + Sync {
195    /// Stable, kebab-case identifier matching [`Integration::id`] for the same
196    /// agent (e.g., `"claude"`).
197    fn id(&self) -> &'static str;
198
199    /// Which scopes this MCP installer accepts.
200    fn supported_mcp_scopes(&self) -> &'static [ScopeKind];
201
202    /// Returns true if a server with `name` is currently recorded under any
203    /// owner in this scope's ownership ledger.
204    ///
205    /// Default impl folds the richer
206    /// [`mcp_status`](McpSurface::mcp_status) into the historical boolean
207    /// ("under any owner"); concretely, both
208    /// [`InstallStatus::InstalledOwned`] and
209    /// [`InstallStatus::InstalledOtherOwner`] count as installed.
210    ///
211    /// # Errors
212    ///
213    /// Propagates whatever [`mcp_status`](McpSurface::mcp_status) returns.
214    fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
215        // Pass the agent's id as the expected owner so any real consumer
216        // owner (e.g. "myapp") routes through `InstalledOtherOwner`. The
217        // boolean fold collapses both arms anyway.
218        Ok(matches!(
219            self.mcp_status(scope, name, self.id())?.status,
220            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
221        ))
222    }
223
224    /// Detailed installation state for the MCP server identified by `name`,
225    /// scored against `expected_owner`.
226    ///
227    /// `expected_owner` is the consumer tag the caller wants to compare
228    /// against — when the ledger records this owner, the report returns
229    /// [`InstallStatus::InstalledOwned`]; anything else recorded becomes
230    /// [`InstallStatus::InstalledOtherOwner`].
231    ///
232    /// # Errors
233    ///
234    /// Same envelope as [`Integration::status`]:
235    /// [`AgentConfigError::PathResolution`], [`AgentConfigError::Io`], and
236    /// [`AgentConfigError::ConfigTooLarge`]. Parse failures fold into
237    /// [`crate::status::DriftIssue::InvalidConfig`] inside the report.
238    fn mcp_status(
239        &self,
240        scope: &Scope,
241        name: &str,
242        expected_owner: &str,
243    ) -> Result<StatusReport, AgentConfigError>;
244
245    /// Validate MCP state without mutating user files.
246    ///
247    /// # Errors
248    ///
249    /// Equivalent to
250    /// [`validate_mcp_for_owner`](McpSurface::validate_mcp_for_owner) with
251    /// `expected_owner = None`.
252    fn validate_mcp(
253        &self,
254        scope: &Scope,
255        name: &str,
256    ) -> Result<ValidationReport, AgentConfigError> {
257        self.validate_mcp_for_owner(scope, name, None)
258    }
259
260    /// Validate MCP state against a caller-supplied expected owner.
261    ///
262    /// # Errors
263    ///
264    /// - [`AgentConfigError::InvalidTag`] when `name` or `expected_owner`
265    ///   fails identifier validation.
266    /// - Any error from [`mcp_status`](McpSurface::mcp_status) other than
267    ///   [`AgentConfigError::JsonInvalid`], which is folded into the
268    ///   returned [`ValidationReport`] as malformed-ledger output.
269    fn validate_mcp_for_owner(
270        &self,
271        scope: &Scope,
272        name: &str,
273        expected_owner: Option<&str>,
274    ) -> Result<ValidationReport, AgentConfigError> {
275        McpSpec::validate_name(name)?;
276        if let Some(owner) = expected_owner {
277            HookSpec::validate_tag(owner)?;
278        }
279        let status = match self.mcp_status(scope, name, expected_owner.unwrap_or("")) {
280            Ok(status) => status,
281            Err(AgentConfigError::JsonInvalid { path, source }) => {
282                let target = PlanTarget::Mcp {
283                    integration_id: self.id(),
284                    scope: scope.clone(),
285                    name: name.to_string(),
286                    owner: expected_owner.unwrap_or_default().to_string(),
287                };
288                return Ok(crate::validation::malformed_ledger_report(
289                    target,
290                    path,
291                    source.to_string(),
292                ));
293            }
294            Err(e) => return Err(e),
295        };
296        let target = PlanTarget::Mcp {
297            integration_id: self.id(),
298            scope: scope.clone(),
299            name: name.to_string(),
300            owner: expected_owner
301                .map(str::to_owned)
302                .or_else(|| owner_from_status(&status))
303                .unwrap_or_default(),
304        };
305        crate::validation::ledger_backed_report_from_status(target, name, expected_owner, status)
306    }
307
308    /// Plan an MCP server install without mutating user files.
309    ///
310    /// # Errors
311    ///
312    /// - [`AgentConfigError::PathResolution`] when the MCP config path
313    ///   cannot be resolved.
314    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
315    ///   when the existing config or ledger cannot be read.
316    /// - [`AgentConfigError::MissingSpecField`] /
317    ///   [`AgentConfigError::InvalidTag`] from [`McpSpec`] re-validation.
318    /// - [`AgentConfigError::InlineSecretInLocalScope`] when `spec` carries an
319    ///   inline secret (`SecretPolicy::Inline`) under `Scope::Local`.
320    ///
321    /// Predictable refusals (unsupported scope, owner mismatch, parse
322    /// failure) are encoded as [`crate::plan::PlanStatus::Refused`].
323    fn plan_install_mcp(
324        &self,
325        scope: &Scope,
326        spec: &McpSpec,
327    ) -> Result<InstallPlan, AgentConfigError>;
328
329    /// Plan an MCP server uninstall without mutating user files.
330    ///
331    /// # Errors
332    ///
333    /// Same envelope as [`plan_install_mcp`](McpSurface::plan_install_mcp).
334    fn plan_uninstall_mcp(
335        &self,
336        scope: &Scope,
337        name: &str,
338        owner_tag: &str,
339    ) -> Result<UninstallPlan, AgentConfigError>;
340
341    /// Install (or update) the MCP server. Repeated calls with the same
342    /// `spec.name` and same content are a no-op after the first.
343    ///
344    /// # Errors
345    ///
346    /// - [`AgentConfigError::PathResolution`] when the target path escapes
347    ///   the scope root or contains a symlink component.
348    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
349    ///   [`supported_mcp_scopes`](McpSurface::supported_mcp_scopes).
350    /// - [`AgentConfigError::Io`] for filesystem failures.
351    /// - [`AgentConfigError::JsonInvalid`] / [`AgentConfigError::TomlInvalid`]
352    ///   when the existing config is unparseable and cannot be merged.
353    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
354    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
355    ///   exists for a file that would otherwise be backed up.
356    /// - [`AgentConfigError::NotOwnedByCaller`] when an existing server entry
357    ///   belongs to a different consumer (or is unowned and `adopt_unowned`
358    ///   is false).
359    /// - [`AgentConfigError::InlineSecretInLocalScope`] under `Scope::Local`
360    ///   when the spec carries an inline secret.
361    /// - [`AgentConfigError::MissingSpecField`] /
362    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
363    fn install_mcp(&self, scope: &Scope, spec: &McpSpec)
364        -> Result<InstallReport, AgentConfigError>;
365
366    /// Uninstall the MCP server identified by `name`, owned by `owner_tag`.
367    ///
368    /// Returns [`AgentConfigError::NotOwnedByCaller`] if the entry is recorded
369    /// under a different owner, or if it exists in the harness config but is
370    /// missing from the ledger (i.e., user-installed by hand).
371    ///
372    /// # Errors
373    ///
374    /// Same envelope as [`install_mcp`](McpSurface::install_mcp). The
375    /// owner-mismatch case is the typical one.
376    fn uninstall_mcp(
377        &self,
378        scope: &Scope,
379        name: &str,
380        owner_tag: &str,
381    ) -> Result<UninstallReport, AgentConfigError>;
382}
383
384/// One AI harness's skill installer.
385///
386/// Skills are directory-scoped: each one is a folder under the harness's
387/// `skills/` root containing a `SKILL.md` plus optional `scripts/`,
388/// `references/`, and `assets/` subdirectories. Implemented by harnesses with
389/// upstream Agent Skills support.
390///
391/// Like [`McpSurface`], ownership is tracked via a sidecar ledger so multiple
392/// consumers can coexist and uninstall is refused on owner mismatch.
393pub trait SkillSurface: Send + Sync {
394    /// Stable, kebab-case identifier matching [`Integration::id`] for the
395    /// same agent.
396    fn id(&self) -> &'static str;
397
398    /// Which scopes this skill installer accepts.
399    fn supported_skill_scopes(&self) -> &'static [ScopeKind];
400
401    /// Returns true if a skill named `name` is currently recorded in the
402    /// ownership ledger for this scope.
403    ///
404    /// Default impl mirrors [`McpSurface::is_mcp_installed`]: both
405    /// [`InstallStatus::InstalledOwned`] and
406    /// [`InstallStatus::InstalledOtherOwner`] count as installed.
407    ///
408    /// # Errors
409    ///
410    /// Propagates whatever [`skill_status`](SkillSurface::skill_status) returns.
411    fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
412        Ok(matches!(
413            self.skill_status(scope, name, self.id())?.status,
414            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
415        ))
416    }
417
418    /// Detailed installation state for the skill identified by `name`,
419    /// scored against `expected_owner`. See [`McpSurface::mcp_status`] for
420    /// the owner-comparison semantics.
421    ///
422    /// # Errors
423    ///
424    /// Same envelope as [`McpSurface::mcp_status`].
425    fn skill_status(
426        &self,
427        scope: &Scope,
428        name: &str,
429        expected_owner: &str,
430    ) -> Result<StatusReport, AgentConfigError>;
431
432    /// Validate skill state without mutating user files.
433    ///
434    /// # Errors
435    ///
436    /// Equivalent to
437    /// [`validate_skill_for_owner`](SkillSurface::validate_skill_for_owner)
438    /// with `expected_owner = None`.
439    fn validate_skill(
440        &self,
441        scope: &Scope,
442        name: &str,
443    ) -> Result<ValidationReport, AgentConfigError> {
444        self.validate_skill_for_owner(scope, name, None)
445    }
446
447    /// Validate skill state against a caller-supplied expected owner.
448    ///
449    /// # Errors
450    ///
451    /// - [`AgentConfigError::InvalidTag`] when `name` violates the kebab-case
452    ///   skill-name contract or `expected_owner` is malformed.
453    /// - Any error from [`skill_status`](SkillSurface::skill_status) other
454    ///   than [`AgentConfigError::JsonInvalid`], which is folded into the
455    ///   returned [`ValidationReport`] as malformed-ledger output.
456    fn validate_skill_for_owner(
457        &self,
458        scope: &Scope,
459        name: &str,
460        expected_owner: Option<&str>,
461    ) -> Result<ValidationReport, AgentConfigError> {
462        SkillSpec::validate_name(name)?;
463        if let Some(owner) = expected_owner {
464            HookSpec::validate_tag(owner)?;
465        }
466        let status = match self.skill_status(scope, name, expected_owner.unwrap_or("")) {
467            Ok(status) => status,
468            Err(AgentConfigError::JsonInvalid { path, source }) => {
469                let target = PlanTarget::Skill {
470                    integration_id: self.id(),
471                    scope: scope.clone(),
472                    name: name.to_string(),
473                    owner: expected_owner.unwrap_or_default().to_string(),
474                };
475                return Ok(crate::validation::malformed_ledger_report(
476                    target,
477                    path,
478                    source.to_string(),
479                ));
480            }
481            Err(e) => return Err(e),
482        };
483        let target = PlanTarget::Skill {
484            integration_id: self.id(),
485            scope: scope.clone(),
486            name: name.to_string(),
487            owner: expected_owner
488                .map(str::to_owned)
489                .or_else(|| owner_from_status(&status))
490                .unwrap_or_default(),
491        };
492        crate::validation::skill_report_from_status(target, name, expected_owner, status)
493    }
494
495    /// Plan a skill install without mutating user files.
496    ///
497    /// # Errors
498    ///
499    /// - [`AgentConfigError::PathResolution`] when the skill directory cannot
500    ///   be resolved.
501    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
502    ///   when reading existing skill assets or the ledger fails.
503    /// - [`AgentConfigError::MissingSpecField`] /
504    ///   [`AgentConfigError::InvalidTag`] from [`SkillSpec`] re-validation.
505    ///
506    /// Predictable refusals (unsupported scope, owner mismatch) are encoded
507    /// as [`crate::plan::PlanStatus::Refused`].
508    fn plan_install_skill(
509        &self,
510        scope: &Scope,
511        spec: &SkillSpec,
512    ) -> Result<InstallPlan, AgentConfigError>;
513
514    /// Plan a skill uninstall without mutating user files.
515    ///
516    /// # Errors
517    ///
518    /// Same envelope as
519    /// [`plan_install_skill`](SkillSurface::plan_install_skill).
520    fn plan_uninstall_skill(
521        &self,
522        scope: &Scope,
523        name: &str,
524        owner_tag: &str,
525    ) -> Result<UninstallPlan, AgentConfigError>;
526
527    /// Install (or update) the skill directory and record ownership.
528    /// Repeated calls with byte-identical contents are a no-op after the
529    /// first.
530    ///
531    /// # Errors
532    ///
533    /// - [`AgentConfigError::PathResolution`] when a target path escapes the
534    ///   scope root or contains a symlink component.
535    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
536    ///   [`supported_skill_scopes`](SkillSurface::supported_skill_scopes).
537    /// - [`AgentConfigError::Io`] for filesystem failures.
538    /// - [`AgentConfigError::JsonInvalid`] when the ownership ledger is
539    ///   malformed.
540    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
541    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
542    ///   exists for a file we would back up.
543    /// - [`AgentConfigError::NotOwnedByCaller`] when the skill exists on
544    ///   disk under another owner (or unowned and `adopt_unowned` is false).
545    /// - [`AgentConfigError::MissingSpecField`] /
546    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
547    fn install_skill(
548        &self,
549        scope: &Scope,
550        spec: &SkillSpec,
551    ) -> Result<InstallReport, AgentConfigError>;
552
553    /// Uninstall the skill identified by `name`, owned by `owner_tag`.
554    /// Returns [`AgentConfigError::NotOwnedByCaller`] on owner mismatch or when
555    /// the skill exists on disk but is missing from the ledger.
556    ///
557    /// # Errors
558    ///
559    /// Same envelope as [`install_skill`](SkillSurface::install_skill). The
560    /// owner-mismatch case is the typical one.
561    fn uninstall_skill(
562        &self,
563        scope: &Scope,
564        name: &str,
565        owner_tag: &str,
566    ) -> Result<UninstallReport, AgentConfigError>;
567}
568
569/// One AI harness's standalone-instruction installer.
570///
571/// Instructions are named markdown files that provide persistent context to
572/// the agent. Unlike hook rules (which are tied to a single hook spec),
573/// instructions are independent documents that are loaded by the agent on
574/// every session start.
575///
576/// Implemented by harnesses that support standalone instruction files or
577/// managed include references in their memory/rules files. Use
578/// [`crate::registry::instruction_capable`] to enumerate agents that
579/// implement this trait.
580///
581/// All operations must be idempotent: installing the same [`InstructionSpec`]
582/// twice reaches and preserves a single on-disk state. Uninstalls refuse to
583/// remove entries owned by another consumer (recorded in a sidecar ledger).
584pub trait InstructionSurface: Send + Sync {
585    /// Stable, kebab-case identifier matching [`Integration::id`] for the
586    /// same agent.
587    fn id(&self) -> &'static str;
588
589    /// Which scopes this instruction installer accepts.
590    fn supported_instruction_scopes(&self) -> &'static [ScopeKind];
591
592    /// Returns true if an instruction named `name` is currently recorded
593    /// under any owner in this scope's ownership ledger.
594    ///
595    /// # Errors
596    ///
597    /// Propagates whatever
598    /// [`instruction_status`](InstructionSurface::instruction_status) returns.
599    fn is_instruction_installed(
600        &self,
601        scope: &Scope,
602        name: &str,
603    ) -> Result<bool, AgentConfigError> {
604        Ok(matches!(
605            self.instruction_status(scope, name, self.id())?.status,
606            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
607        ))
608    }
609
610    /// Detailed installation state for the instruction identified by `name`,
611    /// scored against `expected_owner`.
612    ///
613    /// # Errors
614    ///
615    /// Same envelope as [`McpSurface::mcp_status`].
616    fn instruction_status(
617        &self,
618        scope: &Scope,
619        name: &str,
620        expected_owner: &str,
621    ) -> Result<StatusReport, AgentConfigError>;
622
623    /// Validate instruction state without mutating user files.
624    ///
625    /// # Errors
626    ///
627    /// Equivalent to
628    /// [`validate_instruction_for_owner`](InstructionSurface::validate_instruction_for_owner)
629    /// with `expected_owner = None`.
630    fn validate_instruction(
631        &self,
632        scope: &Scope,
633        name: &str,
634    ) -> Result<ValidationReport, AgentConfigError> {
635        self.validate_instruction_for_owner(scope, name, None)
636    }
637
638    /// Validate instruction state against a caller-supplied expected owner.
639    ///
640    /// # Errors
641    ///
642    /// - [`AgentConfigError::InvalidTag`] when `name` or `expected_owner`
643    ///   fails identifier validation.
644    /// - Any error from
645    ///   [`instruction_status`](InstructionSurface::instruction_status)
646    ///   other than [`AgentConfigError::JsonInvalid`], which is folded into
647    ///   the returned [`ValidationReport`] as malformed-ledger output.
648    fn validate_instruction_for_owner(
649        &self,
650        scope: &Scope,
651        name: &str,
652        expected_owner: Option<&str>,
653    ) -> Result<ValidationReport, AgentConfigError> {
654        InstructionSpec::validate_name(name)?;
655        if let Some(owner) = expected_owner {
656            HookSpec::validate_tag(owner)?;
657        }
658        let status = match self.instruction_status(scope, name, expected_owner.unwrap_or("")) {
659            Ok(status) => status,
660            Err(AgentConfigError::JsonInvalid { path, source }) => {
661                let target = PlanTarget::Instruction {
662                    integration_id: self.id(),
663                    scope: scope.clone(),
664                    name: name.to_string(),
665                    owner: expected_owner.unwrap_or_default().to_string(),
666                };
667                return Ok(crate::validation::malformed_ledger_report(
668                    target,
669                    path,
670                    source.to_string(),
671                ));
672            }
673            Err(e) => return Err(e),
674        };
675        let target = PlanTarget::Instruction {
676            integration_id: self.id(),
677            scope: scope.clone(),
678            name: name.to_string(),
679            owner: expected_owner
680                .map(str::to_owned)
681                .or_else(|| owner_from_status(&status))
682                .unwrap_or_default(),
683        };
684        crate::validation::ledger_backed_report_from_status(target, name, expected_owner, status)
685    }
686
687    /// Plan an instruction install without mutating user files.
688    ///
689    /// # Errors
690    ///
691    /// - [`AgentConfigError::PathResolution`] when the instruction or host
692    ///   memory file cannot be resolved.
693    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
694    ///   when reading the existing instruction, host file, or ledger fails.
695    /// - [`AgentConfigError::MissingSpecField`] /
696    ///   [`AgentConfigError::InvalidTag`] from [`InstructionSpec`]
697    ///   re-validation.
698    ///
699    /// Predictable refusals (unsupported scope, owner mismatch) are encoded
700    /// as [`crate::plan::PlanStatus::Refused`].
701    fn plan_install_instruction(
702        &self,
703        scope: &Scope,
704        spec: &InstructionSpec,
705    ) -> Result<InstallPlan, AgentConfigError>;
706
707    /// Plan an instruction uninstall without mutating user files.
708    ///
709    /// # Errors
710    ///
711    /// Same envelope as
712    /// [`plan_install_instruction`](InstructionSurface::plan_install_instruction).
713    fn plan_uninstall_instruction(
714        &self,
715        scope: &Scope,
716        name: &str,
717        owner_tag: &str,
718    ) -> Result<UninstallPlan, AgentConfigError>;
719
720    /// Install (or update) the instruction. Repeated calls with the same
721    /// name and identical content are a no-op after the first.
722    ///
723    /// # Errors
724    ///
725    /// - [`AgentConfigError::PathResolution`] when the instruction or host
726    ///   path escapes the scope root or contains a symlink component.
727    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
728    ///   [`supported_instruction_scopes`](InstructionSurface::supported_instruction_scopes).
729    /// - [`AgentConfigError::Io`] for filesystem failures.
730    /// - [`AgentConfigError::JsonInvalid`] when the ownership ledger is
731    ///   malformed.
732    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
733    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
734    ///   exists for a file we would back up.
735    /// - [`AgentConfigError::NotOwnedByCaller`] when the instruction or
736    ///   include block exists under another owner (or unowned and
737    ///   `adopt_unowned` is false).
738    /// - [`AgentConfigError::MissingSpecField`] /
739    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
740    fn install_instruction(
741        &self,
742        scope: &Scope,
743        spec: &InstructionSpec,
744    ) -> Result<InstallReport, AgentConfigError>;
745
746    /// Uninstall the instruction identified by `name`, owned by `owner_tag`.
747    /// Returns [`AgentConfigError::NotOwnedByCaller`] on owner mismatch.
748    ///
749    /// # Errors
750    ///
751    /// Same envelope as
752    /// [`install_instruction`](InstructionSurface::install_instruction).
753    fn uninstall_instruction(
754        &self,
755        scope: &Scope,
756        name: &str,
757        owner_tag: &str,
758    ) -> Result<UninstallReport, AgentConfigError>;
759}
760
761/// Outcome of a successful [`Integration::install`].
762#[must_use]
763#[derive(Debug, Default, Clone)]
764#[non_exhaustive]
765pub struct InstallReport {
766    /// Files this call created (did not previously exist).
767    pub created: Vec<PathBuf>,
768    /// Existing files this call modified.
769    pub patched: Vec<PathBuf>,
770    /// Sibling `.bak` files written. Each entry is the backup path; the
771    /// original lives at the same path without `.bak`.
772    pub backed_up: Vec<PathBuf>,
773    /// True if every target was already in the desired state; nothing changed.
774    pub already_installed: bool,
775}
776
777impl InstallReport {
778    /// Fold another report's contents into this one.
779    ///
780    /// `already_installed` stays true only if both reports were already
781    /// installed *and* this report has not produced any created/patched
782    /// entries from earlier merges.
783    pub(crate) fn merge(&mut self, from: InstallReport) {
784        if !from.already_installed {
785            self.already_installed = false;
786        } else if self.created.is_empty() && self.patched.is_empty() {
787            self.already_installed = true;
788        }
789        self.created.extend(from.created);
790        self.patched.extend(from.patched);
791        self.backed_up.extend(from.backed_up);
792    }
793}
794
795/// Outcome of a successful [`Integration::uninstall`].
796#[must_use]
797#[derive(Debug, Default, Clone)]
798#[non_exhaustive]
799pub struct UninstallReport {
800    /// Files removed entirely.
801    pub removed: Vec<PathBuf>,
802    /// Files modified (tagged content stripped, file kept).
803    pub patched: Vec<PathBuf>,
804    /// Backups restored to their original location.
805    pub restored: Vec<PathBuf>,
806    /// True if the integration was not installed; nothing changed.
807    pub not_installed: bool,
808}
809
810impl UninstallReport {
811    /// Fold another report's contents into this one. `not_installed` survives
812    /// only when neither report removed/patched/restored anything.
813    pub(crate) fn merge(&mut self, from: UninstallReport) {
814        self.not_installed = from.not_installed
815            && self.removed.is_empty()
816            && self.patched.is_empty()
817            && self.restored.is_empty();
818        self.removed.extend(from.removed);
819        self.patched.extend(from.patched);
820        self.restored.extend(from.restored);
821    }
822}
823
824/// Outcome of a successful [`Integration::migrate`].
825#[must_use]
826#[derive(Debug, Clone)]
827#[non_exhaustive]
828pub enum MigrationReport {
829    /// Nothing to migrate.
830    NoOp,
831    /// Migrated files. Includes paths removed and paths rewritten.
832    Migrated {
833        /// Paths removed during migration (e.g., legacy shell scripts).
834        removed: Vec<PathBuf>,
835        /// Paths rewritten in place.
836        rewritten: Vec<PathBuf>,
837    },
838}
839
840fn owner_from_status(status: &StatusReport) -> Option<String> {
841    match &status.status {
842        InstallStatus::InstalledOwned { owner }
843        | InstallStatus::InstalledOtherOwner { owner }
844        | InstallStatus::LedgerOnly { owner } => Some(owner.clone()),
845        _ => None,
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852
853    #[test]
854    fn install_report_default() {
855        let r = InstallReport::default();
856        assert!(r.created.is_empty());
857        assert!(r.patched.is_empty());
858        assert!(r.backed_up.is_empty());
859        assert!(!r.already_installed);
860    }
861
862    #[test]
863    fn uninstall_report_default() {
864        let r = UninstallReport::default();
865        assert!(r.removed.is_empty());
866        assert!(r.patched.is_empty());
867        assert!(r.restored.is_empty());
868        assert!(!r.not_installed);
869    }
870
871    #[test]
872    fn migration_report_debug_clone() {
873        let noop = MigrationReport::NoOp;
874        let _ = format!("{noop:?}");
875
876        let migrated = MigrationReport::Migrated {
877            removed: vec![PathBuf::from("/a")],
878            rewritten: vec![],
879        };
880        let cloned = migrated.clone();
881        let _ = format!("{cloned:?}");
882    }
883}