oxframe-cli 0.24.1

The `oxframe` command: create, preview with hot reload, render and check oxframe projects
//! Instructions for AI coding agents, written into projects by `oxframe init`
//! and refreshed by `oxframe agents`.

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");

/// The Claude Code hook that shows the open review notes with every prompt
/// (silent when there are none, and never blocks the prompt).
const NOTES_HOOK: &str = "oxframe notes 2>/dev/null || true";

/// Version marker inside the generated files.
const MARKER: &str = "oxframe-agents ";

/// The files for a project on oxframe `sdk` (links point at that version's
/// docs, so agents never write code against another version's API).
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)),
    ]
}

/// Whether `existing` was generated by us (and may be replaced).
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>,
}

/// Writes the agent files into `dir`. Files written by the user (without our
/// marker) are kept unless `force`.
pub fn write(dir: &Path, force: bool) -> std::io::Result<Report> {
    let mut report = Report::default();
    // The project's pinned SDK, else the one this CLI creates projects with.
    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)
}

/// Adds the notes hook to `.claude/settings.json`, keeping whatever else is
/// there; a file we cannot read as JSON is left alone.
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(())
}

/// `oxframe agents [--force]`.
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}}"));
        // Idempotent.
        assert_eq!(write(tmp.path(), false).unwrap(), Report::default());
        // An older generated file is refreshed; a user's own file is kept.
        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);
        // Once only.
        assert_eq!(write(tmp.path(), false).unwrap(), Report::default());
        // Not JSON: left alone.
        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("{{"));
    }
}