crates/signaltty-cli/src/main.rs

mod attach;
mod client;
mod daemon;
mod integration;
mod permission_hook;
mod skill;
mod worktree;

use std::path::PathBuf;

use base64::Engine;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};

use client::{CliError, Client};

#[derive(Debug, Parser)]
#[command(
    name = "signaltty",
    about = "signaltty client: workspaces, panes, agents"
)]
struct Args {
    /// Unix socket path (default: $XDG_RUNTIME_DIR/signaltty/signaltty.sock).
    #[arg(long, global = true)]
    socket: Option<PathBuf>,
    /// Machine-readable JSON output (raw `result` object).
    #[arg(long, global = true)]
    json: bool,
    #[command(subcommand)]
    cmd: Command,
}

#[derive(Debug, Subcommand)]
#[allow(clippy::large_enum_variant)]
enum Command {
    /// Ensure the session server is running (start detached if needed).
    Daemon,
    /// Server status.
    Status,
    /// Print the server's API contract (methods, events, error codes).
    Schema,
    /// Create a workspace and spawn a pane in it.
    New {
        #[arg(long)]
        cwd: Option<String>,
        #[arg(long)]
        name: Option<String>,
        #[arg(long)]
        tab: Option<String>,
        /// Command to run (default: $SHELL).
        #[arg(last = true)]
        cmd: Vec<String>,
    },
    /// Workspace operations.
    Workspace {
        #[command(subcommand)]
        op: WorkspaceOp,
    },
    /// Git worktree lifecycle.
    Worktree {
        #[command(subcommand)]
        op: worktree::WorktreeOp,
    },
    /// Tab operations.
    Tab {
        #[command(subcommand)]
        op: TabOp,
    },
    /// Pane operations.
    Pane {
        #[command(subcommand)]
        op: PaneOp,
    },
    /// Emit an explicit notification for a pane.
    Notify {
        #[arg(long)]
        title: String,
        #[arg(long)]
        body: Option<String>,
        #[arg(long)]
        pane: Option<String>,
        #[arg(long, value_parser = ["info", "warning", "error"])]
        severity: Option<String>,
    },
    /// Report a native agent session id for a pane.
    ReportSession {
        #[arg(long)]
        pane: String,
        #[arg(long)]
        session: String,
        #[arg(long)]
        agent: Option<String>,
    },
    /// Deliver an agent hook event (used by installed shims).
    HookEvent {
        #[arg(long)]
        agent: String,
        #[arg(long)]
        event: String,
        #[arg(long)]
        pane: Option<String>,
        /// Read the hook payload JSON from stdin.
        #[arg(long)]
        payload_stdin: bool,
        /// Wait for a native PermissionRequest answer; output provider JSON only.
        #[arg(long)]
        wait_for_answer: bool,
        #[arg(long)]
        message: Option<String>,
        #[arg(long)]
        title: Option<String>,
        #[arg(long, value_parser = ["info", "warning", "error"])]
        severity: Option<String>,
        /// Structured decision request as JSON
        /// (`{"id","prompt","options":[{"id","label"}]}`).
        #[arg(long)]
        decision: Option<String>,
    },
    /// Answer a pending structured decision (directive 2).
    Decision {
        #[command(subcommand)]
        op: DecisionOp,
    },
    /// Manage agent hook integrations.
    Integration {
        #[command(subcommand)]
        op: IntegrationOp,
    },
    /// The gated agent skill (directive 5): read, check, install.
    Skill {
        #[command(subcommand)]
        op: SkillOp,
    },
    /// Wait until a pane reaches a state (lifecycle, attention, exited, seen).
    Wait {
        #[arg(long)]
        pane: String,
        #[arg(long, required = true, value_delimiter = ',')]
        until: Vec<String>,
        /// JSON wait_baseline captured from pane get before submitting work.
        #[arg(long)]
        after_baseline: Option<String>,
        #[arg(long)]
        timeout: Option<u64>,
    },
    /// Focus helpers.
    Focus {
        #[command(subcommand)]
        op: FocusOp,
    },
    /// Executable plugins (manifests + event hooks + commands).
    Plugin {
        #[command(subcommand)]
        op: PluginOp,
    },
    /// Orchestrated background tasks.
    Task {
        #[command(subcommand)]
        op: TaskOp,
    },
    /// Report a task outcome with structured result.
    Report {
        #[arg(long, value_parser = ["completed", "failed", "rejected"])]
        status: String,
        #[arg(long)]
        summary: String,
        #[arg(long)]
        task: Option<String>,
        #[arg(long)]
        pane: Option<String>,
        #[arg(long)]
        artifacts: Option<String>,
        #[arg(long)]
        evidence: Option<String>,
    },
    /// List panes requiring user attention, ranked by severity then recency.
    Attention {
        #[arg(long)]
        limit: Option<usize>,
    },
}

