Skip to main content

mollify_core/
agents.rs

1//! Agent-integration installer.
2//!
3//! Mollify ships ready-to-commit skills, rules, hooks, slash-commands, and
4//! workflows for several coding agents. Those artifacts live in this repo
5//! (`.claude/`, `.cursor/`, `.gemini/`, `.codex/`, `.agents/`, `.devin/`,
6//! `.windsurf/`, plus a few root marker files). This module embeds them into
7//! the binary via [`include_dir`] so `mollify init --agent <name>` can scaffold
8//! the right set into any project — regardless of whether mollify was installed
9//! through `uv`, `pip`, or `cargo`. The embedded copy is version-matched
10//! to the CLI by construction (it is compiled from the same tree).
11//!
12//! Existing files are never overwritten unless `force` is set; the installer
13//! reports created / skipped counts so a human stays in control of their repo.
14
15use camino::{Utf8Path, Utf8PathBuf};
16use include_dir::{include_dir, Dir, File};
17
18/// Every agent artifact, mirrored into the crate by `scripts/sync-agent-assets.sh`.
19///
20/// Embedding from *inside* the crate (rather than reaching out to `../../`)
21/// keeps the published crate self-contained, so `cargo install mollify-cli`
22/// (crates.io) builds identically to the maturin/source builds. Each path
23/// within this tree already equals its install-relative destination (e.g.
24/// `.claude/skills/mollify/SKILL.md`). The `assets_match_repo_root_sources`
25/// test guards against the mirror drifting from the canonical sources.
26static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets");
27
28/// A coding agent we can scaffold integration files for.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum Agent {
31    /// Claude Code: `.mcp.json`, `.claude/` (skills, commands, hooks). The
32    /// "use mollify" instructions live in the skill, so no root memory file.
33    Claude,
34    /// Cursor: `.cursor/` (rules, MCP config, slash commands).
35    Cursor,
36    /// Gemini CLI: `GEMINI.md`, `.gemini/` (settings + commands).
37    Gemini,
38    /// Codex / portable open-standard: `AGENTS.md`, `.codex/`, `.agents/`.
39    Codex,
40    /// Devin Desktop / Windsurf Cascade: `.devin/` + `.windsurf/`.
41    Cascade,
42}
43
44impl Agent {
45    /// All agents, for `--agent all`.
46    pub const ALL: [Agent; 5] = [
47        Agent::Claude,
48        Agent::Cursor,
49        Agent::Gemini,
50        Agent::Codex,
51        Agent::Cascade,
52    ];
53
54    /// Parse an agent name (case-insensitive). Accepts a few friendly aliases.
55    pub fn parse(name: &str) -> Option<Agent> {
56        match name.to_ascii_lowercase().as_str() {
57            "claude" | "claude-code" => Some(Agent::Claude),
58            "cursor" => Some(Agent::Cursor),
59            "gemini" | "gemini-cli" => Some(Agent::Gemini),
60            "codex" | "agents" => Some(Agent::Codex),
61            "cascade" | "devin" | "windsurf" => Some(Agent::Cascade),
62            _ => None,
63        }
64    }
65
66    /// The canonical name used in messages.
67    pub fn name(self) -> &'static str {
68        match self {
69            Agent::Claude => "claude",
70            Agent::Cursor => "cursor",
71            Agent::Gemini => "gemini",
72            Agent::Codex => "codex",
73            Agent::Cascade => "cascade",
74        }
75    }
76
77    /// Top-level entries (within [`ASSETS`]) this agent installs. Each is either
78    /// a directory (installed recursively) or a single root marker file.
79    fn entries(self) -> &'static [&'static str] {
80        match self {
81            // Claude and Cascade install hooks that invoke the advisory report
82            // helper (`scripts/mollify-report.sh`), so they ship it too.
83            Agent::Claude => &[".claude", ".mcp.json", "scripts/mollify-report.sh"],
84            Agent::Cursor => &[".cursor"],
85            Agent::Gemini => &[".gemini", "GEMINI.md"],
86            Agent::Codex => &[".codex", ".agents", "AGENTS.md"],
87            Agent::Cascade => &[".devin", ".windsurf", "scripts/mollify-report.sh"],
88        }
89    }
90
91    /// The (destination-relative-path, contents) pairs this agent installs.
92    /// An embedded path is already relative to the install root, so it doubles
93    /// as the destination.
94    fn artifacts(self) -> Vec<(Utf8PathBuf, &'static [u8])> {
95        let mut out: Vec<(Utf8PathBuf, &'static [u8])> = Vec::new();
96        for name in self.entries() {
97            if let Some(file) = ASSETS.get_file(name) {
98                out.push((path_of(file), file.contents()));
99            } else if let Some(dir) = ASSETS.get_dir(name) {
100                let mut files: Vec<&File> = Vec::new();
101                collect_files(dir, &mut files);
102                for f in files {
103                    out.push((path_of(f), f.contents()));
104                }
105            } else {
106                panic!("embedded asset `{name}` is missing — run scripts/sync-agent-assets.sh");
107            }
108        }
109        out.sort_by(|a, b| a.0.cmp(&b.0));
110        out
111    }
112}
113
114/// An embedded file's path as UTF-8 (already relative to the install root).
115fn path_of(f: &File) -> Utf8PathBuf {
116    Utf8Path::from_path(f.path())
117        .expect("embedded asset paths are valid UTF-8")
118        .to_path_buf()
119}
120
121/// Recursively gather every embedded `File` under `dir`.
122fn collect_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<&'a File<'a>>) {
123    for entry in dir.entries() {
124        match entry {
125            include_dir::DirEntry::File(f) => out.push(f),
126            include_dir::DirEntry::Dir(d) => collect_files(d, out),
127        }
128    }
129}
130
131/// What happened to a single artifact during install.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum FileOutcome {
134    Created,
135    Overwritten,
136    Skipped,
137}
138
139/// One installed (or skipped) artifact.
140#[derive(Debug, Clone)]
141pub struct InstalledFile {
142    pub path: Utf8PathBuf,
143    pub outcome: FileOutcome,
144}
145
146/// Scaffold `agent`'s artifacts under `root`. Existing files are skipped unless
147/// `force` is true. Returns a per-file outcome list (deterministic order).
148pub fn install(root: &Utf8Path, agent: Agent, force: bool) -> std::io::Result<Vec<InstalledFile>> {
149    let mut results = Vec::new();
150    for (rel, contents) in agent.artifacts() {
151        let dest = root.join(&rel);
152        let exists = dest.exists();
153        if exists && !force {
154            results.push(InstalledFile {
155                path: rel,
156                outcome: FileOutcome::Skipped,
157            });
158            continue;
159        }
160        if let Some(parent) = dest.parent() {
161            std::fs::create_dir_all(parent)?;
162        }
163        std::fs::write(&dest, contents)?;
164        results.push(InstalledFile {
165            path: rel,
166            outcome: if exists {
167                FileOutcome::Overwritten
168            } else {
169                FileOutcome::Created
170            },
171        });
172    }
173    Ok(results)
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    fn temp(tag: &str) -> Utf8PathBuf {
181        let base =
182            std::env::temp_dir().join(format!("mollify-agents-{}-{tag}", std::process::id()));
183        let _ = std::fs::remove_dir_all(&base);
184        std::fs::create_dir_all(&base).unwrap();
185        Utf8PathBuf::from_path_buf(base).unwrap()
186    }
187
188    #[test]
189    fn parses_agent_names_and_aliases() {
190        assert_eq!(Agent::parse("Claude"), Some(Agent::Claude));
191        assert_eq!(Agent::parse("devin"), Some(Agent::Cascade));
192        assert_eq!(Agent::parse("windsurf"), Some(Agent::Cascade));
193        assert_eq!(Agent::parse("nope"), None);
194    }
195
196    #[test]
197    fn every_agent_has_artifacts() {
198        for a in Agent::ALL {
199            assert!(!a.artifacts().is_empty(), "{} has no artifacts", a.name());
200        }
201    }
202
203    #[test]
204    fn installs_cursor_files_and_skips_existing() {
205        let d = temp("cursor");
206        let r = install(&d, Agent::Cursor, false).unwrap();
207        assert!(r.iter().all(|f| f.outcome == FileOutcome::Created));
208        // The Cursor rule file is a known artifact.
209        assert!(
210            d.join(".cursor/rules/mollify.mdc").exists(),
211            "cursor rule not written"
212        );
213        // Re-running without force skips everything.
214        let r2 = install(&d, Agent::Cursor, false).unwrap();
215        assert!(r2.iter().all(|f| f.outcome == FileOutcome::Skipped));
216        // With force, files are overwritten.
217        let r3 = install(&d, Agent::Cursor, true).unwrap();
218        assert!(r3.iter().all(|f| f.outcome == FileOutcome::Overwritten));
219        std::fs::remove_dir_all(&d).ok();
220    }
221
222    #[test]
223    fn claude_installs_root_markers() {
224        let d = temp("claude");
225        install(&d, Agent::Claude, false).unwrap();
226        assert!(d.join(".mcp.json").exists());
227        assert!(d.join(".claude/skills/mollify/SKILL.md").exists());
228        std::fs::remove_dir_all(&d).ok();
229    }
230
231    /// Drift guard: the in-crate embedded mirror must byte-match the canonical
232    /// repo-root sources. If this fails, run `scripts/sync-agent-assets.sh` and
233    /// commit. (Dev/CI-only — reaches out to the workspace root, which exists
234    /// when our own tests run, never on a crates.io consumer's build.)
235    #[test]
236    fn assets_match_repo_root_sources() {
237        let root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
238            .join("..")
239            .join("..");
240        // Skip when the canonical sources aren't present (e.g. `cargo test` on a
241        // published crate, where only the in-crate `assets/` mirror exists).
242        if !root.join(".claude").exists() {
243            return;
244        }
245        let mut files: Vec<&File> = Vec::new();
246        collect_files(&ASSETS, &mut files);
247        assert!(
248            !files.is_empty(),
249            "no embedded assets — sync script not run?"
250        );
251        for f in files {
252            let rel = path_of(f);
253            let src = root.join(&rel);
254            let on_disk = std::fs::read(src.as_std_path()).unwrap_or_else(|_| {
255                panic!("canonical source missing for {rel}; run scripts/sync-agent-assets.sh")
256            });
257            assert!(
258                on_disk == f.contents(),
259                "{rel} is out of sync; run scripts/sync-agent-assets.sh"
260            );
261        }
262    }
263}