pi-workflows 0.10.0

Terminal viewer and live replay server for pi-workflows run bundles
Documentation
use anyhow::Result;
use clap::{Parser, Subcommand};
use piw::{server, ui};
use std::path::PathBuf;

/// Terminal viewer and live replay server for pi-workflows run bundles.
#[derive(Parser)]
#[command(name = "piw", version, about)]
struct Cli {
    /// Runs directory or a single run bundle directory
    /// (default: ~/.pi/agent/workflows/runs).
    path: Option<PathBuf>,

    /// Connect to a `piw serve` server instead of reading the filesystem.
    #[arg(long, value_name = "URL", conflicts_with = "path")]
    connect: Option<String>,

    /// Viewer theme name. Overrides PIW_THEME and the config file.
    #[arg(long, value_name = "NAME")]
    theme: Option<String>,

    /// Print built-in viewer theme names and exit.
    #[arg(long)]
    list_themes: bool,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    /// Serve run views over the live replay protocol (WebSocket).
    Serve {
        /// Runs directory to watch (default: ~/.pi/agent/workflows/runs).
        #[arg(long, value_name = "DIR")]
        runs_dir: Option<PathBuf>,
        /// Address to bind. Bundles contain private data; keep this local.
        #[arg(long, default_value = "127.0.0.1:9377")]
        bind: String,
    },
}

fn default_runs_dir() -> PathBuf {
    if let Ok(dir) = std::env::var("PI_WORKFLOWS_RUNS_DIR") {
        return PathBuf::from(dir);
    }
    std::env::home_dir()
        .unwrap_or_else(|| PathBuf::from("."))
        .join(".pi")
        .join("agent")
        .join("workflows")
        .join("runs")
}

fn main() -> Result<()> {
    let cli = Cli::parse();
    if cli.list_themes {
        for name in piw::theme::THEME_NAMES {
            println!("{name}");
        }
        return Ok(());
    }
    let cli_theme = cli.theme.clone();
    match cli.command {
        Some(Command::Serve { runs_dir, bind }) => {
            let runs_dir = runs_dir.unwrap_or_else(default_runs_dir);
            anyhow::ensure!(
                runs_dir.is_dir(),
                "runs directory {} does not exist",
                runs_dir.display()
            );
            let runtime = tokio::runtime::Runtime::new()?;
            runtime.block_on(server::serve(server::ServeOptions { runs_dir, bind }))
        }
        None => {
            if let Some(url) = cli.connect {
                return ui::run_remote(&url, cli_theme.as_deref());
            }
            let path = match cli.path {
                Some(path) if path.is_dir() => path,
                // A bare run id resolves inside the default runs directory.
                Some(path) => {
                    let candidate = default_runs_dir().join(&path);
                    anyhow::ensure!(
                        candidate.is_dir(),
                        "{} is neither a directory nor a run id under {}",
                        path.display(),
                        default_runs_dir().display()
                    );
                    candidate
                }
                None => {
                    let dir = default_runs_dir();
                    anyhow::ensure!(dir.is_dir(), "{} does not exist", dir.display());
                    dir
                }
            };
            // A directory containing manifest.json is a single bundle;
            // anything else is treated as a runs directory.
            if path.join("manifest.json").is_file() {
                ui::run_single(&path, cli_theme.as_deref())
            } else {
                ui::run_local(&path, cli_theme.as_deref())
            }
        }
    }
}