use anyhow::{Context, Result};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy)]
pub enum Agent {
ClaudeCode,
Cursor,
Codex,
Opencode,
}
impl Agent {
pub fn all() -> [Agent; 4] {
[
Agent::ClaudeCode,
Agent::Cursor,
Agent::Codex,
Agent::Opencode,
]
}
pub fn name(self) -> &'static str {
match self {
Agent::ClaudeCode => "claude-code",
Agent::Cursor => "cursor",
Agent::Codex => "codex",
Agent::Opencode => "opencode",
}
}
pub fn skill_dir(self) -> PathBuf {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default();
match self {
Agent::ClaudeCode => home.join(".claude/skills"),
Agent::Cursor => home.join(".cursor/skills"),
Agent::Codex => home.join(".codex/skills"),
Agent::Opencode => home.join(".config/opencode/skills"),
}
}
pub fn skill_name(self) -> &'static str {
"android-doctor"
}
}
const FRONTMATTER: &str = "---
id: android-doctor
name: android-doctor
description: Static analysis for Android OTA firmware images.
version: 0.1.0
---
";
const SKILL_BODY: &str = include_str!("skill_body.md");
pub fn install_in(agent: Agent, dir: &std::path::Path) -> Result<PathBuf> {
std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
let dest = dir.join(format!("{}.md", agent.skill_name()));
std::fs::write(&dest, skill_content())
.with_context(|| format!("writing {}", dest.display()))?;
Ok(dest)
}
const BLOCK_BEGIN: &str =
"<!-- android-doctor:begin (managed; re-run `android-doctor doctor install`) -->";
const BLOCK_END: &str = "<!-- android-doctor:end -->";
pub fn install_project(agent: Agent, root: &std::path::Path) -> Result<Option<PathBuf>> {
let dest = match agent {
Agent::Cursor => {
let dest = root.join(".cursor/rules/android-doctor.mdc");
std::fs::create_dir_all(dest.parent().unwrap())
.with_context(|| format!("creating {}", dest.display()))?;
let mdc = format!(
"---\ndescription: Audit Android firmware images with android-doctor\nalwaysApply: false\n---\n{SKILL_BODY}"
);
std::fs::write(&dest, mdc).with_context(|| format!("writing {}", dest.display()))?;
dest
}
Agent::Codex | Agent::Opencode => {
let dest = root.join("AGENTS.md");
let old = std::fs::read_to_string(&dest).unwrap_or_default();
let block = format!("{BLOCK_BEGIN}\n{SKILL_BODY}\n{BLOCK_END}\n");
let new = match (old.find(BLOCK_BEGIN), old.find(BLOCK_END)) {
(Some(b), Some(e)) if b < e => {
format!(
"{}{}{}",
&old[..b],
block,
old[e + BLOCK_END.len()..].trim_start_matches('\n')
)
}
_ if old.is_empty() => block,
_ => format!("{}\n\n{}", old.trim_end(), block),
};
std::fs::write(&dest, new).with_context(|| format!("writing {}", dest.display()))?;
dest
}
Agent::ClaudeCode => return Ok(None),
};
Ok(Some(dest))
}
pub fn content() -> String {
SKILL_BODY.to_string()
}
fn skill_content() -> String {
format!("{}{}", FRONTMATTER, SKILL_BODY)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::Scratch;
fn install_in_scratch(agent: Agent) -> (Scratch, PathBuf) {
let scratch = Scratch::new(&format!("skill-{}", agent.name()));
let path = install_in(agent, scratch.as_ref()).unwrap();
(scratch, path)
}
#[test]
fn install_creates_the_file_and_dir() {
let (_s, path) = install_in_scratch(Agent::ClaudeCode);
assert!(
path.is_file(),
"skill file should exist at {}",
path.display()
);
assert!(path.parent().unwrap().is_dir());
}
#[test]
fn install_writes_frontmatter() {
let (_s, path) = install_in_scratch(Agent::Cursor);
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.starts_with("---\n"), "frontmatter starts with ---");
assert!(content.contains("id: android-doctor"), "frontmatter has id");
assert!(
content.contains("name: android-doctor"),
"frontmatter has name"
);
}
#[test]
fn cursor_gets_an_mdc_rule_and_codex_an_agents_md_block() {
let s = Scratch::new("skill-project");
let mdc = install_project(Agent::Cursor, s.as_ref()).unwrap().unwrap();
assert!(mdc.ends_with(".cursor/rules/android-doctor.mdc"));
assert!(
std::fs::read_to_string(mdc)
.unwrap()
.starts_with("---\ndescription:")
);
std::fs::write(s.as_ref().join("AGENTS.md"), "# mine\n").unwrap();
for _ in 0..2 {
install_project(Agent::Codex, s.as_ref()).unwrap();
}
let md = std::fs::read_to_string(s.as_ref().join("AGENTS.md")).unwrap();
assert!(md.starts_with("# mine\n"));
assert_eq!(
md.matches(BLOCK_BEGIN).count(),
1,
"block is replaced, not duplicated"
);
assert!(
install_project(Agent::ClaudeCode, s.as_ref())
.unwrap()
.is_none()
);
}
#[test]
fn install_is_idempotent() {
let (_s, path) = install_in_scratch(Agent::Codex);
let first = std::fs::read_to_string(&path).unwrap();
let path2 = install_in(Agent::Codex, path.parent().unwrap()).unwrap();
assert_eq!(path, path2);
let second = std::fs::read_to_string(&path2).unwrap();
assert_eq!(first, second);
}
#[test]
fn install_all_agents_writes_valid_files() {
for agent in Agent::all() {
let (_s, path) = install_in_scratch(agent);
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("id: android-doctor"),
"{} skill file is valid",
agent.name()
);
}
}
}