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, unsupported transport, owner
322    /// mismatch, parse failure) are encoded as
323    /// [`crate::plan::PlanStatus::Refused`].
324    fn plan_install_mcp(
325        &self,
326        scope: &Scope,
327        spec: &McpSpec,
328    ) -> Result<InstallPlan, AgentConfigError>;
329
330    /// Plan an MCP server uninstall without mutating user files.
331    ///
332    /// # Errors
333    ///
334    /// Same envelope as [`plan_install_mcp`](McpSurface::plan_install_mcp).
335    fn plan_uninstall_mcp(
336        &self,
337        scope: &Scope,
338        name: &str,
339        owner_tag: &str,
340    ) -> Result<UninstallPlan, AgentConfigError>;
341
342    /// Install (or update) the MCP server. Repeated calls with the same
343    /// `spec.name` and same content are a no-op after the first.
344    ///
345    /// # Errors
346    ///
347    /// - [`AgentConfigError::PathResolution`] when the target path escapes
348    ///   the scope root or contains a symlink component.
349    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
350    ///   [`supported_mcp_scopes`](McpSurface::supported_mcp_scopes).
351    /// - [`AgentConfigError::Io`] for filesystem failures.
352    /// - [`AgentConfigError::JsonInvalid`] / [`AgentConfigError::TomlInvalid`]
353    ///   when the existing config is unparseable and cannot be merged.
354    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
355    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
356    ///   exists for a file that would otherwise be backed up.
357    /// - [`AgentConfigError::NotOwnedByCaller`] when an existing server entry
358    ///   belongs to a different consumer (or is unowned and `adopt_unowned`
359    ///   is false).
360    /// - [`AgentConfigError::InlineSecretInLocalScope`] under `Scope::Local`
361    ///   when the spec carries an inline secret.
362    /// - [`AgentConfigError::UnsupportedTransport`] when the integration does
363    ///   not support the requested MCP transport.
364    /// - [`AgentConfigError::MissingSpecField`] /
365    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
366    fn install_mcp(&self, scope: &Scope, spec: &McpSpec)
367        -> Result<InstallReport, AgentConfigError>;
368
369    /// Uninstall the MCP server identified by `name`, owned by `owner_tag`.
370    ///
371    /// Returns [`AgentConfigError::NotOwnedByCaller`] if the entry is recorded
372    /// under a different owner, or if it exists in the harness config but is
373    /// missing from the ledger (i.e., user-installed by hand).
374    ///
375    /// # Errors
376    ///
377    /// Same envelope as [`install_mcp`](McpSurface::install_mcp). The
378    /// owner-mismatch case is the typical one.
379    fn uninstall_mcp(
380        &self,
381        scope: &Scope,
382        name: &str,
383        owner_tag: &str,
384    ) -> Result<UninstallReport, AgentConfigError>;
385}
386
387/// One AI harness's skill installer.
388///
389/// Skills are directory-scoped: each one is a folder under the harness's
390/// `skills/` root containing a `SKILL.md` plus optional `scripts/`,
391/// `references/`, and `assets/` subdirectories. Implemented by harnesses with
392/// upstream Agent Skills support.
393///
394/// Like [`McpSurface`], ownership is tracked via a sidecar ledger so multiple
395/// consumers can coexist and uninstall is refused on owner mismatch.
396pub trait SkillSurface: Send + Sync {
397    /// Stable, kebab-case identifier matching [`Integration::id`] for the
398    /// same agent.
399    fn id(&self) -> &'static str;
400
401    /// Which scopes this skill installer accepts.
402    fn supported_skill_scopes(&self) -> &'static [ScopeKind];
403
404    /// Returns true if a skill named `name` is currently recorded in the
405    /// ownership ledger for this scope.
406    ///
407    /// Default impl mirrors [`McpSurface::is_mcp_installed`]: both
408    /// [`InstallStatus::InstalledOwned`] and
409    /// [`InstallStatus::InstalledOtherOwner`] count as installed.
410    ///
411    /// # Errors
412    ///
413    /// Propagates whatever [`skill_status`](SkillSurface::skill_status) returns.
414    fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
415        Ok(matches!(
416            self.skill_status(scope, name, self.id())?.status,
417            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
418        ))
419    }
420
421    /// Detailed installation state for the skill identified by `name`,
422    /// scored against `expected_owner`. See [`McpSurface::mcp_status`] for
423    /// the owner-comparison semantics.
424    ///
425    /// # Errors
426    ///
427    /// Same envelope as [`McpSurface::mcp_status`].
428    fn skill_status(
429        &self,
430        scope: &Scope,
431        name: &str,
432        expected_owner: &str,
433    ) -> Result<StatusReport, AgentConfigError>;
434
435    /// Validate skill state without mutating user files.
436    ///
437    /// # Errors
438    ///
439    /// Equivalent to
440    /// [`validate_skill_for_owner`](SkillSurface::validate_skill_for_owner)
441    /// with `expected_owner = None`.
442    fn validate_skill(
443        &self,
444        scope: &Scope,
445        name: &str,
446    ) -> Result<ValidationReport, AgentConfigError> {
447        self.validate_skill_for_owner(scope, name, None)
448    }
449
450    /// Validate skill state against a caller-supplied expected owner.
451    ///
452    /// # Errors
453    ///
454    /// - [`AgentConfigError::InvalidTag`] when `name` violates the kebab-case
455    ///   skill-name contract or `expected_owner` is malformed.
456    /// - Any error from [`skill_status`](SkillSurface::skill_status) other
457    ///   than [`AgentConfigError::JsonInvalid`], which is folded into the
458    ///   returned [`ValidationReport`] as malformed-ledger output.
459    fn validate_skill_for_owner(
460        &self,
461        scope: &Scope,
462        name: &str,
463        expected_owner: Option<&str>,
464    ) -> Result<ValidationReport, AgentConfigError> {
465        SkillSpec::validate_name(name)?;
466        if let Some(owner) = expected_owner {
467            HookSpec::validate_tag(owner)?;
468        }
469        let status = match self.skill_status(scope, name, expected_owner.unwrap_or("")) {
470            Ok(status) => status,
471            Err(AgentConfigError::JsonInvalid { path, source }) => {
472                let target = PlanTarget::Skill {
473                    integration_id: self.id(),
474                    scope: scope.clone(),
475                    name: name.to_string(),
476                    owner: expected_owner.unwrap_or_default().to_string(),
477                };
478                return Ok(crate::validation::malformed_ledger_report(
479                    target,
480                    path,
481                    source.to_string(),
482                ));
483            }
484            Err(e) => return Err(e),
485        };
486        let target = PlanTarget::Skill {
487            integration_id: self.id(),
488            scope: scope.clone(),
489            name: name.to_string(),
490            owner: expected_owner
491                .map(str::to_owned)
492                .or_else(|| owner_from_status(&status))
493                .unwrap_or_default(),
494        };
495        crate::validation::skill_report_from_status(target, name, expected_owner, status)
496    }
497
498    /// Plan a skill install without mutating user files.
499    ///
500    /// # Errors
501    ///
502    /// - [`AgentConfigError::PathResolution`] when the skill directory cannot
503    ///   be resolved.
504    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
505    ///   when reading existing skill assets or the ledger fails.
506    /// - [`AgentConfigError::MissingSpecField`] /
507    ///   [`AgentConfigError::InvalidTag`] from [`SkillSpec`] re-validation.
508    ///
509    /// Predictable refusals (unsupported scope, owner mismatch) are encoded
510    /// as [`crate::plan::PlanStatus::Refused`].
511    fn plan_install_skill(
512        &self,
513        scope: &Scope,
514        spec: &SkillSpec,
515    ) -> Result<InstallPlan, AgentConfigError>;
516
517    /// Plan a skill uninstall without mutating user files.
518    ///
519    /// # Errors
520    ///
521    /// Same envelope as
522    /// [`plan_install_skill`](SkillSurface::plan_install_skill).
523    fn plan_uninstall_skill(
524        &self,
525        scope: &Scope,
526        name: &str,
527        owner_tag: &str,
528    ) -> Result<UninstallPlan, AgentConfigError>;
529
530    /// Install (or update) the skill directory and record ownership.
531    /// Repeated calls with byte-identical contents are a no-op after the
532    /// first.
533    ///
534    /// # Errors
535    ///
536    /// - [`AgentConfigError::PathResolution`] when a target path escapes the
537    ///   scope root or contains a symlink component.
538    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
539    ///   [`supported_skill_scopes`](SkillSurface::supported_skill_scopes).
540    /// - [`AgentConfigError::Io`] for filesystem failures.
541    /// - [`AgentConfigError::JsonInvalid`] when the ownership ledger is
542    ///   malformed.
543    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
544    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
545    ///   exists for a file we would back up.
546    /// - [`AgentConfigError::NotOwnedByCaller`] when the skill exists on
547    ///   disk under another owner (or unowned and `adopt_unowned` is false).
548    /// - [`AgentConfigError::MissingSpecField`] /
549    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
550    fn install_skill(
551        &self,
552        scope: &Scope,
553        spec: &SkillSpec,
554    ) -> Result<InstallReport, AgentConfigError>;
555
556    /// Uninstall the skill identified by `name`, owned by `owner_tag`.
557    /// Returns [`AgentConfigError::NotOwnedByCaller`] on owner mismatch or when
558    /// the skill exists on disk but is missing from the ledger.
559    ///
560    /// # Errors
561    ///
562    /// Same envelope as [`install_skill`](SkillSurface::install_skill). The
563    /// owner-mismatch case is the typical one.
564    fn uninstall_skill(
565        &self,
566        scope: &Scope,
567        name: &str,
568        owner_tag: &str,
569    ) -> Result<UninstallReport, AgentConfigError>;
570}
571
572/// One AI harness's standalone-instruction installer.
573///
574/// Instructions are named markdown files that provide persistent context to
575/// the agent. Unlike hook rules (which are tied to a single hook spec),
576/// instructions are independent documents that are loaded by the agent on
577/// every session start.
578///
579/// Implemented by harnesses that support standalone instruction files or
580/// managed include references in their memory/rules files. Use
581/// [`crate::registry::instruction_capable`] to enumerate agents that
582/// implement this trait.
583///
584/// All operations must be idempotent: installing the same [`InstructionSpec`]
585/// twice reaches and preserves a single on-disk state. Uninstalls refuse to
586/// remove entries owned by another consumer (recorded in a sidecar ledger).
587pub trait InstructionSurface: Send + Sync {
588    /// Stable, kebab-case identifier matching [`Integration::id`] for the
589    /// same agent.
590    fn id(&self) -> &'static str;
591
592    /// Which scopes this instruction installer accepts.
593    fn supported_instruction_scopes(&self) -> &'static [ScopeKind];
594
595    /// Returns true if an instruction named `name` is currently recorded
596    /// under any owner in this scope's ownership ledger.
597    ///
598    /// # Errors
599    ///
600    /// Propagates whatever
601    /// [`instruction_status`](InstructionSurface::instruction_status) returns.
602    fn is_instruction_installed(
603        &self,
604        scope: &Scope,
605        name: &str,
606    ) -> Result<bool, AgentConfigError> {
607        Ok(matches!(
608            self.instruction_status(scope, name, self.id())?.status,
609            InstallStatus::InstalledOwned { .. } | InstallStatus::InstalledOtherOwner { .. }
610        ))
611    }
612
613    /// Detailed installation state for the instruction identified by `name`,
614    /// scored against `expected_owner`.
615    ///
616    /// # Errors
617    ///
618    /// Same envelope as [`McpSurface::mcp_status`].
619    fn instruction_status(
620        &self,
621        scope: &Scope,
622        name: &str,
623        expected_owner: &str,
624    ) -> Result<StatusReport, AgentConfigError>;
625
626    /// Validate instruction state without mutating user files.
627    ///
628    /// # Errors
629    ///
630    /// Equivalent to
631    /// [`validate_instruction_for_owner`](InstructionSurface::validate_instruction_for_owner)
632    /// with `expected_owner = None`.
633    fn validate_instruction(
634        &self,
635        scope: &Scope,
636        name: &str,
637    ) -> Result<ValidationReport, AgentConfigError> {
638        self.validate_instruction_for_owner(scope, name, None)
639    }
640
641    /// Validate instruction state against a caller-supplied expected owner.
642    ///
643    /// # Errors
644    ///
645    /// - [`AgentConfigError::InvalidTag`] when `name` or `expected_owner`
646    ///   fails identifier validation.
647    /// - Any error from
648    ///   [`instruction_status`](InstructionSurface::instruction_status)
649    ///   other than [`AgentConfigError::JsonInvalid`], which is folded into
650    ///   the returned [`ValidationReport`] as malformed-ledger output.
651    fn validate_instruction_for_owner(
652        &self,
653        scope: &Scope,
654        name: &str,
655        expected_owner: Option<&str>,
656    ) -> Result<ValidationReport, AgentConfigError> {
657        InstructionSpec::validate_name(name)?;
658        if let Some(owner) = expected_owner {
659            HookSpec::validate_tag(owner)?;
660        }
661        let status = match self.instruction_status(scope, name, expected_owner.unwrap_or("")) {
662            Ok(status) => status,
663            Err(AgentConfigError::JsonInvalid { path, source }) => {
664                let target = PlanTarget::Instruction {
665                    integration_id: self.id(),
666                    scope: scope.clone(),
667                    name: name.to_string(),
668                    owner: expected_owner.unwrap_or_default().to_string(),
669                };
670                return Ok(crate::validation::malformed_ledger_report(
671                    target,
672                    path,
673                    source.to_string(),
674                ));
675            }
676            Err(e) => return Err(e),
677        };
678        let target = PlanTarget::Instruction {
679            integration_id: self.id(),
680            scope: scope.clone(),
681            name: name.to_string(),
682            owner: expected_owner
683                .map(str::to_owned)
684                .or_else(|| owner_from_status(&status))
685                .unwrap_or_default(),
686        };
687        crate::validation::ledger_backed_report_from_status(target, name, expected_owner, status)
688    }
689
690    /// Plan an instruction install without mutating user files.
691    ///
692    /// # Errors
693    ///
694    /// - [`AgentConfigError::PathResolution`] when the instruction or host
695    ///   memory file cannot be resolved.
696    /// - [`AgentConfigError::Io`] / [`AgentConfigError::ConfigTooLarge`]
697    ///   when reading the existing instruction, host file, or ledger fails.
698    /// - [`AgentConfigError::MissingSpecField`] /
699    ///   [`AgentConfigError::InvalidTag`] from [`InstructionSpec`]
700    ///   re-validation.
701    ///
702    /// Predictable refusals (unsupported scope, owner mismatch) are encoded
703    /// as [`crate::plan::PlanStatus::Refused`].
704    fn plan_install_instruction(
705        &self,
706        scope: &Scope,
707        spec: &InstructionSpec,
708    ) -> Result<InstallPlan, AgentConfigError>;
709
710    /// Plan an instruction uninstall without mutating user files.
711    ///
712    /// # Errors
713    ///
714    /// Same envelope as
715    /// [`plan_install_instruction`](InstructionSurface::plan_install_instruction).
716    fn plan_uninstall_instruction(
717        &self,
718        scope: &Scope,
719        name: &str,
720        owner_tag: &str,
721    ) -> Result<UninstallPlan, AgentConfigError>;
722
723    /// Install (or update) the instruction. Repeated calls with the same
724    /// name and identical content are a no-op after the first.
725    ///
726    /// # Errors
727    ///
728    /// - [`AgentConfigError::PathResolution`] when the instruction or host
729    ///   path escapes the scope root or contains a symlink component.
730    /// - [`AgentConfigError::UnsupportedScope`] when the scope is not in
731    ///   [`supported_instruction_scopes`](InstructionSurface::supported_instruction_scopes).
732    /// - [`AgentConfigError::Io`] for filesystem failures.
733    /// - [`AgentConfigError::JsonInvalid`] when the ownership ledger is
734    ///   malformed.
735    /// - [`AgentConfigError::ConfigTooLarge`] when an input exceeds 8 MiB.
736    /// - [`AgentConfigError::BackupExists`] when a sibling `.bak` already
737    ///   exists for a file we would back up.
738    /// - [`AgentConfigError::NotOwnedByCaller`] when the instruction or
739    ///   include block exists under another owner (or unowned and
740    ///   `adopt_unowned` is false).
741    /// - [`AgentConfigError::MissingSpecField`] /
742    ///   [`AgentConfigError::InvalidTag`] from spec re-validation.
743    fn install_instruction(
744        &self,
745        scope: &Scope,
746        spec: &InstructionSpec,
747    ) -> Result<InstallReport, AgentConfigError>;
748
749    /// Uninstall the instruction identified by `name`, owned by `owner_tag`.
750    /// Returns [`AgentConfigError::NotOwnedByCaller`] on owner mismatch.
751    ///
752    /// # Errors
753    ///
754    /// Same envelope as
755    /// [`install_instruction`](InstructionSurface::install_instruction).
756    fn uninstall_instruction(
757        &self,
758        scope: &Scope,
759        name: &str,
760        owner_tag: &str,
761    ) -> Result<UninstallReport, AgentConfigError>;
762}
763
764/// Outcome of a successful [`Integration::install`].
765#[must_use]
766#[derive(Debug, Default, Clone)]
767#[non_exhaustive]
768pub struct InstallReport {
769    /// Files this call created (did not previously exist).
770    pub created: Vec<PathBuf>,
771    /// Existing files this call modified.
772    pub patched: Vec<PathBuf>,
773    /// Sibling `.bak` files written. Each entry is the backup path; the
774    /// original lives at the same path without `.bak`.
775    pub backed_up: Vec<PathBuf>,
776    /// True if every target was already in the desired state; nothing changed.
777    pub already_installed: bool,
778}
779
780impl InstallReport {
781    /// Fold another report's contents into this one.
782    ///
783    /// `already_installed` stays true only if both reports were already
784    /// installed *and* this report has not produced any created/patched
785    /// entries from earlier merges.
786    pub(crate) fn merge(&mut self, from: InstallReport) {
787        if !from.already_installed {
788            self.already_installed = false;
789        } else if self.created.is_empty() && self.patched.is_empty() {
790            self.already_installed = true;
791        }
792        self.created.extend(from.created);
793        self.patched.extend(from.patched);
794        self.backed_up.extend(from.backed_up);
795    }
796}
797
798/// Outcome of a successful [`Integration::uninstall`].
799#[must_use]
800#[derive(Debug, Default, Clone)]
801#[non_exhaustive]
802pub struct UninstallReport {
803    /// Files removed entirely.
804    pub removed: Vec<PathBuf>,
805    /// Files modified (tagged content stripped, file kept).
806    pub patched: Vec<PathBuf>,
807    /// Backups restored to their original location.
808    pub restored: Vec<PathBuf>,
809    /// True if the integration was not installed; nothing changed.
810    pub not_installed: bool,
811}
812
813impl UninstallReport {
814    /// Fold another report's contents into this one. `not_installed` survives
815    /// only when neither report removed/patched/restored anything.
816    pub(crate) fn merge(&mut self, from: UninstallReport) {
817        self.not_installed = from.not_installed
818            && self.removed.is_empty()
819            && self.patched.is_empty()
820            && self.restored.is_empty();
821        self.removed.extend(from.removed);
822        self.patched.extend(from.patched);
823        self.restored.extend(from.restored);
824    }
825}
826
827/// Outcome of a successful [`Integration::migrate`].
828#[must_use]
829#[derive(Debug, Clone)]
830#[non_exhaustive]
831pub enum MigrationReport {
832    /// Nothing to migrate.
833    NoOp,
834    /// Migrated files. Includes paths removed and paths rewritten.
835    Migrated {
836        /// Paths removed during migration (e.g., legacy shell scripts).
837        removed: Vec<PathBuf>,
838        /// Paths rewritten in place.
839        rewritten: Vec<PathBuf>,
840    },
841}
842
843fn owner_from_status(status: &StatusReport) -> Option<String> {
844    match &status.status {
845        InstallStatus::InstalledOwned { owner }
846        | InstallStatus::InstalledOtherOwner { owner }
847        | InstallStatus::LedgerOnly { owner } => Some(owner.clone()),
848        _ => None,
849    }
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    #[test]
857    fn install_report_default() {
858        let r = InstallReport::default();
859        assert!(r.created.is_empty());
860        assert!(r.patched.is_empty());
861        assert!(r.backed_up.is_empty());
862        assert!(!r.already_installed);
863    }
864
865    #[test]
866    fn uninstall_report_default() {
867        let r = UninstallReport::default();
868        assert!(r.removed.is_empty());
869        assert!(r.patched.is_empty());
870        assert!(r.restored.is_empty());
871        assert!(!r.not_installed);
872    }
873
874    #[test]
875    fn migration_report_debug_clone() {
876        let noop = MigrationReport::NoOp;
877        let _ = format!("{noop:?}");
878
879        let migrated = MigrationReport::Migrated {
880            removed: vec![PathBuf::from("/a")],
881            rewritten: vec![],
882        };
883        let cloned = migrated.clone();
884        let _ = format!("{cloned:?}");
885    }
886}