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}