rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok agent:*` — AI agent orchestration wrappers.

use anyhow::Result;
use serde_json::json;
use std::process::Command;

const ROK_RULES: &str = r#"You are implementing features for the rok Rust web framework.
rok is built on Axum + SQLx targeting Laravel/AdonisJS-grade developer experience.

## Coding Rules
- No extra abstractions beyond what the task requires
- No comments unless the WHY is non-obvious
- Use anyhow::Result for all fallible functions
- Follow existing file structure: crates/<crate>/src/
- cargo check -p <crate> must pass after each file

## CLI Cheatsheet (JSON-native)
rok make:controller <Name> [--json]        → controller stub
rok make:model <Name> [--json]             → model + migration
rok make:scaffold <template> [--json]      → feature scaffold
rok make:feature --json-file spec.json     → full feature from spec
rok plan:next [--json]                     → next incomplete phase
rok plan:list [--json]                     → all phases + status
rok plan:status --phase N [--json]         → single phase status
rok db:migrate [--json]                    → run migrations
rok db:status [--json]                     → migration status

## Commit Format
feat(phase-N): <description>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
"#;

pub fn rules(json: bool) -> Result<()> {
    if json {
        let obj = json!({ "status": "ok", "rules": ROK_RULES });
        println!("{}", serde_json::to_string_pretty(&obj)?);
    } else {
        println!("{ROK_RULES}");
    }
    Ok(())
}

pub fn context(phase: Option<u32>, json: bool) -> Result<()> {
    let parts: Vec<(String, String)> = if let Some(n) = phase {
        let prefix = format!("feat/phase-{n}-*");
        // Try to find the directory
        let dir = glob::glob(&prefix)
            .ok()
            .and_then(|mut g| g.next())
            .and_then(|r| r.ok())
            .map(|p| p.display().to_string())
            .unwrap_or_else(|| format!("feat/phase-{n}"));

        vec![
            (
                format!("{dir}/feature.md"),
                std::fs::read_to_string(format!("{dir}/feature.md")).unwrap_or_default(),
            ),
            (
                format!("{dir}/PRD.json"),
                std::fs::read_to_string(format!("{dir}/PRD.json")).unwrap_or_default(),
            ),
            (
                format!("{dir}/prompt.md"),
                std::fs::read_to_string(format!("{dir}/prompt.md")).unwrap_or_default(),
            ),
            (
                format!("{dir}/progress.txt"),
                std::fs::read_to_string(format!("{dir}/progress.txt")).unwrap_or_default(),
            ),
        ]
    } else {
        vec![(
            "router.md".into(),
            std::fs::read_to_string("router.md").unwrap_or_default(),
        )]
    };

    if json {
        let files: Vec<serde_json::Value> = parts
            .iter()
            .map(|(path, content)| json!({ "path": path, "content": content }))
            .collect();
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({ "status": "ok", "files": files }))?
        );
    } else {
        for (path, content) in &parts {
            println!("=== {path} ===\n{content}\n");
        }
    }
    Ok(())
}

pub fn feedback(progress: &str, prd_item: Option<usize>, json: bool) -> Result<()> {
    // Append to progress.txt in current dir (if it exists)
    let progress_path = "progress.txt";
    if std::path::Path::new(progress_path).exists() {
        let mut existing = std::fs::read_to_string(progress_path)?;
        existing.push('\n');
        existing.push_str(progress);
        std::fs::write(progress_path, &existing)?;
    }

    if json {
        println!(
            "{}",
            serde_json::to_string_pretty(&json!({
                "status": "ok",
                "appended": progress,
                "prd_item_updated": prd_item,
            }))?
        );
    } else {
        println!("Feedback recorded.");
        if let Some(item) = prd_item {
            println!("PRD item {} marked as updated.", item);
        }
    }
    Ok(())
}

fn detect_agent(name: &str) -> Option<String> {
    let cmd = if cfg!(target_os = "windows") {
        "where"
    } else {
        "which"
    };
    let out = Command::new(cmd).arg(name).output().ok()?;
    if out.status.success() {
        let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !path.is_empty() {
            Some(path)
        } else {
            None
        }
    } else {
        None
    }
}

pub fn launch_claude(phase: Option<u32>, extra_prompt: Option<&str>) -> Result<()> {
    let agent = detect_agent("claude").ok_or_else(|| {
        anyhow::anyhow!("claude not found in PATH — install from https://claude.ai/download")
    })?;

    let mut system = ROK_RULES.to_string();
    if let Some(n) = phase {
        let ctx = load_phase_context(n);
        system.push_str("\n\n## Phase Context\n");
        system.push_str(&ctx);
    }
    if let Some(extra) = extra_prompt {
        system.push_str("\n\n## Additional Instructions\n");
        system.push_str(extra);
    }

    let status = Command::new(&agent).args(["-p", &system]).status()?;

    if !status.success() {
        anyhow::bail!("claude exited with status {status}");
    }
    Ok(())
}

pub fn launch_opencode(phase: Option<u32>, extra_prompt: Option<&str>) -> Result<()> {
    let agent =
        detect_agent("opencode").ok_or_else(|| anyhow::anyhow!("opencode not found in PATH"))?;

    let mut system = ROK_RULES.to_string();
    if let Some(n) = phase {
        system.push_str(&format!(
            "\n\nImplement Phase {n}. See feat/phase-{n}-*/prompt.md"
        ));
    }
    if let Some(extra) = extra_prompt {
        system.push_str("\n\n");
        system.push_str(extra);
    }

    Command::new(&agent).arg(&system).status()?;
    Ok(())
}

fn load_phase_context(n: u32) -> String {
    let prefix = format!("feat/phase-{n}-");
    let dir = std::fs::read_dir("feat")
        .ok()
        .and_then(|mut entries| {
            entries.find_map(|e| {
                let e = e.ok()?;
                let name = e.file_name().to_string_lossy().to_string();
                if name.starts_with(&prefix) {
                    Some(e.path().display().to_string())
                } else {
                    None
                }
            })
        })
        .unwrap_or_else(|| format!("feat/phase-{n}"));

    let mut ctx = String::new();
    for file in &["feature.md", "PRD.json", "prompt.md", "progress.txt"] {
        let path = format!("{dir}/{file}");
        if let Ok(content) = std::fs::read_to_string(&path) {
            ctx.push_str(&format!("### {file}\n{content}\n\n"));
        }
    }
    ctx
}