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::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}