klasp_core/surface.rs
1//! `AgentSurface` — abstraction over an AI-agent integration surface.
2//!
3//! Design: [docs/design.md §3.1, §5]. Each agent (Claude Code, Codex,
4//! Cursor, Aider) has a structurally different install path: Claude merges
5//! into a JSON file, Codex writes a managed-block markdown into AGENTS.md,
6//! Cursor writes `.cursor/rules/*.mdc`, Aider edits `.aider.conf.yml`. A
7//! trait — not an enum + match — keeps each impl free to share no state
8//! with the others, and lets v0.3 plugins ship third-party `AgentSurface`
9//! implementations without forking klasp.
10
11use std::borrow::Cow;
12use std::path::{Path, PathBuf};
13
14/// Inputs handed to every `install` invocation. Holds enough context that
15/// an `AgentSurface` impl needs no further filesystem probing.
16#[derive(Debug, Clone)]
17pub struct InstallContext {
18 pub repo_root: PathBuf,
19 pub dry_run: bool,
20 pub force: bool,
21 /// The wire-protocol schema version the generated hook script should
22 /// export. Sourced from [`crate::protocol::GATE_SCHEMA_VERSION`] at the
23 /// caller; passed in here to keep the trait pure.
24 pub schema_version: u32,
25}
26
27/// What an install (or dry-run) actually did. The `paths_written` field is
28/// empty when `dry_run` was true.
29#[derive(Debug, Clone)]
30pub struct InstallReport {
31 pub agent_id: String,
32 pub hook_path: PathBuf,
33 pub settings_path: PathBuf,
34 pub already_installed: bool,
35 pub paths_written: Vec<PathBuf>,
36 /// In dry-run mode, the rendered hook-script content for preview.
37 /// Populated by `install()` when `ctx.dry_run` is true and identical to
38 /// `render_hook_script(ctx)`. `None` outside dry-run.
39 pub preview: Option<String>,
40}
41
42#[derive(Debug, thiserror::Error)]
43pub enum InstallError {
44 #[error("io error at {path}: {source}")]
45 Io {
46 path: PathBuf,
47 #[source]
48 source: std::io::Error,
49 },
50
51 #[error(
52 "{path} exists but does not contain klasp's managed marker. \
53 Re-run with --force to overwrite, or remove the file manually."
54 )]
55 MarkerConflict { path: PathBuf },
56
57 #[error("could not parse {path} as JSON: {source}")]
58 SettingsParse {
59 path: PathBuf,
60 #[source]
61 source: serde_json::Error,
62 },
63
64 #[error("agent surface `{agent_id}` reports: {message}")]
65 Surface { agent_id: String, message: String },
66}
67
68/// A non-fatal warning produced by surface install or doctor operations.
69#[derive(Debug, Clone)]
70pub struct SurfaceWarning {
71 pub path: PathBuf,
72 pub message: Cow<'static, str>,
73}
74
75/// A single finding from [`AgentSurface::doctor_check`].
76#[derive(Debug, Clone)]
77pub enum DoctorFinding {
78 Ok(String),
79 Warn(String),
80 Fail(String),
81 Info(String),
82}
83
84/// Object-safe trait. The surface registry stores impls as
85/// `Box<dyn AgentSurface>`; built-in surfaces (Claude in v0.1, Codex in
86/// v0.2, etc.) are registered statically, and v0.3 subprocess plugins add
87/// dynamic registrations at startup.
88pub trait AgentSurface: Send + Sync {
89 /// Stable agent identifier (e.g. `"claude_code"`, `"codex"`).
90 fn agent_id(&self) -> &'static str;
91
92 /// Auto-detect whether this surface is relevant to the given repo
93 /// (e.g. presence of `.claude/` for Claude Code, `AGENTS.md` for Codex).
94 /// `klasp install --force` overrides a `false` here.
95 fn detect(&self, repo_root: &Path) -> bool;
96
97 /// Perform the install. Must be idempotent: running twice with the same
98 /// input yields the same on-disk state and returns
99 /// `already_installed = true` on the second run.
100 fn install(&self, ctx: &InstallContext) -> Result<InstallReport, InstallError>;
101
102 /// Remove klasp's managed entries. Returns the list of paths that were
103 /// (or would be, in `dry_run`) modified. Sibling hooks must be
104 /// preserved.
105 fn uninstall(&self, repo_root: &Path, dry_run: bool) -> Result<Vec<PathBuf>, InstallError>;
106
107 /// Render the hook-script content this surface would write. Pure —
108 /// no filesystem access. Used by `install` and by `--dry-run`.
109 fn render_hook_script(&self, ctx: &InstallContext) -> String;
110
111 /// Path to the hook-script file this surface owns.
112 fn hook_path(&self, repo_root: &Path) -> PathBuf;
113
114 /// Path to the agent's settings/config file this surface mutates.
115 fn settings_path(&self, repo_root: &Path) -> PathBuf;
116
117 /// Install the surface and return the report plus any non-fatal warnings
118 /// (e.g. skipped hook conflicts). The default delegates to `install` and
119 /// returns an empty warning list; surfaces that can produce warnings
120 /// (currently Codex) override this method.
121 fn install_with_warnings(
122 &self,
123 ctx: &InstallContext,
124 ) -> Result<(InstallReport, Vec<SurfaceWarning>), InstallError> {
125 Ok((self.install(ctx)?, Vec::new()))
126 }
127
128 /// Per-surface health check for `klasp doctor`. Returns a list of
129 /// [`DoctorFinding`]s; the default implementation verifies that the
130 /// on-disk hook at `hook_path` byte-matches a freshly rendered
131 /// `render_hook_script`. Surfaces with richer install shapes (Codex:
132 /// two hooks + AGENTS.md block; Claude Code: JSON settings parse)
133 /// override this method.
134 fn doctor_check(&self, repo_root: &Path, schema_version: u32) -> Vec<DoctorFinding> {
135 let agent_id = self.agent_id();
136 let hook_path = self.hook_path(repo_root);
137 let mut findings = Vec::new();
138
139 let actual = match std::fs::read_to_string(&hook_path) {
140 Ok(s) => s,
141 Err(_) => {
142 findings.push(DoctorFinding::Fail(format!(
143 "hook[{agent_id}]: {} not found; re-run `klasp install`",
144 hook_path.display()
145 )));
146 return findings;
147 }
148 };
149
150 let ctx = InstallContext {
151 repo_root: repo_root.to_path_buf(),
152 dry_run: false,
153 force: false,
154 schema_version,
155 };
156 let expected = self.render_hook_script(&ctx);
157
158 if actual == expected {
159 findings.push(DoctorFinding::Ok(format!(
160 "hook[{agent_id}]: current (schema v{schema_version})"
161 )));
162 } else {
163 findings.push(DoctorFinding::Fail(format!(
164 "hook[{agent_id}]: schema drift detected (re-run `klasp install`)"
165 )));
166 }
167
168 findings
169 }
170}