Skip to main content

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}