use std::path::Path;
use crate::exit;
pub(crate) const AGENTS_MD: &str = include_str!("../templates/agents/AGENTS.md");
const CLAUDE_MD: &str = include_str!("../templates/agents/CLAUDE.md");
pub(crate) const SKILL_MD: &str = include_str!("../templates/agents/SKILL.md");
pub(crate) const REFERENCE_MD: &str = include_str!("../templates/agents/reference.md");
const MARKER: &str = "oxframe-agents ";
fn files(sdk: &str) -> [(&'static str, String); 4] {
let v = env!("CARGO_PKG_VERSION");
let series = sdk.split('.').take(2).collect::<Vec<_>>().join(".");
let fill = |t: &str| {
t.replace("{{VERSION}}", v)
.replace("{{SDK_VERSION}}", sdk)
.replace("{{DOCS_SERIES}}", &series)
};
[
("AGENTS.md", fill(AGENTS_MD)),
("CLAUDE.md", fill(CLAUDE_MD)),
(".claude/skills/oxframe/SKILL.md", fill(SKILL_MD)),
(".claude/skills/oxframe/reference.md", fill(REFERENCE_MD)),
]
}
fn is_ours(existing: &str) -> bool {
existing.contains(MARKER) || existing.trim() == "@AGENTS.md"
}
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Report {
pub written: Vec<String>,
pub skipped: Vec<String>,
}
pub fn write(dir: &Path, force: bool) -> std::io::Result<Report> {
let mut report = Report::default();
let sdk = std::fs::read_to_string(dir.join("Cargo.toml"))
.ok()
.and_then(|t| crate::upgrade::project_sdk_version(&t))
.unwrap_or_else(|| crate::init::SDK_VERSION.to_owned());
for (rel, content) in files(&sdk) {
let path = dir.join(rel);
if let Ok(existing) = std::fs::read_to_string(&path) {
if existing == content {
continue;
}
if !force && !is_ours(&existing) {
report.skipped.push(rel.to_owned());
continue;
}
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, content)?;
report.written.push(rel.to_owned());
}
Ok(report)
}
pub fn run(force: bool) -> u8 {
let root = match crate::config::project_root() {
Ok(r) => r,
Err(e) => {
crate::error(e);
return exit::ENV;
}
};
match write(&root, force) {
Ok(r) => {
for f in &r.written {
crate::note(format!("wrote {f}"));
}
for f in &r.skipped {
crate::note(format!("kept {f} (edited by you; use --force to replace)"));
}
if r.written.is_empty() && r.skipped.is_empty() {
crate::note("agent instructions are up to date");
}
exit::OK
}
Err(e) => {
crate::error(format!("cannot write agent files: {e}"));
exit::ENV
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_updates_and_respects_user_files() {
let tmp = tempfile::tempdir().unwrap();
let r = write(tmp.path(), false).unwrap();
assert_eq!(r.written.len(), 4);
let skill =
std::fs::read_to_string(tmp.path().join(".claude/skills/oxframe/SKILL.md")).unwrap();
assert!(skill.starts_with("---\nname: oxframe\ndescription: "));
assert!(!skill.contains("{{VERSION}}"));
assert_eq!(write(tmp.path(), false).unwrap(), Report::default());
std::fs::write(
tmp.path().join("AGENTS.md"),
"<!-- oxframe-agents 0.0.1 -->\nold",
)
.unwrap();
std::fs::write(tmp.path().join("CLAUDE.md"), "my own notes").unwrap();
let r = write(tmp.path(), false).unwrap();
assert_eq!(r.written, vec!["AGENTS.md"]);
assert_eq!(r.skipped, vec!["CLAUDE.md"]);
assert_eq!(write(tmp.path(), true).unwrap().written, vec!["CLAUDE.md"]);
}
#[test]
fn links_the_docs_of_the_projects_version() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("Cargo.toml"),
"[package]\nname = \"a\"\n\n[dependencies]\noxframe = { version = \"=0.3.2\" }\n",
)
.unwrap();
write(tmp.path(), false).unwrap();
let agents = std::fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap();
assert!(agents.contains("oxframe 0.3.2"));
assert!(agents.contains("https://oxframe.dev/docs/0.3\n"));
assert!(agents.contains("https://oxframe.dev/docs/0.3/llms.txt"));
assert!(agents.contains("https://oxframe.dev/docs/0.3/llms-full.txt"));
assert!(agents.contains("https://docs.rs/oxframe/0.3.2/oxframe/"));
assert!(!agents.contains("{{"));
}
}