oxo-flow-cli 0.29.1

CLI for the oxo-flow bioinformatics pipeline engine
//! Scaffold agent-facing docs into a project: `AGENTS.md` (user-editable
//! entry point), `CLAUDE.md` (one-line import for Claude Code), and the full
//! canonical `SKILL.md` operating manual.
//!
//! Agents are the actual operators of many oxo-flow projects; the entry file
//! is what they read first. `AGENTS.md` is the open convention most tools
//! pick up (Codex, Cursor, Jules, Claude Code); `CLAUDE.md` imports it so
//! Claude Code users need no extra step. The generated files are start
//! points the user owns — they are never overwritten without `--force`.

use anyhow::{Context, Result};
use colored::Colorize;
use std::path::{Path, PathBuf};

/// The canonical agent operating manual. `docs/guide/src/SKILL.md` is the
/// source of truth; this copy exists because `cargo package`/`cargo publish`
/// only ship files inside the crate (same precedent as `templates/`), and
/// `make skill-drift` fails CI when the two diverge.
const SKILL_MD: &str = include_str!("../../assets/SKILL.md");

const SKILL_FILENAME: &str = "SKILL.md";
const AGENTS_FILENAME: &str = "AGENTS.md";
const CLAUDE_FILENAME: &str = "CLAUDE.md";

pub fn agent_docs_command(dir: Option<PathBuf>, force: bool) -> Result<()> {
    let dir = dir.unwrap_or_else(|| PathBuf::from("."));
    write_agent_docs(&dir, force, None)
}

/// Write the three agent docs into `dir`. Existing files are kept unless
/// `force` — `AGENTS.md` is meant to be edited by the project owner.
pub fn write_agent_docs(dir: &Path, force: bool, project_name: Option<&str>) -> Result<()> {
    std::fs::create_dir_all(dir)
        .with_context(|| format!("failed to create directory {}", dir.display()))?;

    let mut written = Vec::new();
    let mut kept = Vec::new();
    for (name, content) in agent_docs_files(project_name) {
        let path = dir.join(name);
        if path.exists() && !force {
            kept.push(path);
            continue;
        }
        std::fs::write(&path, content)
            .with_context(|| format!("failed to write {}", path.display()))?;
        written.push(path);
    }

    for path in &written {
        eprintln!("  {} {}", "✓".green().bold(), path.display());
    }
    for path in &kept {
        eprintln!(
            "  {} {} (existing kept; --force to refresh)",
            "•".dimmed(),
            path.display()
        );
    }
    Ok(())
}

/// The `(filename, content)` pairs, in write order. `SKILL.md` is the full
/// canonical manual; `AGENTS.md` carries the safety summary inline so an
/// agent that reads only the entry file still gets the non-negotiables.
fn agent_docs_files(project_name: Option<&str>) -> [(String, String); 3] {
    let version = env!("CARGO_PKG_VERSION");
    let title = project_name.unwrap_or("this oxo-flow project");

    let agents = format!(
        r#"# Agent Context — {title}

<!-- Generated by oxo-flow {version}. This file belongs to the project owner:
     edit it freely; `oxo-flow agent-docs` never overwrites it without --force.
     Re-run `oxo-flow agent-docs --force` to refresh the generated skeleton
     after an oxo-flow upgrade. -->

Read [SKILL.md](./SKILL.md) before operating in this project — it is the
canonical oxo-flow operating manual (workflow format, static gates, execution,
monitoring, QC, and delivery), shipped alongside this file by
`oxo-flow agent-docs`.

## Non-negotiable data safety (full contract: SKILL.md → Data Safety)

- Input data is read-only. Never delete, move, or overwrite an input without
  explicit human authorization.
- Snapshot before mutating (`cp -a` + checksum); write new files and rename;
  never truncate an existing file with `>`.
- All probes and scratch work live under `.oxo-flow/agent/<date>-<task>/`
  inside this project; journal every mutating command to
  `.oxo-flow/agent/operations.log`.
- Prefer `oxo-flow status`, `oxo-flow report`, `oxo-flow provenance verify`,
  and `oxo-flow dry-run` over ad-hoc shell.

## Project notes (edit freely)

<!-- Describe what an agent cannot infer from the files. Examples:
- Raw data: raw/ (vendor FASTQs — read-only)
- References: reference/ (genome build, indexes)
- Cluster profile: see [cluster] in the workflow
- Sample naming: {{sample}}_R1.fastq.gz / {{sample}}_R2.fastq.gz
-->
"#
    );

    let claude = format!(
        r#"# Claude Code

<!-- Generated by oxo-flow {version}; re-run `oxo-flow agent-docs --force`
     to refresh. Claude Code reads this file and imports the lines below. -->

@AGENTS.md
"#
    );

    [
        (SKILL_FILENAME.to_string(), SKILL_MD.to_string()),
        (AGENTS_FILENAME.to_string(), agents),
        (CLAUDE_FILENAME.to_string(), claude),
    ]
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn writes_three_files_with_relative_links() {
        let dir = tempfile::tempdir().unwrap();
        write_agent_docs(dir.path(), false, Some("demo")).unwrap();

        let skill = std::fs::read_to_string(dir.path().join(SKILL_FILENAME)).unwrap();
        assert_eq!(
            skill, SKILL_MD,
            "SKILL.md must be the embedded copy verbatim"
        );
        assert!(
            skill.contains("Data Safety"),
            "manual carries the safety contract"
        );

        let agents = std::fs::read_to_string(dir.path().join(AGENTS_FILENAME)).unwrap();
        assert!(
            agents.contains("[SKILL.md](./SKILL.md)"),
            "relative link to the manual"
        );
        assert!(agents.contains("demo"), "project name is spliced in");
        assert!(agents.contains("read-only"), "safety summary is inline");
        assert!(
            agents.contains(env!("CARGO_PKG_VERSION")),
            "version marker for refreshes"
        );

        let claude = std::fs::read_to_string(dir.path().join(CLAUDE_FILENAME)).unwrap();
        assert!(claude.contains("@AGENTS.md"), "Claude Code import line");
    }

    #[test]
    fn keeps_existing_files_without_force() {
        let dir = tempfile::tempdir().unwrap();
        write_agent_docs(dir.path(), false, None).unwrap();
        std::fs::write(dir.path().join(AGENTS_FILENAME), "my notes\n").unwrap();

        write_agent_docs(dir.path(), false, None).unwrap();
        let agents = std::fs::read_to_string(dir.path().join(AGENTS_FILENAME)).unwrap();
        assert_eq!(agents, "my notes\n", "user edits survive a re-run");
    }

    #[test]
    fn force_refreshes_existing_files() {
        let dir = tempfile::tempdir().unwrap();
        write_agent_docs(dir.path(), false, None).unwrap();
        std::fs::write(dir.path().join(AGENTS_FILENAME), "my notes\n").unwrap();

        write_agent_docs(dir.path(), true, None).unwrap();
        let agents = std::fs::read_to_string(dir.path().join(AGENTS_FILENAME)).unwrap();
        assert_ne!(agents, "my notes\n");
        assert!(agents.contains("[SKILL.md](./SKILL.md)"));
    }
}