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}