use camino::{Utf8Path, Utf8PathBuf};
use include_dir::{include_dir, Dir, File};
static ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
Cursor,
Gemini,
Codex,
Cascade,
}
impl Agent {
pub const ALL: [Agent; 5] = [
Agent::Claude,
Agent::Cursor,
Agent::Gemini,
Agent::Codex,
Agent::Cascade,
];
pub fn parse(name: &str) -> Option<Agent> {
match name.to_ascii_lowercase().as_str() {
"claude" | "claude-code" => Some(Agent::Claude),
"cursor" => Some(Agent::Cursor),
"gemini" | "gemini-cli" => Some(Agent::Gemini),
"codex" | "agents" => Some(Agent::Codex),
"cascade" | "devin" | "windsurf" => Some(Agent::Cascade),
_ => None,
}
}
pub fn name(self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Cursor => "cursor",
Agent::Gemini => "gemini",
Agent::Codex => "codex",
Agent::Cascade => "cascade",
}
}
fn entries(self) -> &'static [&'static str] {
match self {
Agent::Claude => &[".claude", ".mcp.json", "scripts/mollify-report.sh"],
Agent::Cursor => &[".cursor"],
Agent::Gemini => &[".gemini", "GEMINI.md"],
Agent::Codex => &[".codex", ".agents", "AGENTS.md"],
Agent::Cascade => &[".devin", ".windsurf", "scripts/mollify-report.sh"],
}
}
fn artifacts(self) -> Vec<(Utf8PathBuf, &'static [u8])> {
let mut out: Vec<(Utf8PathBuf, &'static [u8])> = Vec::new();
for name in self.entries() {
if let Some(file) = ASSETS.get_file(name) {
out.push((path_of(file), file.contents()));
} else if let Some(dir) = ASSETS.get_dir(name) {
let mut files: Vec<&File> = Vec::new();
collect_files(dir, &mut files);
for f in files {
out.push((path_of(f), f.contents()));
}
} else {
panic!("embedded asset `{name}` is missing — run scripts/sync-agent-assets.sh");
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
}
fn path_of(f: &File) -> Utf8PathBuf {
Utf8Path::from_path(f.path())
.expect("embedded asset paths are valid UTF-8")
.to_path_buf()
}
fn collect_files<'a>(dir: &'a Dir<'a>, out: &mut Vec<&'a File<'a>>) {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::File(f) => out.push(f),
include_dir::DirEntry::Dir(d) => collect_files(d, out),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileOutcome {
Created,
Overwritten,
Skipped,
}
#[derive(Debug, Clone)]
pub struct InstalledFile {
pub path: Utf8PathBuf,
pub outcome: FileOutcome,
}
pub fn install(root: &Utf8Path, agent: Agent, force: bool) -> std::io::Result<Vec<InstalledFile>> {
let mut results = Vec::new();
for (rel, contents) in agent.artifacts() {
let dest = root.join(&rel);
let exists = dest.exists();
if exists && !force {
results.push(InstalledFile {
path: rel,
outcome: FileOutcome::Skipped,
});
continue;
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, contents)?;
results.push(InstalledFile {
path: rel,
outcome: if exists {
FileOutcome::Overwritten
} else {
FileOutcome::Created
},
});
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
fn temp(tag: &str) -> Utf8PathBuf {
let base =
std::env::temp_dir().join(format!("mollify-agents-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
Utf8PathBuf::from_path_buf(base).unwrap()
}
#[test]
fn parses_agent_names_and_aliases() {
assert_eq!(Agent::parse("Claude"), Some(Agent::Claude));
assert_eq!(Agent::parse("devin"), Some(Agent::Cascade));
assert_eq!(Agent::parse("windsurf"), Some(Agent::Cascade));
assert_eq!(Agent::parse("nope"), None);
}
#[test]
fn every_agent_has_artifacts() {
for a in Agent::ALL {
assert!(!a.artifacts().is_empty(), "{} has no artifacts", a.name());
}
}
#[test]
fn installs_cursor_files_and_skips_existing() {
let d = temp("cursor");
let r = install(&d, Agent::Cursor, false).unwrap();
assert!(r.iter().all(|f| f.outcome == FileOutcome::Created));
assert!(
d.join(".cursor/rules/mollify.mdc").exists(),
"cursor rule not written"
);
let r2 = install(&d, Agent::Cursor, false).unwrap();
assert!(r2.iter().all(|f| f.outcome == FileOutcome::Skipped));
let r3 = install(&d, Agent::Cursor, true).unwrap();
assert!(r3.iter().all(|f| f.outcome == FileOutcome::Overwritten));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn claude_installs_root_markers() {
let d = temp("claude");
install(&d, Agent::Claude, false).unwrap();
assert!(d.join(".mcp.json").exists());
assert!(d.join(".claude/skills/mollify/SKILL.md").exists());
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn assets_match_repo_root_sources() {
let root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..");
if !root.join(".claude").exists() {
return;
}
let mut files: Vec<&File> = Vec::new();
collect_files(&ASSETS, &mut files);
assert!(
!files.is_empty(),
"no embedded assets — sync script not run?"
);
for f in files {
let rel = path_of(f);
let src = root.join(&rel);
let on_disk = std::fs::read(src.as_std_path()).unwrap_or_else(|_| {
panic!("canonical source missing for {rel}; run scripts/sync-agent-assets.sh")
});
assert!(
on_disk == f.contents(),
"{rel} is out of sync; run scripts/sync-agent-assets.sh"
);
}
}
}