main
John Lauer Flow-app template, step --hwnd, jlcpcb fit column (on 0.1.57) 9ddc18b
//! adom-aiflow-example: TEMPLATE for a flow app. Rename it with template/flow-app/new-flow.sh, write
//! flows/flow.json, then add a verb per deterministic job your stage has. Keep the verbs the AI
//! leans on everywhere: init, next, step, ledger, clip.
mod bridge;
mod flow;
mod ledger;
mod out;

use clap::{Parser, Subcommand};
use out::{err, ok};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};

/// The folder this flow keeps inside the project (state.json, run.jsonl, clips/, evidence).
const FLOW_DIR: &str = "example-flow";

#[derive(Parser)]
#[command(name = "adom-aiflow-example", version, about = "TEMPLATE flow app: one later stage of making a board, as a CLI that tells the AI what to do next")]
struct Cli {
    /// The name of the AI conversation calling (required for commands that change state)
    #[arg(long, global = true)]
    ai_thread: Option<String>,
    /// Project folder (default: the nearest one above the current folder that has this flow's state)
    #[arg(long, global = true)]
    project: Option<String>,
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Start the flow on a finished board: `init --release <board-release.json>` (written by the board flow at finish) or `init --board <B.kicad_pcb> --bom <csv>`
    Init { #[arg(long)] release: Option<String>, #[arg(long)] board: Option<String>, #[arg(long)] bom: Option<String>, #[arg(long)] target: Option<String> },
    /// What to do now: the first step whose artifacts are missing, and what the AI must do in it
    Next,
    /// Every step with its state
    Steps,
    /// The run ledger, one line per command
    Ledger,
    /// `clip start --name <step> --hwnd <window>` / `clip stop`: record one desktop window for the video
    Clip { what: String, #[arg(long)] name: Option<String>, #[arg(long)] hwnd: Option<i64> },
}

fn thread(cli: &Cli) -> String {
    cli.ai_thread.clone().unwrap_or_else(|| err("this command changes state: pass --ai-thread \"<your thread name>\"", &[]))
}

fn project(cli: &Cli) -> ledger::Project {
    let start = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
    ledger::Project::find(&start, FLOW_DIR).unwrap_or_else(|| err(&format!("no {FLOW_DIR}/ project here"), &["Start one: adom-aiflow-example init --release <board-release.json> --ai-thread <you>".into()]))
}

fn sha256(p: &Path) -> String {
    let o = std::process::Command::new("sha256sum").arg(p).output();
    o.ok().and_then(|o| String::from_utf8_lossy(&o.stdout).split_whitespace().next().map(str::to_string)).unwrap_or_default()
}

fn main() {
    let cli = Cli::parse();
    let fl = flow::load();
    match &cli.cmd {
        Cmd::Init { release, board, bom, target } => {
            let th = thread(&cli);
            let dir = cli.project.as_ref().map(PathBuf::from).unwrap_or_else(|| std::env::current_dir().unwrap());
            // the handoff: a board release from the board flow, or the board and BOM named directly
            let rel: Value = match release {
                Some(r) => serde_json::from_str(&std::fs::read_to_string(r).unwrap_or_else(|e| err(&format!("{r}: {e}"), &[]))).unwrap_or_else(|e| err(&format!("{r}: not JSON: {e}"), &[])),
                None => {
                    let (Some(b), Some(m)) = (board, bom) else { err("init needs --release <board-release.json>, or --board and --bom", &["The board flow writes board-release.json when it finishes (schema adom/board-release@1, template/flow-app/schema).".into()]) };
                    json!({"schema": "adom/board-release@1", "board": {"path": b, "sha256": sha256(Path::new(b))}, "bom": {"path": m, "sha256": sha256(Path::new(m))}})
                }
            };
            for k in ["board", "bom"] {
                let p = rel[k]["path"].as_str().unwrap_or("");
                if !Path::new(p).is_file() { err(&format!("the release's {k} is not here: {p}"), &[]); }
                let want = rel[k]["sha256"].as_str().unwrap_or("");
                if !want.is_empty() && want != sha256(Path::new(p)) { err(&format!("{p} changed since the release (sha256 differs)"), &["Finish the board again so the release names the board you mean to build.".into()]); }
            }
            let p = ledger::Project::create(&dir, FLOW_DIR, json!({"flow": fl.name, "release": rel, "target": target, "started": ledger::now()})).unwrap_or_else(|e| err(&format!("cannot write {FLOW_DIR}/: {e}"), &[]));
            p.log(&th, "init", json!({"release": release}));
            let next = flow::current(&fl, &p.flow_dir).map(|s| format!("next step: {}: {}", s.name, s.todo)).unwrap_or_default();
            ok(&format!("{} flow started in {}", fl.name, p.flow_dir.display()), &[next]);
        }
        Cmd::Next => {
            let p = project(&cli);
            match flow::current(&fl, &p.flow_dir) {
                Some(s) => ok(&format!("step {}", s.name), &[s.todo.clone()]),
                None => ok("every step has its artifacts: the flow is done", &[]),
            }
        }
        Cmd::Steps => {
            let p = project(&cli);
            let cur = flow::current(&fl, &p.flow_dir).map(|s| s.name.clone());
            for s in &fl.steps {
                let done = s.done.iter().all(|x| flow::exists(&p.flow_dir, x));
                println!("{} {}", if done { "done" } else if Some(&s.name) == cur.as_ref() { "NOW " } else { "    " }, s.name);
            }
        }
        Cmd::Ledger => {
            let p = project(&cli);
            print!("{}", std::fs::read_to_string(p.path("run.jsonl")).unwrap_or_default());
        }
        Cmd::Clip { what, name, hwnd } => {
            let th = thread(&cli);
            let mut p = project(&cli);
            let target = p.state["target"].as_str().map(str::to_string).unwrap_or_else(|| err("no desktop target: init with --target <box>", &[]));
            let br = bridge::Bridge { target, ai_thread: th.clone() };
            match what.as_str() {
                "start" => {
                    let (Some(n), Some(h)) = (name, hwnd) else { err("clip start needs --name <step> --hwnd <window>", &["Find the window with `adom-bridge desktop_list_windows`. Drive browsers with pup (no OS cursor), so the clip shows no moving mouse.".into()]) };
                    if p.state["clip"].is_object() { err("a clip is already recording: clip stop first", &[]); }
                    let id = br.record_window_start(*h, &format!("{FLOW_DIR}: record step {n}")).unwrap_or_else(|r| err(&format!("recording did not start: {r}"), &[]));
                    p.state["clip"] = json!({"id": id, "name": n, "hwnd": h, "started": ledger::now()});
                    p.save().ok(); p.log(&th, "clip-start", p.state["clip"].clone());
                    ok(&format!("recording {n} (window {h})"), &[]);
                }
                "stop" => {
                    let c = p.state["clip"].clone();
                    let id = c["id"].as_str().unwrap_or_else(|| err("no clip is recording", &[]));
                    let dir = p.path("clips"); std::fs::create_dir_all(&dir).ok();
                    let s = br.record_stop(id, &dir);
                    p.state["clip"] = Value::Null; p.save().ok();
                    p.log(&th, "clip-stop", json!({"name": c["name"], "remote": s.remote, "local": s.local, "pulled": s.pulled}));
                    if !s.pulled { err(&format!("the clip did not land locally: {}", s.reply), &["Pull it by hand with pull_file before the desktop cleans its recordings folder.".into()]); }
                    ok(&format!("clip {} saved to {}", c["name"].as_str().unwrap_or(""), s.local.unwrap().display()), &[]);
                }
                _ => err("clip start|stop", &[]),
            }
        }
    }
}