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 NOTES_HOOK: &str = "oxframe notes 2>/dev/null || true";
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());
}
notes_hook(dir, &mut report)?;
Ok(report)
}
fn notes_hook(dir: &Path, report: &mut Report) -> std::io::Result<()> {
const REL: &str = ".claude/settings.json";
let path = dir.join(REL);
let mut settings = match std::fs::read_to_string(&path) {
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
Ok(v) if v.is_object() => v,
_ => {
report.skipped.push(REL.to_owned());
return Ok(());
}
},
Err(_) => serde_json::json!({}),
};
let hooks = &mut settings["hooks"]["UserPromptSubmit"];
if hooks.to_string().contains("oxframe notes") {
return Ok(());
}
if !hooks.is_array() {
*hooks = serde_json::json!([]);
}
hooks
.as_array_mut()
.expect("an array")
.push(serde_json::json!({ "hooks": [{ "type": "command", "command": NOTES_HOOK }] }));
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string_pretty(&settings)? + "\n")?;
report.written.push(REL.to_owned());
Ok(())
}
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 {
if f.ends_with("settings.json") {
crate::note(format!(
"kept {f} (not plain JSON); add a UserPromptSubmit hook `{NOTES_HOOK}` by hand"
));
} else {
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(), 5);
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 adds_the_notes_hook_to_claude_settings() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(".claude/settings.json");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
r#"{"permissions":{"allow":["Bash(cargo:*)"]},"hooks":{"UserPromptSubmit":[{"hooks":[{"type":"command","command":"date"}]}]}}"#,
)
.unwrap();
write(tmp.path(), false).unwrap();
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
v["permissions"]["allow"][0], "Bash(cargo:*)",
"the user's settings stay"
);
let hooks = v["hooks"]["UserPromptSubmit"].as_array().unwrap();
assert_eq!(hooks.len(), 2);
assert_eq!(hooks[1]["hooks"][0]["command"], NOTES_HOOK);
assert_eq!(write(tmp.path(), false).unwrap(), Report::default());
std::fs::write(&path, "{ // mine").unwrap();
assert_eq!(
write(tmp.path(), false).unwrap().skipped,
vec![".claude/settings.json"]
);
}
#[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("{{"));
}
}