#[derive(Debug, Subcommand)]
#[allow(clippy::large_enum_variant)]
enum TaskOp {
    /// Start a new orchestrated background task.
    Start {
        #[arg(long)]
        repo: String,
        #[arg(long, conflicts_with = "objective_file")]
        objective: Option<String>,
        #[arg(long)]
        objective_file: Option<PathBuf>,
        #[arg(long)]
        constraints: Option<String>,
        #[arg(long)]
        output_format: Option<String>,
        #[arg(long, value_delimiter = ',')]
        acceptance_criteria: Option<Vec<String>>,
        #[arg(long)]
        agent: Option<String>,
        #[arg(long)]
        label: Option<String>,
        #[arg(long)]
        parent_pane: Option<String>,
        #[arg(long)]
        context: Option<String>,
        /// Idempotency key: a retry with the same key returns the existing task.
        #[arg(long)]
        client_request_id: Option<String>,
        #[arg(long)]
        base_ref: Option<String>,
        #[arg(long)]
        fetch_first: bool,
        #[arg(long)]
        branch: Option<String>,
        #[arg(long)]
        path: Option<String>,
        #[arg(long)]
        ready_timeout_s: Option<u64>,
        #[arg(long)]
        stall_timeout_s: Option<u64>,
        #[arg(last = true)]
        cmd: Vec<String>,
    },
    /// Get details of an orchestrated task.
    Get { id: String },
    /// List orchestrated tasks.
    List {
        #[arg(long)]
        context: Option<String>,
        #[arg(long)]
        state: Option<String>,
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Wait for a task or context to settle.
    Wait {
        #[arg(value_name = "TASK_ID")]
        id: Option<String>,
        #[arg(long = "id", hide = true)]
        id_flag: Option<String>,
        #[arg(long)]
        context: Option<String>,
        #[arg(long, default_value = "settled", value_delimiter = ',')]
        until: Vec<String>,
        #[arg(long)]
        timeout: Option<u64>,
    },
    /// View diff of task worktree vs base commit.
    Diff { id: String },
    /// View diff of a specific file in task worktree vs base commit.
    FileDiff { id: String, path: String },
    /// Explicitly merge or discard a completed/working task.
    Finish {
        id: String,
        #[arg(long, conflicts_with = "discard")]
        merge: bool,
        #[arg(long)]
        discard: bool,
        #[arg(long)]
        target: Option<String>,
        /// Delete the task branch (already the default for --merge; opt-in for --discard).
        #[arg(long)]
        delete_branch: bool,
        /// Keep the task branch after --merge (--discard always keeps it unless --delete-branch).
        #[arg(long, conflicts_with_all = ["delete_branch", "discard"])]
        keep_branch: bool,
        #[arg(long)]
        ignore_dirty: bool,
    },
    /// Open a GitHub pull request for a completed task.
    Pr {
        id: String,
        #[arg(long)]
        title: Option<String>,
        #[arg(long)]
        body: Option<String>,
        #[arg(long)]
        draft: bool,
    },
    /// Refresh pull request status (state, checks, review) from GitHub.
    #[command(name = "pr-refresh")]
    PrRefresh { id: Option<String> },
    /// Cancel an orchestrated task.
    Cancel { id: String },
}

#[derive(Debug, Subcommand)]
enum WorkspaceOp {
    List,
    Get {
        id: String,
    },
    Rename {
        id: String,
        name: String,
    },
    Close {
        id: String,
    },
    RefreshGit {
        id: String,
    },
    /// Worktree-vs-HEAD diff as data (files, per-dir groups, totals).
    Diff {
        id: String,
    },
}

#[derive(Debug, Subcommand)]
enum TabOp {
    Create {
        #[arg(long)]
        workspace: String,
        #[arg(long)]
        title: Option<String>,
    },
    Close {
        id: String,
    },
}

#[derive(Debug, Subcommand)]
enum PaneOp {
    Spawn {
        #[arg(long)]
        workspace: String,
        #[arg(long)]
        tab: Option<String>,
        #[arg(long)]
        cwd: Option<String>,
        #[arg(long)]
        agent: Option<String>,
        #[arg(long)]
        parent_pane: Option<String>,
        #[arg(long)]
        label: Option<String>,
        #[arg(long, value_parser = ["fork", "subagent"])]
        relationship: Option<String>,
        #[arg(last = true)]
        cmd: Vec<String>,
    },
    /// Submit prompt to an agent pane with bracketed paste and delayed Enter.
    Submit {
        id: String,
        /// Literal text to send (use --stdin for piped input).
        #[arg(long, conflicts_with = "stdin")]
        text: Option<String>,
        #[arg(long)]
        stdin: bool,
        #[arg(long)]
        submit_delay_ms: Option<u64>,
        #[arg(long, alias = "stall-timeout")]
        stall_timeout_s: Option<u64>,
    },
    Split {
        id: String,
        #[arg(long, value_parser = ["right", "down"], default_value = "right")]
        direction: String,
        #[arg(long)]
        cwd: Option<String>,
        #[arg(last = true)]
        cmd: Vec<String>,
    },
    Get {
        id: String,
    },
    Input {
        id: String,
        /// Literal text to send (use --stdin for piped input).
        #[arg(long, conflicts_with = "stdin")]
        data: Option<String>,
        #[arg(long)]
        stdin: bool,
    },
    Resize {
        id: String,
        #[arg(long)]
        cols: u16,
        #[arg(long)]
        rows: u16,
    },
    Signal {
        id: String,
        #[arg(long, default_value = "INT")]
        signal: String,
        #[arg(long)]
        group: bool,
    },
    Read {
        id: String,
        #[arg(long, value_parser = ["screen", "tail", "rendered"], default_value = "tail")]
        mode: String,
        #[arg(long, default_value_t = 200)]
        lines: u64,
        #[arg(long)]
        after_seq: Option<u64>,
        #[arg(long)]
        raw: bool,
    },
    Attach {
        id: String,
    },
    Close {
        id: String,
    },
    MarkSeen {
        id: String,
    },
    /// Resume a restored pane via its adapter's official resume command.
    Resume {
        id: String,
    },
}

#[derive(Debug, Subcommand)]
enum DecisionOp {
    /// Answer with one of the decision's options.
    Answer {
        #[arg(long)]
        pane: String,
        #[arg(long)]
        decision: String,
        #[arg(long)]
        option: String,
    },
}

#[derive(Debug, Subcommand)]
enum FocusOp {
    /// Id of the next pane needing a human (severity → recency).
    NextUnread,
}

#[derive(Debug, Subcommand)]
enum PluginOp {
    /// List loaded plugins, hooks, and commands.
    List,
    /// Reload plugin manifests from the plugin dir.
    Reload,
    /// Run a plugin command locally (extra args appended).
    Run {
        plugin: String,
        command: String,
        /// Extra args passed to the command.
        #[arg(last = true)]
        args: Vec<String>,
    },
}

#[derive(Debug, Subcommand)]
enum SkillOp {
    /// Print the skill document (agents read this inside a pane).
    Cat,
    /// Gate: exit 0 inside a managed pane, 1 outside.
    Check,
    /// Install the skill file into the harness skills dirs.
    Install {
        #[arg(long)]
        home: Option<String>,
    },
    /// Remove skill files installed by `install`.
    Uninstall {
        #[arg(long)]
        home: Option<String>,
    },
    /// Show skill install status.
    Status {
        #[arg(long)]
        home: Option<String>,
    },
}

#[derive(Debug, Subcommand)]
enum IntegrationOp {
    /// Install hook shims for an agent (or `all`).
    Install {
        agent: String,
        /// Home directory override (default: $HOME). For testing.
        #[arg(long)]
        home: Option<String>,
    },
    /// Remove hook shims installed by `install`.
    Uninstall {
        agent: String,
        #[arg(long)]
        home: Option<String>,
    },
    /// Show integration status.
    Status {
        #[arg(long)]
        home: Option<String>,
    },
}

fn socket_path(args: &Args) -> PathBuf {
    args.socket
        .clone()
        .unwrap_or_else(signaltty_core::paths::socket_path)
}

fn emit(json_mode: bool, result: &Value, human: String) {
    if json_mode {
        println!("{}", serde_json::to_string(result).unwrap());
    } else {
        println!("{human}");
    }
}

fn shell_cmd() -> Vec<String> {
    vec![std::env::var("SHELL").unwrap_or_else(|_| "sh".to_string())]
}

/// Parse `--after-baseline` JSON. An explicit `null` (e.g. from `jq .wait_baseline`
/// on a failed `pane get`) must not silently become a current-state wait.
fn parse_wait_baseline(raw: &str) -> Result<Value, CliError> {
    let baseline: Value = serde_json::from_str(raw)
        .map_err(|e| CliError::Usage(format!("invalid wait baseline: {e}")))?;
    if baseline.is_null() {
        return Err(CliError::Usage("wait baseline must not be null".into()));
    }
    Ok(baseline)
}

#[tokio::main]
async fn main() {
    let args = Args::parse();
    if let Err(e) = run(args).await {
        eprintln!("signaltty: {e}");
        std::process::exit(1);
    }
    // Exit without dropping the runtime: attach's pending stdin read sits on
    // a blocking thread that would hold shutdown until the next keypress.
    let _ = std::io::Write::flush(&mut std::io::stdout());
    std::process::exit(0);
}

async fn run(args: Args) -> Result<(), CliError> {
    let socket = socket_path(&args);
    let json = args.json;
    match args.cmd {
        Command::Daemon => daemon::ensure_running(&socket, json).await,
        Command::Status => {
            let mut c = Client::connect(&socket).await?;
            let r = c.call("server.status", json!({})).await?;
            let human = format!(
                "signaltty {} ({}) up {}s — {} workspaces, {} tabs, {} panes ({} live)",
                r["version"].as_str().unwrap_or("?"),
                r["protocol"].as_str().unwrap_or("?"),
                r["uptime_s"].as_u64().unwrap_or(0),
                r["workspaces"].as_u64().unwrap_or(0),
                r["tabs"].as_u64().unwrap_or(0),
                r["panes"].as_u64().unwrap_or(0),
                r["live_panes"].as_u64().unwrap_or(0),
            );
            emit(json, &r, human);
            Ok(())
        }
        Command::Schema => {
            let mut c = Client::connect(&socket).await?;
            let r = c.call("server.schema", json!({})).await?;
            let names = |key: &str| {
                r[key]
                    .as_array()
                    .map(|a| {
                        a.iter()
                            .filter_map(|v| v.as_str())
                            .collect::<Vec<_>>()
                            .join(" ")
                    })
                    .unwrap_or_default()
            };
            let human = format!(
                "signaltty {} ({})\nmethods: {}\nevents: {}\ncodes: {}",
                r["version"].as_str().unwrap_or("?"),
                r["protocol"].as_str().unwrap_or("?"),
                names("methods"),
                names("events"),
                names("codes"),
            );
            emit(json, &r, human);
            Ok(())
        }
        Command::New {
            cwd,
            name,
            tab,
            cmd,
        } => {
            let mut c = Client::connect(&socket).await?;
            let mut wparams = json!({});
            if let Some(cwd) = cwd {
                wparams["cwd"] = json!(cwd);
            }
            if let Some(name) = name {
                wparams["name"] = json!(name);
            }
            let w = c.call("workspace.create", wparams).await?;
            let ws_id = w["workspace"]["id"].as_str().unwrap_or("?").to_string();
            let tab_id = if let Some(title) = tab {
                let t = c
                    .call("tab.create", json!({"workspace_id": ws_id, "title": title}))
                    .await?;
                t["tab"]["id"].as_str().unwrap_or("?").to_string()
            } else {
                String::new()
            };
            let argv = if cmd.is_empty() { shell_cmd() } else { cmd };
            let mut sparams = json!({"workspace_id": ws_id, "argv": argv});
            if !tab_id.is_empty() {
                sparams["tab_id"] = json!(tab_id);
            }
            let p = c.call("pane.spawn", sparams).await?;
            integration_notice(&p, json);
            let pane_id = p["pane"]["id"].as_str().unwrap_or("?").to_string();
            let handle = w["workspace"]["handle"].as_str().unwrap_or("?").to_string();
            let mut result = json!({"workspace_id": ws_id, "handle": handle, "pane_id": pane_id});
            if let Some(setup) = p.get("integration") {
                result["integration"] = setup.clone();
            }
            emit(
                json,
                &result,
                format!("workspace {ws_id} ({handle}) pane {pane_id}"),
            );
            Ok(())
        }
        Command::Worktree { op } => {
            let mut c = Client::connect(&socket).await?;
            worktree::run(&mut c, op, json).await
        }
        Command::Workspace { op } => {
            let mut c = Client::connect(&socket).await?;
            match op {
                WorkspaceOp::List => {
                    let r = c.call("workspace.list", json!({})).await?;
                    let mut human = String::new();
                    for w in r["workspaces"].as_array().cloned().unwrap_or_default() {
                        human.push_str(&format!(
                            "{}  {}  {}  {}  branch={}\n",
                            w["id"].as_str().unwrap_or("?"),
                            w["handle"].as_str().unwrap_or("-"),
                            w["name"].as_str().unwrap_or("?"),
                            w["cwd"].as_str().unwrap_or("?"),
                            w["git"]["branch"].as_str().unwrap_or("-"),
                        ));
                    }
                    emit(json, &r, human.trim_end().to_string());
                    Ok(())
                }
                WorkspaceOp::Get { id } => {
                    let r = c.call("workspace.get", json!({"workspace_id": id})).await?;
                    let w = &r["workspace"];
                    let human = format!(
                        "{} {} ({}) — {} tabs, {} panes",
                        w["id"].as_str().unwrap_or("?"),
                        w["name"].as_str().unwrap_or("?"),
                        w["cwd"].as_str().unwrap_or("?"),
                        r["tabs"].as_array().map(|a| a.len()).unwrap_or(0),
                        r["panes"].as_array().map(|a| a.len()).unwrap_or(0),
                    );
                    emit(json, &r, human);
                    Ok(())
                }
                WorkspaceOp::Rename { id, name } => {
                    let r = c
                        .call(
                            "workspace.rename",
                            json!({"workspace_id": id, "name": name}),
                        )
                        .await?;
                    emit(json, &r, format!("renamed {id}"));
                    Ok(())
                }
                WorkspaceOp::Close { id } => {
                    let r = c
                        .call("workspace.close", json!({"workspace_id": id}))
                        .await?;
                    emit(json, &r, format!("closed {id}"));
                    Ok(())
                }
                WorkspaceOp::RefreshGit { id } => {
                    let r = c
                        .call("workspace.refresh_git", json!({"workspace_id": id}))
                        .await?;
                    let branch = r["workspace"]["git"]["branch"].as_str().unwrap_or("-");
                    emit(json, &r, format!("branch={branch}"));
                    Ok(())
                }
                WorkspaceOp::Diff { id } => {
                    let r = c
                        .call("workspace.diff", json!({"workspace_id": id}))
                        .await?;
                    let mut human = String::new();
                    for f in r["files"].as_array().cloned().unwrap_or_default() {
                        let path = f["path"].as_str().unwrap_or("?");
                        if f["untracked"].as_bool().unwrap_or(false) {
                            human.push_str(&format!("?? {path}\n"));
                        } else if f["binary"].as_bool().unwrap_or(false) {
                            human.push_str(&format!("bin {path}\n"));
                        } else {
                            human.push_str(&format!(
                                "+{} -{} {path}\n",
                                f["added"].as_u64().unwrap_or(0),
                                f["removed"].as_u64().unwrap_or(0),
                            ));
                        }
                    }
                    for g in r["dirs"].as_array().cloned().unwrap_or_default() {
                        human.push_str(&format!(
                            "{}/ +{} -{}\n",
                            g["dir"].as_str().unwrap_or("?"),
                            g["added"].as_u64().unwrap_or(0),
                            g["removed"].as_u64().unwrap_or(0),
                        ));
                    }
                    human.push_str(&format!(
                        "total +{} -{}\n",
                        r["added"].as_u64().unwrap_or(0),
                        r["removed"].as_u64().unwrap_or(0),
                    ));
                    emit(json, &r, human.trim_end().to_string());
                    Ok(())
                }
            }
        }
        Command::Tab { op } => {
            let mut c = Client::connect(&socket).await?;
            match op {
                TabOp::Create { workspace, title } => {
                    let mut p = json!({"workspace_id": workspace});
                    if let Some(t) = title {
                        p["title"] = json!(t);
                    }
                    let r = c.call("tab.create", p).await?;
                    let id = r["tab"]["id"].as_str().unwrap_or("?").to_string();
                    emit(json, &r, format!("tab {id}"));
                    Ok(())
                }
                TabOp::Close { id } => {
                    let r = c.call("tab.close", json!({"tab_id": id})).await?;
                    emit(json, &r, format!("closed {id}"));
                    Ok(())
                }
            }
        }
        Command::Pane { op } => pane_cmd(socket, json, op).await,
        Command::Notify {
            title,
            body,
            pane,
            severity,
        } => {
            let pane = pane
                .or_else(|| std::env::var("SIGNALTTY_PANE").ok())
                .ok_or_else(|| {
                    CliError::Usage("--pane required (or run inside a pane)".to_string())
                })?;
            let mut c = Client::connect(&socket).await?;
            let mut p = json!({"pane_id": pane, "title": title});
            if let Some(b) = body {
                p["body"] = json!(b);
            }
            if let Some(s) = severity {
                p["severity"] = json!(s);
            }
            let r = c.call("notify", p).await?;
            let id = r["notification"]["id"].as_str().unwrap_or("?").to_string();
            emit(json, &r, format!("notification {id}"));
            Ok(())
        }
        Command::ReportSession {
            pane,
            session,
            agent,
        } => {
            let mut c = Client::connect(&socket).await?;
            let mut p = json!({"pane_id": pane, "agent_session_id": session});
            if let Some(a) = agent {
                p["agent"] = json!(a);
            }
            let r = c.call("report-session", p).await?;
            emit(json, &r, format!("session recorded for {pane}"));
            Ok(())
        }
        Command::HookEvent {
            agent,
            event,
            pane,
            payload_stdin,
            wait_for_answer,
            message,
            title,
            severity,
            decision,
        } => {
            let pane = pane.or_else(|| std::env::var("SIGNALTTY_PANE").ok());
            let mut c = Client::connect(&socket).await?;
            let mut p = json!({"agent": agent, "event": event, "client_pid": std::process::id()});
            if let Some(pid) = pane {
                p["pane_id"] = json!(pid);
            }
            if payload_stdin {
                use tokio::io::AsyncReadExt;
                let mut buf = String::new();
                tokio::io::stdin()
                    .read_to_string(&mut buf)
                    .await
                    .map_err(|e| CliError::Io(e.to_string()))?;
                let payload: Value = if buf.trim().is_empty() {
                    Value::Null
                } else {
                    serde_json::from_str(&buf)
                        .map_err(|e| CliError::Usage(format!("invalid payload JSON: {e}")))?
                };
                p["payload"] = payload;
            }
            if let Some(m) = message {
                p["message"] = json!(m);
            }
            if let Some(t) = title {
                p["title"] = json!(t);
            }
            if let Some(s) = severity {
                p["severity"] = json!(s);
            }
            if let Some(d) = decision {
                let v: Value = serde_json::from_str(&d)
                    .map_err(|e| CliError::Usage(format!("invalid decision JSON: {e}")))?;
                p["decision"] = v;
            }
            if wait_for_answer {
                p["wait_for_answer"] = json!(true);
            }
            let r = c.call("hook-event", p).await?;
            if wait_for_answer {
                return permission_hook::print_verdict(&r);
            }
            if json {
                println!("{}", serde_json::to_string(&r).unwrap());
            } else {
                // Human feedback goes to stderr: hook hosts parse stdout
                // (codex Stop REQUIRES stdout to be empty or JSON).
                eprintln!(
                    "accepted {}:{} lifecycle={} attention={}",
                    r["agent"].as_str().unwrap_or("?"),
                    r["event"].as_str().unwrap_or("?"),
                    r["lifecycle"].as_str().unwrap_or("-"),
                    r["attention"].as_str().unwrap_or("-"),
                );
            }
            Ok(())
        }
        Command::Integration { op } => match op {
            IntegrationOp::Install { agent, home } => {
                let home = home.map(std::path::PathBuf::from);
                if agent == "all" {
                    for a in integration::valid_agents() {
                        integration::install(home.as_deref(), a, json)?;
                    }
                    Ok(())
                } else {
                    integration::install(home.as_deref(), &agent, json)
                }
            }
            IntegrationOp::Uninstall { agent, home } => {
                let home = home.map(std::path::PathBuf::from);
                if agent == "all" {
                    for a in integration::valid_agents() {
                        integration::uninstall(home.as_deref(), a, json)?;
                    }
                    Ok(())
                } else {
                    integration::uninstall(home.as_deref(), &agent, json)
                }
            }
            IntegrationOp::Status { home } => {
                let home = home.map(std::path::PathBuf::from);
                integration::status(home.as_deref(), &[], json)
            }
        },
        Command::Decision { op } => {
            let mut c = Client::connect(&socket).await?;
            match op {
                DecisionOp::Answer {
                    pane,
                    decision,
                    option,
                } => {
                    let r = c
                        .call(
                            "decision.answer",
                            json!({"pane_id": pane, "decision_id": decision, "option_id": option}),
                        )
                        .await?;
                    let human = if r["answered"].as_bool().unwrap_or(false) {
                        format!("answered {decision} with {option}")
                    } else {
                        format!("decision {decision} already gone")
                    };
                    emit(json, &r, human);
                    Ok(())
                }
            }
        }
        Command::Skill { op } => match op {
            SkillOp::Cat => {
                if json {
                    println!(
                        "{}",
                        serde_json::json!({"version": env!("CARGO_PKG_VERSION"), "text": skill::TEXT})
                    );
                } else {
                    print!("{}", skill::TEXT);
                }
                Ok(())
            }
            SkillOp::Check => match skill::check() {
                Ok(pane) => {
                    emit(json, &json!({"pane": pane}), format!("managed pane {pane}"));
                    Ok(())
                }
                Err(e) => Err(e),
            },
            SkillOp::Install { home } => {
                let home = integration::home_dir(home.as_deref())?;
                skill::install(&home, json)
            }
            SkillOp::Uninstall { home } => {
                let home = integration::home_dir(home.as_deref())?;
                skill::uninstall(&home, json)
            }
            SkillOp::Status { home } => {
                let home = integration::home_dir(home.as_deref())?;
                skill::status(&home, json)
            }
        },
        Command::Wait {
            pane,
            until,
            after_baseline,
            timeout,
        } => {
            let mut c = Client::connect(&socket).await?;
            let mut p = json!({"pane_id": pane, "until": until});
            if let Some(baseline) = after_baseline {
                p["after"] = parse_wait_baseline(&baseline)?;
            }
            if let Some(t) = timeout {
                p["timeout_s"] = json!(t);
            }
            let r = c.call("wait", p).await?;
            let human = format!(
                "satisfied: lifecycle={} attention={}",
                r["lifecycle"].as_str().unwrap_or("?"),
                r["attention"].as_str().unwrap_or("?"),
            );
            emit(json, &r, human);
            Ok(())
        }
        Command::Focus { op } => {
            let mut c = Client::connect(&socket).await?;
            match op {
                FocusOp::NextUnread => {
                    let r = c.call("focus.next_unread", json!({})).await?;
                    let id = r["pane_id"].as_str().unwrap_or_default().to_string();
                    emit(
                        json,
                        &r,
                        if id.is_empty() {
                            "(none)".to_string()
                        } else {
                            id
                        },
                    );
                    Ok(())
                }
            }
        }
        Command::Plugin { op } => plugin_cmd(socket, json, op).await,
        Command::Task { op } => task_cmd(socket, json, op).await,
        Command::Report {
            status,
            summary,
            task,
            pane,
            artifacts,
            evidence,
        } => {
            let task_id = task.or_else(|| {
                std::env::var("SIGNALTTY_TASK")
                    .ok()
                    .filter(|s| !s.is_empty())
            });
            let pane_id = pane.or_else(|| {
                std::env::var("SIGNALTTY_PANE")
                    .ok()
                    .filter(|s| !s.is_empty())
            });
            if task_id.is_none() && pane_id.is_none() {
                return Err(CliError::Usage(
                    "must provide --task, --pane, or set $SIGNALTTY_TASK or $SIGNALTTY_PANE"
                        .to_string(),
                ));
            }
            let parsed_artifacts: Option<Vec<Value>> = if let Some(ref a) = artifacts {
                if let Ok(v) = serde_json::from_str::<Vec<Value>>(a) {
                    Some(v)
                } else if let Ok(content) = std::fs::read_to_string(a) {
                    Some(serde_json::from_str::<Vec<Value>>(&content).map_err(|e| {
                        CliError::Usage(format!("failed to parse artifacts JSON file: {e}"))
                    })?)
                } else {
                    return Err(CliError::Usage(format!(
                        "artifacts must be a valid JSON array or path to a JSON file: {a}"
                    )));
                }
            } else {
                None
            };
            let parsed_evidence: Option<Value> = if let Some(ref ev) = evidence {
                if let Ok(v) = serde_json::from_str::<Value>(ev) {
                    Some(v)
                } else if let Ok(content) = std::fs::read_to_string(ev) {
                    Some(serde_json::from_str::<Value>(&content).map_err(|e| {
                        CliError::Usage(format!("failed to parse evidence JSON file: {e}"))
                    })?)
                } else {
                    return Err(CliError::Usage(format!(
                        "evidence must be valid JSON or path to a JSON file: {ev}"
                    )));
                }
            } else {
                None
            };
            let mut payload = json!({
                "status": status,
                "summary": summary,
            });
            if let Some(tid) = task_id {
                payload["task_id"] = json!(tid);
            }
            if let Some(pid) = pane_id {
                payload["pane_id"] = json!(pid);
            }
            if let Some(art) = parsed_artifacts {
                payload["artifacts"] = json!(art);
            }
            if let Some(ev) = parsed_evidence {
                payload["evidence"] = ev;
            }
            let mut c = Client::connect(&socket).await?;
            let r = c.call("task.report", payload).await?;
            let task = &r["task"];
            emit(
                json,
                &r,
                format!(
                    "reported {}: task {} is {}",
                    status,
                    task["id"].as_str().unwrap_or("?"),
                    task["state"].as_str().unwrap_or("?")
                ),
            );
            Ok(())
        }
        Command::Attention { limit } => {
            let mut p = json!({});
            if let Some(lim) = limit {
                p["limit"] = json!(lim);
            }
            let mut c = Client::connect(&socket).await?;
            let r = c.call("attention.pending", p).await?;
            let mut human = String::new();
            if let Some(panes) = r["panes"].as_array() {
                if panes.is_empty() {
                    human.push_str("no panes require attention\n");
                } else {
                    for p in panes {
                        human.push_str(&format!(
                            "{:<8} {:<20} {:<12} {}\n",
                            p["pane_id"].as_str().unwrap_or("?"),
                            p["attention"].as_str().unwrap_or("?"),
                            p["task_id"].as_str().unwrap_or("-"),
                            p["last_message"].as_str().unwrap_or("")
                        ));
                    }
                }
            }
            emit(json, &r, human.trim_end().to_string());
            Ok(())
        }
    }
}

async fn plugin_cmd(socket: PathBuf, json: bool, op: PluginOp) -> Result<(), CliError> {
    match op {
        PluginOp::List | PluginOp::Reload => {
            let method = match op {
                PluginOp::List => "plugin.list",
                _ => "plugin.reload",
            };
            let mut c = Client::connect(&socket).await?;
            let r = c.call(method, json!({})).await?;
            let mut human = String::new();
            let plugins = r["plugins"].as_array().cloned().unwrap_or_default();
            if plugins.is_empty() {
                human.push_str("(no plugins)\n");
            }
            for p in &plugins {
                human.push_str(&format!(
                    "{} {} — {}\n",
                    p["name"].as_str().unwrap_or("?"),
                    p["version"].as_str().unwrap_or(""),
                    p["description"].as_str().unwrap_or("")
                ));
                for h in p["hooks"].as_array().cloned().unwrap_or_default() {
                    human.push_str(&format!(
                        "  hook {:?} → {} (runs {}, errors {})\n",
                        h["events"],
                        h["command"][0].as_str().unwrap_or("?"),
                        h["runs"].as_u64().unwrap_or(0),
                        h["errors"].as_u64().unwrap_or(0),
                    ));
                }
                for cmd in p["commands"].as_array().cloned().unwrap_or_default() {
                    human.push_str(&format!(
                        "  command {} — {}\n",
                        cmd["name"].as_str().unwrap_or("?"),
                        cmd["description"].as_str().unwrap_or("")
                    ));
                }
            }
            for f in r["failures"].as_array().cloned().unwrap_or_default() {
                human.push_str(&format!(
                    "FAILED {:?}: {}\n",
                    f["dir"].as_str().unwrap_or("?"),
                    f["error"].as_str().unwrap_or("?")
                ));
            }
            emit(json, &r, human.trim_end().to_string());
            Ok(())
        }
        PluginOp::Run {
            plugin,
            command,
            args,
        } => {
            // Commands run locally (cwd = plugin dir) with the socket
            // exported so scripts can call back into the server.
            let reg = signaltty_plugin::PluginRegistry::load(signaltty_core::paths::plugin_dir());
            let (dir, cmds) = reg
                .commands(&plugin)
                .ok_or_else(|| CliError::Usage(format!("no such plugin '{plugin}'")))?;
            let cmd = cmds.iter().find(|c| c.name == command).ok_or_else(|| {
                CliError::Usage(format!("plugin '{plugin}' has no command '{command}'"))
            })?;
            let argv = signaltty_plugin::resolve_argv(&dir, &cmd.run);
            let status = std::process::Command::new(&argv[0])
                .args(&argv[1..])
                .args(&args)
                .current_dir(&dir)
                .env("SIGNALTTY_SOCKET", &socket)
                .env("SIGNALTTY_PLUGIN_DIR", &dir)
                .env("SIGNALTTY_PLUGIN_NAME", &plugin)
                .status()
                .map_err(|e| CliError::Usage(format!("cannot run plugin command: {e}")))?;
            if status.success() {
                Ok(())
            } else {
                Err(CliError::Usage(format!(
                    "plugin command exited with {status}"
                )))
            }
        }
    }
}

async fn pane_cmd(socket: PathBuf, json: bool, op: PaneOp) -> Result<(), CliError> {
    match op {
        PaneOp::Attach { id } => attach::run(&socket, &id).await,
        _ => {
            let mut c = Client::connect(&socket).await?;
            match op {
                PaneOp::Attach { .. } => unreachable!(),
                PaneOp::Spawn {
                    workspace,
                    tab,
                    cwd,
                    agent,
                    parent_pane,
                    label,
                    relationship,
                    cmd,
                } => {
                    let argv = if cmd.is_empty() { shell_cmd() } else { cmd };
                    let mut p = json!({"workspace_id": workspace, "argv": argv});
                    if let Some(t) = tab {
                        p["tab_id"] = json!(t);
                    }
                    if let Some(cwd) = cwd {
                        p["cwd"] = json!(cwd);
                    }
                    if let Some(a) = agent {
                        p["agent_hint"] = json!(a);
                    }
                    if let Some(pp) = parent_pane {
                        p["parent_pane_id"] = json!(pp);
                    }
                    if let Some(l) = label {
                        p["label"] = json!(l);
                    }
                    if let Some(rel) = relationship {
                        p["relationship"] = json!(rel);
                    }
                    let r = c.call("pane.spawn", p).await?;
                    integration_notice(&r, json);
                    let id = r["pane"]["id"].as_str().unwrap_or("?").to_string();
                    emit(json, &r, format!("pane {id}"));
                    Ok(())
                }
                PaneOp::Submit {
                    id,
                    text,
                    stdin,
                    submit_delay_ms,
                    stall_timeout_s,
                } => {
                    let text = if stdin {
                        use tokio::io::AsyncReadExt;
                        let mut buf = String::new();
                        tokio::io::stdin()
                            .read_to_string(&mut buf)
                            .await
                            .map_err(|e| CliError::Io(e.to_string()))?;
                        buf
                    } else {
                        text.ok_or_else(|| {
                            CliError::Usage("--text or --stdin required".to_string())
                        })?
                    };
                    let mut p = json!({
                        "pane_id": id,
                        "text": text,
                    });
                    if let Some(d) = submit_delay_ms {
                        p["submit_delay_ms"] = json!(d);
                    }
                    if let Some(t) = stall_timeout_s {
                        p["stall_timeout_s"] = json!(t);
                    }
                    let r = c.call("pane.submit", p).await?;
                    let outcome = r["outcome"].as_str().unwrap_or("?");
                    let seq = r["transition_seq"].as_u64().unwrap_or(0);
                    let lc = r["lifecycle"].as_str().unwrap_or("?");
                    emit(
                        json,
                        &r,
                        format!("submitted: outcome={outcome} seq={seq} lifecycle={lc}"),
                    );
                    Ok(())
                }
                PaneOp::Split {
                    id,
                    direction,
                    cwd,
                    cmd,
                } => {
                    let mut p = json!({"pane_id": id, "direction": direction});
                    if let Some(cwd) = cwd {
                        p["cwd"] = json!(cwd);
                    }
                    if !cmd.is_empty() {
                        p["argv"] = json!(cmd);
                    }
                    let r = c.call("pane.split", p).await?;
                    integration_notice(&r, json);
                    let nid = r["pane"]["id"].as_str().unwrap_or("?").to_string();
                    emit(json, &r, format!("pane {nid}"));
                    Ok(())
                }
                PaneOp::Get { id } => {
                    let r = c.call("pane.get", json!({"pane_id": id})).await?;
                    let p = &r["pane"];
                    let mut human = format!(
                        "{} {} live={:?} lifecycle={} attention={}",
                        p["id"].as_str().unwrap_or("?"),
                        p["title"].as_str().unwrap_or("?"),
                        p["live"].to_string(),
                        p["lifecycle"].as_str().unwrap_or("?"),
                        p["attention"].as_str().unwrap_or("?"),
                    );
                    // Structured decision requests print as data (directive 2):
                    // prompt plus one line per option, never prose buttons.
                    if let Some(d) = p.get("pending_decision") {
                        human.push_str(&format!(
                            "\ndecision {}: {}",
                            d["id"].as_str().unwrap_or("?"),
                            d["prompt"].as_str().unwrap_or("?"),
                        ));
                        for o in d["options"].as_array().cloned().unwrap_or_default() {
                            human.push_str(&format!(
                                "\n  {} — {}",
                                o["id"].as_str().unwrap_or("?"),
                                o["label"].as_str().unwrap_or("?"),
                            ));
                        }
                        if !d["answerable"].as_bool().unwrap_or(true) {
                            human.push_str("\n  (read-only: answer in the terminal)");
                        }
                    }
                    emit(json, &r, human);
                    Ok(())
                }
                PaneOp::Input { id, data, stdin } => {
                    let bytes = if stdin {
                        use tokio::io::AsyncReadExt;
                        let mut buf = Vec::new();
                        tokio::io::stdin()
                            .read_to_end(&mut buf)
                            .await
                            .map_err(|e| CliError::Io(e.to_string()))?;
                        buf
                    } else {
                        data.unwrap_or_default().into_bytes()
                    };
                    let r = c
                        .call(
                            "pane.input",
                            json!({
                                "pane_id": id,
                                "data_b64": base64::engine::general_purpose::STANDARD.encode(&bytes),
                            }),
                        )
                        .await?;
                    let n = r["written"].as_u64().unwrap_or(0);
                    emit(json, &r, format!("wrote {n} bytes"));
                    Ok(())
                }
                PaneOp::Resize { id, cols, rows } => {
                    let r = c
                        .call(
                            "pane.resize",
                            json!({"pane_id": id, "cols": cols, "rows": rows}),
                        )
                        .await?;
                    emit(json, &r, format!("resized {id} to {cols}x{rows}"));
                    Ok(())
                }
                PaneOp::Signal { id, signal, group } => {
                    let r = c
                        .call(
                            "pane.signal",
                            json!({"pane_id": id, "signal": signal, "group": group}),
                        )
                        .await?;
                    emit(json, &r, format!("sent {signal} to {id}"));
                    Ok(())
                }
                PaneOp::Read {
                    id,
                    mode,
                    lines,
                    after_seq,
                    raw,
                } => {
                    let r = c
                        .call("pane.read", json!({"pane_id": id, "mode": mode, "lines": lines, "after_seq": after_seq, "strip_ansi": !raw}))
                        .await?;
                    if json {
                        println!("{}", serde_json::to_string(&r).unwrap());
                    } else {
                        println!("{}", r["text"].as_str().unwrap_or(""));
                    }
                    Ok(())
                }
                PaneOp::Close { id } => {
                    let r = c.call("pane.close", json!({"pane_id": id})).await?;
                    emit(json, &r, format!("closed {id}"));
                    Ok(())
                }
                PaneOp::MarkSeen { id } => {
                    let r = c.call("pane.mark_seen", json!({"pane_id": id})).await?;
                    emit(json, &r, format!("marked seen {id}"));
                    Ok(())
                }
                PaneOp::Resume { id } => {
                    let r = c.call("pane.resume", json!({"pane_id": id})).await?;
                    integration_notice(&r, json);
                    let pid = r["pane"]["id"].as_str().unwrap_or("?").to_string();
                    emit(json, &r, format!("resumed {pid}"));
                    Ok(())
                }
            }
        }
    }
}

fn integration_notice(result: &Value, json: bool) {
    if !json {
        if let Some(notice) = result["integration"]["notice"].as_str() {
            eprintln!("{notice}");
        }
    }
}

async fn task_cmd(socket: PathBuf, json: bool, op: TaskOp) -> Result<(), CliError> {
    let mut c = Client::connect(&socket).await?;
    match op {
        TaskOp::Start {
            repo,
            objective,
            objective_file,
            constraints,
            output_format,
            acceptance_criteria,
            agent,
            label,
            parent_pane,
            context,
            client_request_id,
            base_ref,
            fetch_first,
            branch,
            path,
            ready_timeout_s,
            stall_timeout_s,
            cmd,
        } => {
            let obj_text = if let Some(path) = objective_file {
                std::fs::read_to_string(&path)
                    .map_err(|e| CliError::Io(format!("failed to read objective file: {e}")))?
            } else if let Some(text) = objective {
                text
            } else {
                return Err(CliError::Usage(
                    "--objective or --objective-file required".to_string(),
                ));
            };

            let mut contract = json!({
                "objective": obj_text,
            });
            if let Some(con) = constraints {
                contract["constraints"] = json!(con);
            }
            if let Some(fmt) = output_format {
                contract["output_format"] = json!(fmt);
            }
            if let Some(crit) = acceptance_criteria {
                contract["acceptance_criteria"] = json!(crit);
            }

            // The server resolves paths against its own cwd, not ours.
            let abs = |path: &str| {
                std::path::absolute(path)
                    .map_err(|e| CliError::Usage(format!("cannot resolve path '{path}': {e}")))
            };
            let mut p = json!({
                "repo": abs(&repo)?,
                "contract": contract,
            });
            if let Some(a) = agent {
                p["agent"] = json!(a);
            }
            if let Some(l) = label {
                p["label"] = json!(l);
            }
            if let Some(pp) = parent_pane {
                p["parent_pane_id"] = json!(pp);
            }
            if let Some(ctx) = context {
                p["context_id"] = json!(ctx);
            }
            if let Some(id) = client_request_id {
                p["client_request_id"] = json!(id);
            }
            if let Some(br) = base_ref {
                p["base_ref"] = json!(br);
            }
            if fetch_first {
                p["fetch_first"] = json!(true);
            }
            if let Some(b) = branch {
                p["branch"] = json!(b);
            }
            if let Some(pa) = path {
                p["path"] = json!(abs(&pa)?);
            }
            if let Some(rt) = ready_timeout_s {
                p["ready_timeout_s"] = json!(rt);
            }
            if let Some(st) = stall_timeout_s {
                p["stall_timeout_s"] = json!(st);
            }
            if !cmd.is_empty() {
                p["argv"] = json!(cmd);
            }

            let r = c.call("task.start", p).await?;
            let task_id = r["task"]["id"].as_str().unwrap_or("?").to_string();
            let pane_id = r["pane"]["id"].as_str().unwrap_or("?").to_string();
            let state = r["task"]["state"].as_str().unwrap_or("?").to_string();
            emit(
                json,
                &r,
                format!("task {task_id} pane {pane_id} (state={state})"),
            );
            Ok(())
        }
        TaskOp::Get { id } => {
            let r = c.call("task.get", json!({"task_id": id})).await?;
            let t = &r["task"];
            let human = format!(
                "{} state={} repo={} branch={}",
                t["id"].as_str().unwrap_or("?"),
                t["state"].as_str().unwrap_or("?"),
                t["source_repo"].as_str().unwrap_or("?"),
                t["branch"].as_str().unwrap_or("?"),
            );
            emit(json, &r, human);
            Ok(())
        }
        TaskOp::List {
            context,
            state,
            limit,
        } => {
            let mut p = json!({});
            if let Some(ctx) = context {
                p["context_id"] = json!(ctx);
            }
            if let Some(st) = state {
                p["state"] = json!(st);
            }
            if let Some(l) = limit {
                p["limit"] = json!(l);
            }
            let r = c.call("task.list", p).await?;
            let mut human = String::new();
            let tasks = r["tasks"].as_array().cloned().unwrap_or_default();
            if tasks.is_empty() {
                human.push_str("(no tasks)\n");
            }
            for t in &tasks {
                human.push_str(&format!(
                    "{} state={} label={} context={}\n",
                    t["id"].as_str().unwrap_or("?"),
                    t["state"].as_str().unwrap_or("?"),
                    t["label"].as_str().unwrap_or("?"),
                    t["context_id"].as_str().unwrap_or("?"),
                ));
            }
            emit(json, &r, human.trim_end().to_string());
            Ok(())
        }
        TaskOp::Wait {
            id,
            id_flag,
            context,
            until,
            timeout,
        } => {
            let target_id = id.or(id_flag);
            let mut p = json!({
                "until": until,
            });
            if let Some(tid) = target_id {
                p["task_id"] = json!(tid);
            }
            if let Some(ctx) = context {
                p["context_id"] = json!(ctx);
            }
            if let Some(to) = timeout {
                p["timeout_s"] = json!(to);
            }
            let r = c.call("task.wait", p).await?;
            let count = r["tasks"].as_array().map(|a| a.len()).unwrap_or(0);
            emit(json, &r, format!("satisfied: {count} tasks"));
            Ok(())
        }
        TaskOp::Diff { id } => {
            let r = c.call("task.diff", json!({ "task_id": id })).await?;
            let mut human = String::new();
            for f in r["files"].as_array().cloned().unwrap_or_default() {
                let path = f["path"].as_str().unwrap_or("?");
                if f["untracked"].as_bool().unwrap_or(false) {
                    human.push_str(&format!("?? {path}\n"));
                } else if f["binary"].as_bool().unwrap_or(false) {
                    human.push_str(&format!("bin {path}\n"));
                } else {
                    human.push_str(&format!(
                        "+{} -{} {path}\n",
                        f["added"].as_u64().unwrap_or(0),
                        f["removed"].as_u64().unwrap_or(0),
                    ));
                }
            }
            for g in r["dirs"].as_array().cloned().unwrap_or_default() {
                human.push_str(&format!(
                    "{}/ +{} -{}\n",
                    g["dir"].as_str().unwrap_or("?"),
                    g["added"].as_u64().unwrap_or(0),
                    g["removed"].as_u64().unwrap_or(0),
                ));
            }
            human.push_str(&format!(
                "total +{} -{}\n",
                r["added"].as_u64().unwrap_or(0),
                r["removed"].as_u64().unwrap_or(0),
            ));
            emit(json, &r, human.trim_end().to_string());
            Ok(())
        }
        TaskOp::FileDiff { id, path } => {
            let r = c
                .call("task.file_diff", json!({ "task_id": id, "path": path }))
                .await?;
            let mut human = String::new();
            if r["untracked"].as_bool().unwrap_or(false) {
                human.push_str(&format!(
                    "untracked {}\n",
                    r["path"].as_str().unwrap_or("?")
                ));
            } else {
                human.push_str(&format!("diff {}\n", r["path"].as_str().unwrap_or("?")));
            }
            if let Some(content) = r.get("content") {
                if let Some(kind) = content.get("kind").and_then(|k| k.as_str()) {
                    match kind {
                        "text" => {
                            if let Some(hunks) = content.get("hunks").and_then(|h| h.as_array()) {
                                for hunk in hunks {
                                    if let Some(heading) =
                                        hunk.get("heading").and_then(|h| h.as_str())
                                    {
                                        human.push_str(&format!("{heading}\n"));
                                    }
                                    if let Some(lines) =
                                        hunk.get("lines").and_then(|l| l.as_array())
                                    {
                                        for l in lines {
                                            let text = l
                                                .get("text")
                                                .and_then(|t| t.as_str())
                                                .unwrap_or("");
                                            let prefix =
                                                match l.get("kind").and_then(|k| k.as_str()) {
                                                    Some("added") => "+",
                                                    Some("removed") => "-",
                                                    Some("no_newline") => "\\ ",
                                                    _ => " ",
                                                };
                                            human.push_str(&format!("{prefix}{text}\n"));
                                        }
                                    }
                                }
                            }
                        }
                        "binary" => human.push_str("(binary file)\n"),
                        "unchanged" => human.push_str("(unchanged)\n"),
                        _ => {}
                    }
                }
            }
            emit(json, &r, human.trim_end().to_string());
            Ok(())
        }
        TaskOp::Finish {
            id,
            merge,
            discard,
            target,
            delete_branch,
            keep_branch,
            ignore_dirty,
        } => {
            let mode = if merge {
                "merge"
            } else if discard {
                "discard"
            } else {
                return Err(CliError::Usage("must specify --merge or --discard".into()));
            };
            let mut p = json!({
                "task_id": id,
                "mode": mode,
            });
            if let Some(t) = target {
                p["target_ref"] = json!(t);
            }
            if delete_branch || keep_branch {
                p["delete_branch"] = json!(delete_branch);
            }
            if ignore_dirty {
                p["ignore_dirty"] = json!(true);
            }
            let r = c.call("task.finish", p).await?;
            let tid = r["task"]["id"].as_str().unwrap_or("?").to_string();
            let outcome = r["task"]["disposition"]["outcome"].as_str().unwrap_or("?");
            let mut summary = format!("finished task {tid}: {outcome}");
            if let Some(merge) = r.get("merge") {
                if let (Some(target), Some(sha)) = (
                    merge.get("target").and_then(|t| t.as_str()),
                    merge.get("sha").and_then(|s| s.as_str()),
                ) {
                    summary.push_str(&format!(" into {target} ({sha})"));
                }
            }
            if let Some(err) = r.get("cleanup_error").and_then(|e| e.as_str()) {
                summary.push_str(&format!(" (cleanup warning: {err})"));
            }
            emit(json, &r, summary);
            Ok(())
        }
        TaskOp::Pr {
            id,
            title,
            body,
            draft,
        } => {
            let mut p = json!({
                "task_id": id,
            });
            if let Some(t) = title {
                p["title"] = json!(t);
            }
            if let Some(b) = body {
                p["body"] = json!(b);
            }
            if draft {
                p["draft"] = json!(true);
            }
            let r = c.call("task.pr_open", p).await?;
            let tid = r["task"]["id"].as_str().unwrap_or("?").to_string();
            let pr = &r["task"]["pr"];
            let url = pr["url"].as_str().unwrap_or("?");
            let num = pr["number"].as_u64().unwrap_or(0);
            emit(
                json,
                &r,
                format!("opened pull request #{num} for task {tid}: {url}"),
            );
            Ok(())
        }
        TaskOp::PrRefresh { id } => {
            let mut p = json!({});
            if let Some(tid) = id {
                p["task_id"] = json!(tid);
            }
            let r = c.call("task.pr_refresh", p).await?;
            let tasks = r["tasks"].as_array();
            let count = tasks.map_or(0, |t| t.len());
            let mut summary = format!("refreshed {count} pull request(s)");
            if let Some(tasks) = tasks {
                for t in tasks {
                    let tid = t["id"].as_str().unwrap_or("?");
                    if let Some(pr) = t.get("pr") {
                        let num = pr["number"].as_u64().unwrap_or(0);
                        let state = pr["state"].as_str().unwrap_or("?");
                        let checks = pr["checks"].as_str().unwrap_or("none");
                        let review = pr["review"].as_str().unwrap_or("none");
                        summary.push_str(&format!(
                            "\n  task {tid}: #{num} [{state}] (checks: {checks}, review: {review})"
                        ));
                    }
                }
            }
            emit(json, &r, summary);
            Ok(())
        }
        TaskOp::Cancel { id } => {
            let r = c.call("task.cancel", json!({"task_id": id})).await?;
            let tid = r["task"]["id"].as_str().unwrap_or("?").to_string();
            emit(json, &r, format!("canceled task {tid}"));
            Ok(())
        }
    }
}

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

