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::path::{Path, PathBuf};
12
13/// Inputs handed to every `install` invocation. Holds enough context that
14/// an `AgentSurface` impl needs no further filesystem probing.
15#[derive(Debug, Clone)]
16pub struct InstallContext {
17 pub repo_root: PathBuf,
18 pub dry_run: bool,
19 pub force: bool,
20 /// The wire-protocol schema version the generated hook script should
21 /// export. Sourced from [`crate::protocol::GATE_SCHEMA_VERSION`] at the
22 /// caller; passed in here to keep the trait pure.
23 pub schema_version: u32,
24}
25
26/// What an install (or dry-run) actually did. The `paths_written` field is
27/// empty when `dry_run` was true.
28#[derive(Debug, Clone)]
29pub struct InstallReport {
30 pub agent_id: String,
31 pub hook_path: PathBuf,
32 pub settings_path: PathBuf,
33 pub already_installed: bool,
34 pub paths_written: Vec<PathBuf>,
35 /// In dry-run mode, the rendered hook-script content for preview.
36 /// Populated by `install()` when `ctx.dry_run` is true and identical to
37 /// `render_hook_script(ctx)`. `None` outside dry-run.
38 pub preview: Option<String>,
39}
40
41#[derive(Debug, thiserror::Error)]
42pub enum InstallError {
43 #[error("io error at {path}: {source}")]
44 Io {
45 path: PathBuf,
46 #[source]
47 source: std::io::Error,
48 },
49
50 #[error(
51 "{path} exists but does not contain klasp's managed marker. \
52 Re-run with --force to overwrite, or remove the file manually."
53 )]
54 MarkerConflict { path: PathBuf },
55
56 #[error("could not parse {path} as JSON: {source}")]
57 SettingsParse {
58 path: PathBuf,
59 #[source]
60 source: serde_json::Error,
61 },
62
63 #[error("agent surface `{agent_id}` reports: {message}")]
64 Surface { agent_id: String, message: String },
65}
66
67/// Object-safe trait. The surface registry stores impls as
68/// `Box<dyn AgentSurface>`; built-in surfaces (Claude in v0.1, Codex in
69/// v0.2, etc.) are registered statically, and v0.3 subprocess plugins add
70/// dynamic registrations at startup.
71pub trait AgentSurface: Send + Sync {
72 /// Stable agent identifier (e.g. `"claude_code"`, `"codex"`).
73 fn agent_id(&self) -> &'static str;
74
75 /// Auto-detect whether this surface is relevant to the given repo
76 /// (e.g. presence of `.claude/` for Claude Code, `AGENTS.md` for Codex).
77 /// `klasp install --force` overrides a `false` here.
78 fn detect(&self, repo_root: &Path) -> bool;
79
80 /// Perform the install. Must be idempotent: running twice with the same
81 /// input yields the same on-disk state and returns
82 /// `already_installed = true` on the second run.
83 fn install(&self, ctx: &InstallContext) -> Result<InstallReport, InstallError>;
84
85 /// Remove klasp's managed entries. Returns the list of paths that were
86 /// (or would be, in `dry_run`) modified. Sibling hooks must be
87 /// preserved.
88 fn uninstall(&self, repo_root: &Path, dry_run: bool) -> Result<Vec<PathBuf>, InstallError>;
89
90 /// Render the hook-script content this surface would write. Pure —
91 /// no filesystem access. Used by `install` and by `--dry-run`.
92 fn render_hook_script(&self, ctx: &InstallContext) -> String;
93
94 /// Path to the hook-script file this surface owns.
95 fn hook_path(&self, repo_root: &Path) -> PathBuf;
96
97 /// Path to the agent's settings/config file this surface mutates.
98 fn settings_path(&self, repo_root: &Path) -> PathBuf;
99}