    #[test]
    fn null_baseline_is_a_usage_error_not_a_current_state_wait() {
        let err = parse_wait_baseline("null").unwrap_err();
        assert!(matches!(err, CliError::Usage(_)));
    }

    #[test]
    fn malformed_baseline_is_a_usage_error() {
        let err = parse_wait_baseline("{not json").unwrap_err();
        assert!(matches!(err, CliError::Usage(_)));
    }

    #[test]
    fn object_baseline_passes_through() {
        let baseline = parse_wait_baseline("{\"pane_id\":\"p\"}").unwrap();
        assert_eq!(baseline["pane_id"], "p");
    }

    #[test]
    fn test_task_pr_cli_parsing() {
        let args =
            Args::try_parse_from(["signaltty", "task", "pr", "task-123", "--draft"]).unwrap();
        match args.cmd {
            Command::Task {
                op:
                    TaskOp::Pr {
                        id,
                        draft,
                        title,
                        body,
                    },
            } => {
                assert_eq!(id, "task-123");
                assert!(draft);
                assert!(title.is_none());
                assert!(body.is_none());
            }
            _ => panic!("wrong command parsed"),
        }

        let args2 = Args::try_parse_from(["signaltty", "task", "pr-refresh", "task-123"]).unwrap();
        match args2.cmd {
            Command::Task {
                op: TaskOp::PrRefresh { id },
            } => {
                assert_eq!(id, Some("task-123".to_string()));
            }
            _ => panic!("wrong command parsed"),
        }

        let args3 = Args::try_parse_from(["signaltty", "task", "pr-refresh"]).unwrap();
        match args3.cmd {
            Command::Task {
                op: TaskOp::PrRefresh { id },
            } => {
                assert_eq!(id, None);
            }
            _ => panic!("wrong command parsed"),
        }
    }
}