#!/usr/bin/env node
import { spawnSync } from "node:child_process";
import { randomBytes as _randomBytes } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
import os from "node:os";
import pty from "node-pty";
import headless from "@xterm/headless";

process.stdout.on("error", (error) => {
  if (error?.code === "EPIPE") process.exit(0);
});

function randomHex(bytes) {
  return _randomBytes(bytes).toString("hex");
}

function mcBaseUrl() {
  return process.env.MC_URL || "http://localhost:19100";
}

function mcToken() {
  if (process.env.MC_TOKEN) return process.env.MC_TOKEN;
  // Auto-load from MC .env file when not set in environment (e.g. SSH sessions)
  const envFile = "/home/admin/treehouse/mc/.env";
  try {
    const lines = fs.readFileSync(envFile, "utf8").split("\n");
    for (const line of lines) {
      const m = line.match(/^MC_TOKEN=(.+)$/);
      if (m) return m[1].trim();
    }
  } catch {}
  return undefined;
}

async function fetchApi({ method, urlPath, body, timeoutMs = 5000 }) {
  const url = new URL(urlPath, mcBaseUrl());
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
  try {
    const res = await fetch(url, {
      method,
      headers: {
        Authorization: `Bearer ${mcToken()}`,
        ...(body ? { "Content-Type": "application/json" } : {}),
      },
      body: body ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
    const text = await res.text();
    let parsed = null;
    try {
      parsed = text ? JSON.parse(text) : null;
    } catch {
      parsed = null;
    }
    if (!res.ok) return { ok: false, status: res.status, error: parsed?.error || text || `HTTP ${res.status}` };
    return { ok: true, status: res.status, data: parsed };
  } catch (e) {
    return { ok: false, status: 0, error: e?.name === "AbortError" ? "timeout" : e?.message || String(e) };
  } finally {
    clearTimeout(timeout);
  }
}

async function fetchJson(urlPath, options = {}) {
  return await fetchApi({ method: "GET", urlPath, ...options });
}

function die(message, code = 1) {
  process.stderr.write(`${message}\n`);
  process.exit(code);
}

function nowMs() {
  return Number(process.hrtime.bigint() / 1000000n);
}

function profEnabled() {
  return process.env.TREEHOUSE_PROFILE === "1";
}

function profLog(step, startMs) {
  if (!profEnabled()) return;
  const elapsed = nowMs() - startMs;
  process.stderr.write(`[profile] ${step}: ${elapsed}ms\n`);
}

function profLogDelta(step, fromMs, toMs) {
  if (!profEnabled()) return;
  if (!fromMs || !toMs) return;
  process.stderr.write(`[profile] ${step}: ${toMs - fromMs}ms\n`);
}

function formatUnixSecondsToLocal(secs) {
  const n = Number(secs);
  if (!Number.isFinite(n)) return null;
  // Heuristic: avoid converting very small numbers (e.g. utilization ratios).
  if (n < 1000000000) return null; // before ~2001-09-09
  const d = new Date(n * 1000);
  if (Number.isNaN(d.getTime())) return null;
  // Local time (server is Europe/London); include TZ abbreviation if available.
  const isoLocal = d.toLocaleString("en-GB", {
    year: "numeric",
    month: "short",
    day: "2-digit",
    hour: "2-digit",
    minute: "2-digit",
    second: "2-digit",
    hour12: false,
    timeZoneName: "short",
  });
  return isoLocal;
}

function run(command, args, options = {}) {
  const result = spawnSync(command, args, {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
    ...options,
  });
  return {
    ok: result.status === 0,
    status: result.status ?? 1,
    stdout: result.stdout ?? "",
    stderr: result.stderr ?? "",
  };
}

function runInherit(command, args, options = {}) {
  const result = spawnSync(command, args, {
    encoding: "utf8",
    stdio: "inherit",
    ...options,
  });
  return {
    ok: result.status === 0,
    status: result.status ?? 1,
  };
}

function runInheritEnv(command, args, env) {
  return runInherit(command, args, { env: { ...process.env, ...env } });
}

function parseArgs(argv) {
  const positional = [];
  const flags = new Set();
  const options = new Map();

  for (let i = 0; i < argv.length; i++) {
    const part = argv[i];
    if (!part.startsWith("--")) {
      positional.push(part);
      continue;
    }

    const eq = part.indexOf("=");
    if (eq !== -1) {
      const key = part.slice(0, eq);
      const value = part.slice(eq + 1);
      if (key === "--set") {
        const existing = options.get("--set") || [];
        existing.push(value);
        options.set("--set", existing);
      } else {
        options.set(key, value);
      }
      continue;
    }

    const next = argv[i + 1];
    if (next && !next.startsWith("--")) {
      if (part === "--set") {
        const existing = options.get("--set") || [];
        existing.push(next);
        options.set("--set", existing);
      } else {
        options.set(part, next);
      }
      i++;
      continue;
    }

    flags.add(part);
  }

  return { positional, flags, options };
}

function usage() {
  return `treehouse - Treehouse admin CLI

Usage:
  treehouse --help
  treehouse doctor [--json]
  treehouse ui screenshot [--path /credentials] [--out /tmp/mc-ui.png] [--full-page]
  treehouse defaults get [--json]
  treehouse defaults set [--provider <claude|codex|pi>] [--model <model>] [--pi-backend <provider-id>] [--json]
  treehouse agents ps [--tenant <id>] [--json]
  treehouse agents kill [--id <containerId>] [--tenant <id>] [--all] [--yes]
  treehouse agents logs --id <containerId> [--tail N]
  treehouse chats list [--tenant <id>] [--limit N] [--json]
  treehouse messages tail [--tenant <id> | --chat_jid <jid> | --group <tenant/group>] [--limit N] [--json]
  treehouse session tail --group <tenant/group> [--limit N]
  treehouse groups list [--tenant <id>] [--json]
  treehouse groups show --folder <tenant/group> [--json]
  treehouse groups dangling [--tenant <id>] [--delete] [--json]
  treehouse groups hooks --folder <tenant/group> [--json]
  treehouse groups hooks enable --folder <tenant/group>
  treehouse groups hooks disable --folder <tenant/group>
  treehouse groups hooks rotate --folder <tenant/group>
  treehouse groups hooks-list [--tenant <id>] [--json]
  treehouse groups hooks-find --token <token> [--json]
  treehouse groups hooks-enable-all [--yes]
  treehouse bots list [--json]
  treehouse bots send --tenant <id> --channel <jid-or-id> --message <text>
  treehouse discord members [--limit N] [--json]
  treehouse discord welcome-check [--bot-tenant <id>] [--days N] [--json]
  treehouse tenants list
  treehouse tenants show --tenant <id> [--json]
  treehouse tenants create --id <id> --email <email> [--set k=v ...] [--json]
  treehouse tenants update --tenant <id> [--set k=v ...] [--json]
  treehouse tenants clear-runtime-overrides [--tenant <id>] [--no-hup] [--yes] [--json]
  treehouse tenants remove --tenant <id> [--yes]
  treehouse tenants usage [--date YYYY-MM-DD] [--json]
  treehouse tenants ports [--json]
  treehouse tenants tailscale-proxy --tenant <id> [--enable | --disable] [--yes]
  treehouse apps list [--tenant <id>] [--json]
  treehouse apps ps [--json]
  treehouse apps deploy --tenant <id> --app <app> [--force-rebuild]
  treehouse apps remove --tenant <id> --app <app> [--yes]
  treehouse apps status --tenant <id> [--app <app>]
  treehouse apps ports --tenant <id>
  treehouse apps reconcile --tenant <id> [--app <app>] [--fix] [--json]
  treehouse apps smoke --tenant <id> [--app <app>] [--yes]
  treehouse apps logs --tenant <id> --name <container> [--tail N] [--follow]
  treehouse apps auth set --tenant <id> --app <app> --user <user> --password <pass>
  treehouse apps auth remove --tenant <id> --app <app> [--yes]
  treehouse apps restart --tenant <id> --app <app> [--yes]
  treehouse apps images [--tenant <id>] [--all] [--json]
  treehouse apps image-prune [--tenant <id>] [--all] [--yes]
  treehouse apps image-rm --tenant <id> --image <image> [--yes]
  treehouse apps clean-stuck --tenant <id> [--app <app>] [--yes]
  treehouse runtime ping [--tenant <id>] [--json]
  treehouse runtime df [--tenant <id>]
  treehouse volumes list [--json]
  treehouse volumes resize --tenant <id> --size <MB> [--yes]
  treehouse caddy lint
  treehouse caddy reload
  treehouse creds list [--json]
  treehouse creds show --id <credId> [--json]
  treehouse creds create --id <credId> --type <oauth|apikey> [--set k=v ...] [--json]
  treehouse creds update --id <credId> [--set k=v ...] [--json]
  treehouse creds login --id <credId> [--set k=v ...] [--json]
  treehouse creds import-auth --id <credId>
  treehouse creds import-pi-auth --id <credId>
  treehouse creds pi-login --id <credId>
  treehouse creds logout --id <credId>
  treehouse creds resume --id <credId>
  treehouse creds login-status --id <credId> [--raw]
  treehouse creds usage --id <credId> [--raw]
  treehouse creds callback --id <credId> --code <code>
  treehouse creds remove --id <credId> [--yes]
  treehouse creds workspace list <tenant>
  treehouse creds workspace set <tenant> <name> <value> [--description <desc>]
  treehouse creds workspace remove <tenant> <name> [--yes]
  treehouse nanoclaw status
  treehouse nanoclaw logs [--lines N]
  treehouse nanoclaw hup
  treehouse nanoclaw restart [--yes]
  treehouse backups status
  treehouse backups run [--yes]
  treehouse mc restart [--build-ui] [--detach] [--yes]
  treehouse tasks list [--tenant <id>] [--json]
  treehouse tasks show --id <taskId> [--json]
  treehouse tasks logs --id <taskId> [--limit N] [--json]
  treehouse tasks watch [--tenant <id>] [--interval <seconds>]
  treehouse tasks pause --id <taskId> [--yes]
  treehouse tasks resume --id <taskId> [--yes]
  treehouse tasks cancel --id <taskId> [--yes]
  treehouse domains list [--tenant <id>] [--json]
  treehouse domains show --id <domainId> [--json]
  treehouse domains add --tenant <id> --domain <domain> [--target dashboard|<subdomain>]
  treehouse domains activate --id <domainId> [--yes]
  treehouse domains remove --id <domainId> [--yes]
  treehouse mctasks list [--status <status>] [--json]
  treehouse mctasks show --id <taskId> [--json]
  treehouse mctasks create --title <title> [--description <desc>] [--status <status>] [--priority <priority>] [--assigned-to <tenant>]
  treehouse mctasks update --id <taskId> [--title <title>] [--description <desc>] [--status <status>] [--priority <priority>] [--assigned-to <tenant>]
  treehouse mctasks comment --id <taskId> --text <text>
  treehouse mctasks delete --id <taskId> [--yes]
  treehouse announce <message>
`;
}

function tenantsNixPath() {
  return "/home/admin/treehouse/nixos/tenants.nix";
}

function nanoclawDbPath() {
  return process.env.TREEHOUSE_DB_PATH || "/var/lib/nanoclaw/store/messages.db";
}

function sqliteJson(dbPath, sql) {
  const res = run("sudo", ["sqlite3", "-json", dbPath, sql]);
  if (!res.ok) return { ok: false, rows: [], error: res.stderr.trim() || `sqlite3 failed (${res.status})` };
  try {
    const parsed = JSON.parse(res.stdout || "[]");
    if (!Array.isArray(parsed)) return { ok: false, rows: [], error: "sqlite3 -json returned non-array JSON" };
    return { ok: true, rows: parsed };
  } catch (e) {
    return { ok: false, rows: [], error: `failed to parse sqlite json: ${e?.message || e}` };
  }
}

function sqliteExec(dbPath, sql) {
  const res = run("sudo", ["sqlite3", dbPath, sql]);
  if (!res.ok) return { ok: false, error: res.stderr.trim() || `sqlite3 failed (${res.status})` };
  return { ok: true };
}

function sqliteQuote(value) {
  return `'${String(value).replace(/'/g, "''")}'`;
}

function listTenantsFromTenantsNix() {
  const file = tenantsNixPath();
  const text = fs.readFileSync(file, "utf8");
  const tenants = [];

  // Very small/fragile parser: matches lines like:
  //   gaia = { uid = 2002; maxMemoryMB = 4096; ... };
  const re = /^\s*([a-zA-Z0-9-]+)\s*=\s*\{\s*uid\s*=\s*([0-9]+)\s*;/gm;
  let match;
  while ((match = re.exec(text))) {
    tenants.push({ tenant: match[1], uid: Number(match[2]) });
  }

  // Keep stable output.
  tenants.sort((a, b) => a.uid - b.uid);
  return tenants;
}

function containerdSocket(uid) {
  return `/run/user/${uid}/containerd/containerd.sock`;
}

function nerdctlPsAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);

  // nerdctl's fatal "stat .: permission denied" happens if cwd is not readable;
  // always run from tenant home via a login shell.
  const lineFormat = "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}";
  const cmd = [
    "-u",
    tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `sock='${sock}'`,
      `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl ps --format '${lineFormat}'`,
    ].join(" && "),
  ];

  const res = run("sudo", cmd);
  if (!res.ok) {
    return { ok: false, rows: [], error: res.stderr.trim() || `nerdctl failed (${res.status})` };
  }

  if (res.stdout.trim() === "__NOSOCK__") {
    return { ok: true, rows: [], note: `no socket at ${sock}` };
  }

  const rows = res.stdout
    .split("\n")
    .map((s) => s.trimEnd())
    .filter(Boolean)
    .map((line) => {
      const [name = "", image = "", status = "", ports = ""] = line.split("\t");
      return { name, image, status, ports };
    });

  return { ok: true, rows };
}

function nerdctlInfoAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);
  const cmd = [
    "-u",
    tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `sock='${sock}'`,
      `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl info`,
    ].join(" && "),
  ];

  const res = run("sudo", cmd);
  if (!res.ok) return { ok: false, output: "", error: res.stderr.trim() || `nerdctl failed (${res.status})` };
  if (res.stdout.trim() === "__NOSOCK__") return { ok: true, output: "", note: `no socket at ${sock}` };
  return { ok: true, output: res.stdout.trimEnd() };
}

function nerdctlSystemDfAsTenant({ tenant, uid }) {
  const sock = containerdSocket(uid);
  const cmd = [
    "-u",
    tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `sock='${sock}'`,
      `if [ ! -S \"$sock\" ]; then echo '__NOSOCK__' ; exit 0; fi`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl system info`,
      `echo`,
      `echo '--- images (top 50) ---'`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl images | head -n 51`,
      `echo`,
      `echo '--- containers ---'`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl ps -a`,
    ].join(" && "),
  ];

  const res = run("sudo", cmd);
  if (!res.ok) return { ok: false, output: "", error: res.stderr.trim() || `nerdctl failed (${res.status})` };
  if (res.stdout.trim() === "__NOSOCK__") return { ok: true, output: "", note: `no socket at ${sock}` };
  return { ok: true, output: res.stdout.trimEnd() };
}

function nerdctlImagesAsTenant({ tenant, uid, all = false }) {
  const sock = containerdSocket(uid);
  const allFlag = all ? " -a" : "";
  const lineFormat = "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.Size}}\t{{.CreatedSince}}";
  const cmd = [
    "-u",
    tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `sock='${sock}'`,
      `if [ ! -S "$sock" ]; then echo '__NOSOCK__' ; exit 0; fi`,
      `XDG_RUNTIME_DIR=/run/user/${uid} CONTAINERD_ADDRESS=$sock nerdctl images${allFlag} --format '${lineFormat}'`,
    ].join(" && "),
  ];

  const res = run("sudo", cmd);
  if (!res.ok) {
    return { ok: false, rows: [], error: res.stderr.trim() || `nerdctl failed (${res.status})` };
  }
  if (res.stdout.trim() === "__NOSOCK__") {
    return { ok: true, rows: [], note: `no socket at ${sock}` };
  }

  const rows = res.stdout
    .split("\n")
    .map((s) => s.trimEnd())
    .filter(Boolean)
    .map((line) => {
      const [repository = "", tag = "", id = "", size = "", created = ""] = line.split("\t");
      return { repository, tag, id, size, created };
    });

  return { ok: true, rows };
}

function listAppsOnDisk({ tenant }) {
  const appsDir = path.join("/home", tenant, "workspace", "apps");
  try {
    const entries = fs.readdirSync(appsDir, { withFileTypes: true });
    return entries
      .filter((e) => e.isDirectory())
      .map((e) => e.name)
      .sort((a, b) => a.localeCompare(b));
  } catch {
    return [];
  }
}

function systemctlUserCmd({ tenant, uid, systemctlArgs, inherit = false }) {
  const base = [
    "-u",
    tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `export XDG_RUNTIME_DIR=/run/user/${uid}`,
      `export DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${uid}/bus`,
      `systemctl --user ${systemctlArgs.join(" ")}`,
    ].join(" && "),
  ];

  if (inherit) return runInherit("sudo", base);
  return run("sudo", base);
}

async function confirmOrDie({ prompt, yesFlag }) {
  if (yesFlag) return;
  if (!process.stdin.isTTY) die(`${prompt} (pass --yes to skip prompt)`);

  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  const answer = await new Promise((resolve) => rl.question(`${prompt} [y/N] `, resolve));
  rl.close();
  const normalized = String(answer || "").trim().toLowerCase();
  if (normalized !== "y" && normalized !== "yes") die("Aborted.");
}

function parseSetOptions(options) {
  const sets = options.get("--set") || [];
  const out = {};

  for (const kv of sets) {
    const idx = kv.indexOf("=");
    if (idx === -1) die(`Invalid --set (expected k=v): ${kv}`);
    const key = kv.slice(0, idx).trim();
    const raw = kv.slice(idx + 1).trim();
    if (!key) die(`Invalid --set key: ${kv}`);

    let value = raw;
    if (raw === "null") value = null;
    else if (raw === "true") value = true;
    else if (raw === "false") value = false;
    else if (/^-?[0-9]+$/.test(raw)) value = Number(raw);
    else if ((raw.startsWith("{") && raw.endsWith("}")) || (raw.startsWith("[") && raw.endsWith("]"))) {
      try {
        value = JSON.parse(raw);
      } catch {
        value = raw;
      }
    }

    out[key] = value;
  }

  return out;
}

async function cmdTenantsShow({ tenantId, json }) {
  if (!tenantId) die("Missing --tenant <id>");
  const res = await fetchJson(`/api/tenants/${encodeURIComponent(tenantId)}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }

  const t = res.data;
  const rows = [
    ["id", t.id],
    ["name", t.name],
    ["email", t.email],
    ["provider", t.provider],
    ["status", t.status],
    ["model", t.model],
    ["is_admin", t.is_admin],
    ["max_concurrent", t.max_concurrent],
    ["max_memory_mb", t.max_memory_mb],
    ["cpu_quota", t.cpu_quota],
    ["cpu_weight", t.cpu_weight],
    ["max_disk_mb", t.max_disk_mb],
    ["port_range_start", t.port_range_start],
    ["port_range_size", t.port_range_size],
  ].map(([key, value]) => ({ key, value: value ?? "" }));

  printTable(rows, [
    { key: "key", header: "KEY" },
    { key: "value", header: "VALUE" },
  ]);
}

async function cmdTenantsCreate({ id, email, setValues, json }) {
  if (!id) die("Missing --id <id>");
  if (!email) die("Missing --email <email>");
  const body = { id, email, ...setValues };
  const res = await fetchApi({ method: "POST", urlPath: "/api/tenants", body });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`${res.data.message || "Tenant created"}\n`);
}

async function cmdTenantsUpdate({ tenantId, setValues, json }) {
  if (!tenantId) die("Missing --tenant <id>");
  const res = await fetchApi({
    method: "PUT",
    urlPath: `/api/tenants/${encodeURIComponent(tenantId)}`,
    body: setValues,
  });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`${res.data.message || "Tenant updated"}\n`);
}

async function cmdTenantsClearRuntimeOverrides({ tenantId, yes, noHup, json }) {
  const dbPath = nanoclawDbPath();

  const where = tenantId ? `WHERE id = ${sqliteQuote(tenantId)}` : "";
  const rowsRes = sqliteJson(
    dbPath,
    `SELECT id, provider, model FROM tenants ${where} ORDER BY id;`,
  );
  if (!rowsRes.ok) die(rowsRes.error);
  const rows = rowsRes.rows;
  if (tenantId && rows.length === 0) die(`Tenant not found: ${tenantId}`);

  const affected = rows.filter((t) => t.provider != null || t.model != null);
  if (affected.length === 0) {
    if (json) process.stdout.write(`${JSON.stringify({ ok: true, changed: 0 }, null, 2)}\n`);
    else process.stdout.write("No tenant provider/model overrides to clear.\n");
    return;
  }

  await confirmOrDie({
    prompt: `Clear provider/model overrides for ${affected.length} tenant(s)${tenantId ? "" : " (all tenants)"}?`,
    yesFlag: yes,
  });

  const updateWhere = tenantId ? `WHERE id = ${sqliteQuote(tenantId)}` : "";
  const updateRes = run("sudo", [
    "sqlite3",
    dbPath,
    `UPDATE tenants SET provider = NULL, model = NULL ${updateWhere}; SELECT changes();`,
  ]);
  if (!updateRes.ok) die(updateRes.stderr.trim() || `sqlite3 failed (${updateRes.status})`);
  const changed = Number(String(updateRes.stdout || "").trim().split("\n").pop());

  if (!noHup) {
    const hupRes = await fetchApi({ method: "POST", urlPath: "/api/nanoclaw/hup" });
    if (!hupRes.ok) die(hupRes.error);
  }

  if (json) {
    process.stdout.write(
      `${JSON.stringify(
        {
          ok: true,
          changed: Number.isFinite(changed) ? changed : affected.length,
          hup: !noHup,
          scope: tenantId ? { tenant: tenantId } : { all: true },
        },
        null,
        2,
      )}\n`,
    );
    return;
  }

  process.stdout.write(
    `Cleared provider/model overrides for ${Number.isFinite(changed) ? changed : affected.length} tenant(s).${noHup ? "" : " Sent SIGHUP to nanoclaw.service."}\n`,
  );
}

async function cmdTenantsRemove({ tenantId, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  await confirmOrDie({
    prompt: `Delete tenant '${tenantId}'? This deletes Discord resources and removes tenant data.`,
    yesFlag: yes,
  });
  const res = await fetchApi({ method: "DELETE", urlPath: `/api/tenants/${encodeURIComponent(tenantId)}`, body: {} });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Tenant deleted"}\n`);
}

async function cmdCredsList({ json }) {
  const res = await fetchJson("/api/credentials");
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  const rows = res.data.map((c) => {
    const expiresMs = typeof c.expires_at === "number" ? c.expires_at : c.expires_at ? Number(c.expires_at) : null;
    const expiresAtIso = expiresMs && Number.isFinite(expiresMs) ? new Date(expiresMs).toISOString() : "";
    return { ...c, expires_at: expiresAtIso || (c.expires_at ?? "") };
  });
  printTable(rows, [
    { key: "id", header: "ID" },
    { key: "provider", header: "PROVIDER" },
    { key: "type", header: "TYPE" },
    { key: "label", header: "LABEL" },
    { key: "account_id", header: "IDENTITY" },
    { key: "status", header: "STATUS" },
    { key: "expires_at", header: "EXPIRES_AT" },
    { key: "plan_type", header: "PLAN" },
    { key: "rate_limit_tier", header: "TIER" },
    { key: "last_used_at", header: "LAST_USED" },
    { key: "total_requests", header: "TOTAL_REQ" },
  ]);
}

async function cmdCredsShow({ credId, json }) {
  if (!credId) die("Missing --id <credId>");
  const res = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  const rows = Object.entries(res.data).map(([key, value]) => ({ key, value: value ?? "" }));
  printTable(rows, [
    { key: "key", header: "KEY" },
    { key: "value", header: "VALUE" },
  ]);
}

async function cmdCredsCreate({ credId, type, setValues, json }) {
  if (!credId) die("Missing --id <credId>");
  if (!type) die("Missing --type <oauth|apikey>");
  const body = { id: credId, type, ...setValues };
  const res = await fetchApi({ method: "POST", urlPath: "/api/credentials", body });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`${res.data.message || "Credential created"}\n`);
  if (res.data.authorize_url) process.stdout.write(`${res.data.authorize_url}\n`);
}

async function cmdCredsUpdate({ credId, setValues, json }) {
  if (!credId) die("Missing --id <credId>");
  const res = await fetchApi({
    method: "PATCH",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}`,
    body: setValues,
  });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`${res.data.message || "Credential updated"}\n`);
}

async function cmdCredsLogin({ credId, setValues, json }) {
  if (!credId) die("Missing --id <credId>");
  // Interactive relogin flow:
  // - If cred doesn't exist: create oauth cred (starts pending OAuth flow)
  // - If cred exists: start relogin to update tokens in-place (Claude OAuth); for Codex use refresh.
  const existing = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);

  // Not found: start normal login flow by creating a new credential with the requested id
  if (!existing.ok && existing.status === 404) {
    const body = { id: credId, type: "oauth", ...setValues };
    const created = await fetchApi({ method: "POST", urlPath: "/api/credentials", body });
    if (!created.ok) die(created.error);
    if (json) {
      process.stdout.write(`${JSON.stringify(created.data, null, 2)}\n`);
      return;
    }
    process.stdout.write(`${created.data.message || "Login started"}\n`);
    if (created.data.authorize_url) {
      process.stdout.write(`\nAuthorize URL:\n${created.data.authorize_url}\n`);
      if (process.stdin.isTTY) {
        const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
        const code = await new Promise((resolve) => rl.question("\nPaste callback code: ", resolve));
        rl.close();
        if (String(code).trim()) await cmdCredsCallback({ credId, code: String(code).trim() });
      } else {
        process.stdout.write(`\nThen:\n  treehouse creds callback --id ${credId} --code <code>\n`);
      }
    }
    return;
  }

  if (!existing.ok) die(existing.error);

  // Existing Claude cred: use PKCE relogin flow (show URL to visit, paste back code)
  if ((existing.data.provider || "claude") === "claude") {
    const relogin = await fetchApi({ method: "POST", urlPath: `/api/credentials/${encodeURIComponent(credId)}/relogin` });
    if (!relogin.ok) die(relogin.error);
    process.stdout.write(`${relogin.data.message || "Relogin started"}\n`);
    if (relogin.data.authorize_url) {
      process.stdout.write(`\nAuthorize URL:\n${relogin.data.authorize_url}\n`);
      if (process.stdin.isTTY) {
        const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
        const code = await new Promise((resolve) => rl.question("\nPaste callback code: ", resolve));
        rl.close();
        if (String(code).trim()) await cmdCredsCallback({ credId, code: String(code).trim() });
      } else {
        process.stdout.write(`\nThen:\n  treehouse creds callback --id ${credId} --code <code>\n`);
      }
    }
    return;
  }

  // Existing cred: if Codex, perform a device-auth login via codex CLI into an isolated CODEX_HOME,
  // then import the resulting auth.json into the credential record.
  if ((existing.data.provider || "claude") === "codex") {
    const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "treehouse-codex-"));
    try {
      process.stdout.write("Starting Codex device login...\n");
      const ok = runInheritEnv("codex", ["login", "--device-auth"], { CODEX_HOME: tmpDir }).ok;
      if (!ok) die("codex login failed");

      const authPath = path.join(tmpDir, "auth.json");
      if (!fs.existsSync(authPath)) die(`Codex auth.json not found at ${authPath}`);
      const authJson = fs.readFileSync(authPath, "utf8");

      const imported = await fetchApi({
        method: "POST",
        urlPath: `/api/credentials/${encodeURIComponent(credId)}/import-auth`,
        body: { auth_json: authJson },
      });
      if (!imported.ok) die(imported.error);
      process.stdout.write(`${imported.data.message || "Auth JSON imported"}\n`);
    } finally {
      try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
    }
    return;
  }

  // Claude OAuth relogin: start new OAuth flow and update tokens in-place via callback upsert.
  const relogin = await fetchApi({ method: "POST", urlPath: `/api/credentials/${encodeURIComponent(credId)}/relogin`, body: {} });
  if (!relogin.ok) die(relogin.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(relogin.data, null, 2)}\n`);
    return;
  }

  process.stdout.write(`${relogin.data.message || "Relogin started"}\n`);
  if (relogin.data.authorize_url) {
    process.stdout.write(`\nAuthorize URL:\n${relogin.data.authorize_url}\n`);
    if (process.stdin.isTTY) {
      const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
      const code = await new Promise((resolve) => rl.question("\nPaste callback code: ", resolve));
      rl.close();
      if (String(code).trim()) await cmdCredsCallback({ credId, code: String(code).trim() });
    } else {
      process.stdout.write(`\nThen:\n  treehouse creds callback --id ${credId} --code <code>\n`);
    }
  }
}

async function cmdCredsImportAuth({ credId }) {
  if (!credId) die("Missing --id <credId>");
  const existing = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  if (!existing.ok) die(existing.error);
  const provider = existing.data.provider || "claude";
  if (existing.data.type !== "oauth") die("creds import-auth supports only type=oauth");

  if (provider === "claude") {
    const baseTmp = path.join(process.env.HOME || "/home/admin", ".cache", "treehouse");
    fs.mkdirSync(baseTmp, { recursive: true });
    const tmpHome = fs.mkdtempSync(path.join(baseTmp, "claude-import-"));
    try {
      process.stdout.write("Starting Claude Code login...\n");
      const ok = runInheritEnv("claude", ["auth", "login"], { HOME: tmpHome }).ok;
      if (!ok) die("claude auth login failed");
      const credPath = path.join(tmpHome, ".claude", ".credentials.json");
      if (!fs.existsSync(credPath)) die(`Claude .credentials.json not found at ${credPath}`);
      const bundle = fs.readFileSync(credPath, "utf8");
      const imported = await fetchApi({
        method: "POST",
        urlPath: `/api/credentials/${encodeURIComponent(credId)}/import-auth`,
        body: { auth_json: bundle },
      });
      if (!imported.ok) die(imported.error);
      process.stdout.write(`${imported.data.message || "Claude credentials imported"}\n`);
    } finally {
      try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch {}
    }
    return;
  }

  if (provider === "codex") {
    die("Use `treehouse creds login --id <credId>` for Codex (device-auth), or `treehouse creds refresh --id <credId>`.");
  }

  die("Unsupported provider for import-auth");
}

async function cmdCredsImportPiAuth({ credId }) {
  if (!credId) die("Missing --id <credId>");
  const existing = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  if (!existing.ok) die(existing.error);
  if ((existing.data.provider || "claude") !== "pi") die("creds import-pi-auth requires a credential with provider=pi");

  const res = await fetchApi({
    method: "POST",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}/import-pi-host-auth`,
    body: {},
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Pi auth.json imported"}\n`);
  process.stdout.write("Tip: run \`pi /login\` on the host first to refresh OAuth tokens.\n");
}

async function cmdCredsPiLogin({ credId }) {
  if (!credId) die("Missing --id <credId>");
  const existing = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  if (!existing.ok) die(existing.error);
  if ((existing.data.provider || "claude") !== "pi") die("creds pi-login requires a credential with provider=pi");

  const piBackend = existing.data.pi_backend;
  if (!piBackend) die("Credential has no pi_backend set. Re-create with --set pi_backend=<provider-id>");

  // Load pi-ai oauth module from agent-runner node_modules
  const piAiPath = "/home/admin/treehouse/nanoclaw/container/agent-runner/node_modules/@mariozechner/pi-ai/dist/utils/oauth/index.js";
  let getOAuthProvider;
  try {
    const mod = await import(piAiPath);
    getOAuthProvider = mod.getOAuthProvider;
  } catch (e) {
    die(`Failed to load pi-ai oauth module: ${e.message}`);
  }

  const oauthProvider = getOAuthProvider(piBackend);
  if (!oauthProvider) die(`No OAuth provider found for '${piBackend}'. Is it a subscription-based provider? (google-gemini-cli, github-copilot, google-antigravity, openai-codex)`);

  const authJsonPath = path.join(process.env.HOME || "/home/admin", ".pi", "agent", "auth.json");
  fs.mkdirSync(path.dirname(authJsonPath), { recursive: true });

  process.stdout.write(`Starting OAuth login for ${oauthProvider.name} (${piBackend})...\n`);

  try {
    const credentials = await oauthProvider.login({
      onAuth: (info) => {
        process.stdout.write("\n");
        process.stdout.write(`Open this URL in your browser to authenticate:\n`);
        process.stdout.write(`\n  ${info.url}\n\n`);
        if (info.instructions) process.stdout.write(`${info.instructions}\n`);
        process.stdout.write("Waiting for OAuth callback...\n");
      },
      onProgress: (msg) => {
        process.stdout.write(`  ${msg}\n`);
      },
    });

    // Merge into existing auth.json
    let existing = {};
    try { existing = JSON.parse(fs.readFileSync(authJsonPath, "utf8")); } catch {}
    existing[piBackend] = { type: oauth, ...credentials };
    fs.writeFileSync(authJsonPath, JSON.stringify(existing, null, 2), { mode: 0o600 });
    process.stdout.write(`\nAuthenticated! Token saved for provider: ${piBackend}\n`);

    // Auto-import into credential pool
    process.stdout.write(`Importing into credential '${credId}'...\n`);
    const imported = await fetchApi({
      method: "POST",
      urlPath: `/api/credentials/${encodeURIComponent(credId)}/import-pi-host-auth`,
      body: {},
    });
    if (!imported.ok) die(`Login succeeded but credential import failed: ${imported.error}`);
    process.stdout.write(`${imported.data.message || "Credential updated"}\n`);
  } catch (err) {
    die(`OAuth login failed: ${err.message}`);
  }
}

async function cmdCredsLogout({ credId }) {
  if (!credId) die("Missing --id <credId>");
  const res = await fetchApi({
    method: "PATCH",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}`,
    body: { status: "paused" },
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Credential paused"}\n`);
}

async function cmdCredsResume({ credId }) {
  if (!credId) die("Missing --id <credId>");
  const res = await fetchApi({
    method: "PATCH",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}`,
    body: { status: "active" },
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Credential resumed"}\n`);
}

async function cmdCredsRefresh({ credId }) {
  if (!credId) die("Missing --id <credId>");
  die("Credential refresh endpoint removed. Use `treehouse creds login --id <credId>` (Claude/Codex) or `treehouse creds import-auth --id <credId>`.");
}

async function cmdCredsCallback({ credId, code }) {
  if (!credId) die("Missing --id <credId>");
  if (!code) die("Missing --code <code>");
  const res = await fetchApi({
    method: "POST",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}/callback`,
    body: { code },
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Callback OK"}\n`);
}

async function cmdCredsLoginStatus({ credId, raw }) {
  if (!credId) die("Missing --id <credId>");
  const res = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  if (!res.ok) die(res.error);

  const cred = res.data;
  if ((cred.provider || "claude") !== "codex") die("creds status currently supports only provider=codex");
  if (cred.type !== "oauth") die("creds status currently supports only type=oauth");
  if (!cred.auth_json) die("Missing auth_json on credential (run `treehouse creds login --id ...` first)");

  const baseTmp = path.join(process.env.HOME || "/home/admin", ".cache", "treehouse");
  fs.mkdirSync(baseTmp, { recursive: true });
  const tmpDir = fs.mkdtempSync(path.join(baseTmp, "codex-status-"));
  try {
    // Force Codex to use file-backed credentials under CODEX_HOME so we can inspect DB creds
    // (and not accidentally read the host admin's global login store).
    fs.writeFileSync(path.join(tmpDir, "auth.json"), cred.auth_json, "utf8");

    // Prefer a non-interactive equivalent to "/status".
    const out = run("codex", ["-c", "cli_auth_credentials_store=\"file\"", "login", "status"], {
      env: {
        ...process.env,
        CODEX_HOME: tmpDir,
      },
      timeout: 15000,
    });

    if (!out.ok) {
      const details = [out.stderr.trim(), out.stdout.trim()].filter(Boolean).join("\n\n");
      die(details || "Failed to run codex /status");
    }

    const text = [out.stdout, out.stderr].filter(Boolean).join("\n").trimEnd();
    if (raw) {
      process.stdout.write(`${text}\n`);
      return;
    }

    process.stdout.write(`${text}\n`);
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
  }
}

async function cmdCredsUsage({ credId, raw }) {
  const t0 = nowMs();
  if (!credId) die("Missing --id <credId>");
  const tFetch = nowMs();
  const res = await fetchJson(`/api/credentials/${encodeURIComponent(credId)}`);
  profLog("fetch_credential", tFetch);
  if (!res.ok) die(res.error);

  const cred = res.data;
  if ((cred.provider || "claude") === "claude") return await cmdCredsUsageClaudeApiProbe({ credId, raw, t0 });

  // Pi credential: route based on backend
  if ((cred.provider || "claude") === "pi") {
    const piBackend = cred.pi_backend;
    if (!piBackend) die("Credential has no pi_backend set");
    if (!cred.auth_json) die("Missing auth_json on pi credential");
    let authJson = {};
    try { authJson = JSON.parse(cred.auth_json); } catch { die("Invalid auth_json on pi credential"); }

    if (piBackend === "anthropic") {
      // Treat as claude OAuth — use the access token from auth_json
      const piCred = authJson["anthropic"] || {};
      if (!piCred.access) die("Missing anthropic access token in pi auth_json");
      return await cmdCredsUsagePiAnthropicProbe({ credId, raw, t0, token: piCred.access });
    } else if (piBackend === "openai-codex") {
      // Construct a codex-compatible auth.json and reuse the codex probe path
      const piCred = authJson["openai-codex"] || {};
      if (!piCred.access) die("Missing openai-codex access token in pi auth_json");
      const codexAuthJson = JSON.stringify({
        auth_mode: "chatgpt",
        OPENAI_API_KEY: null,
        tokens: {
          id_token: piCred.access,
          access_token: piCred.access,
          refresh_token: piCred.refresh || "",
        },
      });
      // Patch cred.auth_json to codex format and fall through to codex path
      cred.auth_json = codexAuthJson;
    } else {
      die(`No usage probe available for pi backend: ${piBackend}`);
    }
  }

  if ((cred.provider || "claude") !== "codex" && (cred.provider || "claude") !== "pi") die("creds usage currently supports only provider=claude|codex|pi");
  if (cred.type !== "oauth") die("creds usage currently supports only type=oauth");
  if (!cred.auth_json) die("Missing auth_json on credential (run `treehouse creds login --id ...` first)");

  const tTmp = nowMs();
  const baseTmp = path.join(process.env.HOME || "/home/admin", ".cache", "treehouse");
  fs.mkdirSync(baseTmp, { recursive: true });
  const tmpDir = fs.mkdtempSync(path.join(baseTmp, "codex-usage-"));
  profLog("mktemp", tTmp);
  try {
    const tWriteAuth = nowMs();
    fs.writeFileSync(path.join(tmpDir, "auth.json"), cred.auth_json, "utf8");
    profLog("write_auth_json", tWriteAuth);

    const { Terminal } = headless;
    const tTerm = nowMs();
    const terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true });
    profLog("xterm_init", tTerm);

    const codexCwd = process.env.HOME || "/home/admin";
    const tSpawn = nowMs();
    const child = pty.spawn(
      "codex",
      [
        "--dangerously-bypass-approvals-and-sandbox",
        "--no-alt-screen",
        "-C",
        codexCwd,
        "-c",
        'cli_auth_credentials_store="file"',
      ],
      {
        name: "xterm-256color",
        cols: 120,
        rows: 40,
        cwd: codexCwd,
        env: {
          ...process.env,
          CODEX_HOME: tmpDir,
        },
      },
    );
    profLog("pty_spawn", tSpawn);

    function parseLimitLine(line) {
      const pct = /([0-9]+)% left/.exec(line)?.[1] || "";
      const resets = /resets ([^)]+)\)/.exec(line)?.[1] || "";
      return { pct, resets };
    }

    const timeoutMs = 180000;
    const started = Date.now();
    let sawStatus = false;
    let statusSentAt = 0;
    let statusSentMonoMs = 0;
    // Some runs may show workspace trust prompts; we attempt to accept them, but we also
    // run with --dangerously-bypass-approvals-and-sandbox to minimize prompts.
    let handledTrust = false;
    let trustAttempts = 0;
    let resolved = false;
    let firstFrameAt = 0;
    let codexBannerAt = 0;
    let limitsSeenAt = 0;
    let statusEchoAt = 0;

    const getScreen = () => {
      const lines = [];
      for (let i = 0; i < terminal.rows; i++) {
        const line = terminal.buffer.active.getLine(i);
        lines.push(line ? line.translateToString(true) : "");
      }
      return lines.join("\n");
    };

    const done = (result) => {
      if (resolved) return;
      resolved = true;
      try { child.kill(); } catch {}
      result();
    };

    const tWait = nowMs();
    await new Promise((resolve) => {
      child.onData((data) => {
        terminal.write(data, () => {
          const screen = getScreen();
          if (!firstFrameAt && screen.trim()) {
            firstFrameAt = nowMs();
            profLogDelta("tui_to_first_frame", tSpawn, firstFrameAt);
          }
          if (!handledTrust && screen.includes("Do you trust the contents of this directory?")) {
            handledTrust = true;
            // Try to accept trust prompt (default selection is "Yes, continue").
            setTimeout(() => child.write("\r"), 150);
            return;
          }
          if (handledTrust && trustAttempts < 5 && screen.includes("Do you trust the contents of this directory?")) {
            trustAttempts++;
            setTimeout(() => child.write("\r"), 200);
            return;
          }
          if (!sawStatus) {
            // After initial render, request status once.
            if (screen.includes("OpenAI Codex")) {
              if (!codexBannerAt) {
                codexBannerAt = nowMs();
                profLogDelta("tui_to_codex_banner", tSpawn, codexBannerAt);
              }
              sawStatus = true;
              statusSentAt = Date.now();
              setTimeout(() => {
                statusSentMonoMs = nowMs();
                profLogDelta("codex_banner_to_send_/status", codexBannerAt, statusSentMonoMs);
                child.write("/status\r");
              }, 500);
            }
            return;
          }

          if (screen.includes("5h limit:") && screen.includes("Weekly limit:")) {
            done(() => {
              if (profEnabled()) {
                limitsSeenAt = nowMs();
                profLog("wait_for_limits", tWait);
                // Split "send" vs "echo" vs "limits" so we can tell whether Codex is slow
                // to accept the input or slow to render the /status panel.
                if (statusEchoAt) {
                  profLogDelta("send_/status_to_echo", statusSentMonoMs, statusEchoAt);
                  profLogDelta("echo_to_limits", statusEchoAt, limitsSeenAt);
                }
                profLogDelta("send_/status_to_limits", statusSentMonoMs, limitsSeenAt);
              }
              if (raw) {
                process.stdout.write(`${screen}\n`);
                profLog("total", t0);
                resolve();
                return;
              }

              const lines = screen.split("\n").map((l) => l.trimEnd());
              const fiveH = lines.find((l) => l.includes("5h limit:")) || "";
              const weekly = lines.find((l) => l.includes("Weekly limit:")) || "";
              const five = parseLimitLine(fiveH);
              const week = parseLimitLine(weekly);

              const rows = [
                { key: "5h_left_pct", value: five.pct },
                { key: "5h_resets", value: five.resets },
                { key: "weekly_left_pct", value: week.pct },
                { key: "weekly_resets", value: week.resets },
              ].filter((r) => r.value);

              printTable(rows, [
                { key: "key", header: "KEY" },
                { key: "value", header: "VALUE" },
              ]);
              profLog("total", t0);
              resolve();
            });
          }
        });
      });

      const timer = setInterval(() => {
        if (resolved) {
          clearInterval(timer);
          return;
        }
        const screen = getScreen();
        if (screen.includes("Do you trust the contents of this directory?")) {
          // Keep nudging Enter; Codex sometimes ignores early keystrokes during init.
          try { child.write("\r"); } catch {}
        }
        if (sawStatus && statusSentAt && Date.now() - statusSentAt > 15000) {
          // If /status didn't render yet, try again (once).
          statusSentAt = 0;
          try { child.write("/status\r"); } catch {}
        }
        if (sawStatus && statusSentMonoMs && !statusEchoAt) {
          const screen = getScreen();
          // We sent "/status" — record when it actually appears on the rendered screen,
          // which indicates Codex has started processing input.
          if (screen.includes("/status")) {
            statusEchoAt = nowMs();
            profLogDelta("send_/status_to_echo", statusSentMonoMs, statusEchoAt);
          }
        }
        if (Date.now() - started > timeoutMs) {
          const screen = getScreen();
          done(() => {
            die(`Timed out waiting for /status limits.\n\nLast screen:\n${screen}`);
          });
        }
      }, 250);
    });
  } finally {
    try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
  }
}

async function cmdCredsUsageClaude({ cred, raw, t0 }) {
  if (cred.type !== "oauth") die("creds usage (claude) currently supports only type=oauth");
  if (!cred.access_token || !cred.refresh_token || !cred.expires_at) {
    die("Missing Claude OAuth fields on credential (need access_token, refresh_token, expires_at)");
  }

  const tTmp = nowMs();
  const baseTmp = path.join(process.env.HOME || "/home/admin", ".cache", "treehouse");
  fs.mkdirSync(baseTmp, { recursive: true });
  // Use a persistent HOME per credential. Fresh HOME dirs appear to trigger persistent
  // /usage rate limiting (likely treated as a new device/session every invocation).
  const tmpHome = path.join(baseTmp, `claude-home-${String(cred.id || "unknown").replace(/[^a-zA-Z0-9._-]/g, "_")}`);
  fs.mkdirSync(tmpHome, { recursive: true });
  profLog("claude_home_prepare", tTmp);

  try {
    const tWrite = nowMs();
    const claudeDir = path.join(tmpHome, ".claude");
    fs.mkdirSync(claudeDir, { recursive: true });

    // Bootstrap non-secret Claude Code state from the current user's real home, if available.
    // This helps the isolated HOME behave like a normal interactive install (e.g. show "Claude Max"
    // instead of "Claude API" in the TUI).
    const realHome = process.env.HOME || "/home/admin";
    try {
      const srcState = path.join(realHome, ".claude.json");
      const dstState = path.join(tmpHome, ".claude.json");
      if (fs.existsSync(srcState)) fs.copyFileSync(srcState, dstState);
    } catch {}
    try {
      const srcSettings = path.join(realHome, ".claude", "settings.json");
      const dstSettings = path.join(claudeDir, "settings.json");
      if (fs.existsSync(srcSettings)) fs.copyFileSync(srcSettings, dstSettings);
    } catch {}
    try {
      const srcLocal = path.join(realHome, ".claude", "settings.local.json");
      const dstLocal = path.join(claudeDir, "settings.local.json");
      if (fs.existsSync(srcLocal)) fs.copyFileSync(srcLocal, dstLocal);
    } catch {}
    const credJson = {
      claudeAiOauth: {
        accessToken: cred.access_token,
        refreshToken: cred.refresh_token,
        expiresAt: cred.expires_at,
        scopes: [
          "user:inference",
          "user:mcp_servers",
          "user:profile",
          "user:sessions:claude_code",
        ],
        subscriptionType: cred.plan_type || "unknown",
        rateLimitTier: cred.rate_limit_tier || "unknown",
      },
    };
    fs.writeFileSync(path.join(claudeDir, ".credentials.json"), JSON.stringify(credJson, null, 2));
    // Ensure ~/.claude.json exists even if we couldn't bootstrap it.
    const statePath = path.join(tmpHome, ".claude.json");
    if (!fs.existsSync(statePath)) {
      const claudeHomeState = {
        hasCompletedOnboarding: true,
        oauthAccount: {
          emailAddress: "",
          billingType: cred.plan_type || "unknown",
          organizationUuid: null,
          organizationName: null,
        },
      };
      fs.writeFileSync(statePath, JSON.stringify(claudeHomeState, null, 2));
    }
    // Avoid permission prompt noise; only write minimal settings if we didn't bootstrap one.
    const settingsPath = path.join(claudeDir, "settings.json");
    if (!fs.existsSync(settingsPath)) {
      fs.writeFileSync(settingsPath, JSON.stringify({ skipDangerousModePermissionPrompt: true }, null, 2));
    }
    profLog("write_claude_creds", tWrite);

    // Sanity-check that Claude sees us as logged in before trying to drive the TUI.
    const tAuth = nowMs();
    const authOut = run("claude", ["auth", "status", "--json"], {
      env: { ...process.env, HOME: tmpHome },
      timeout: 10000,
    });
    profLog("claude_auth_status", tAuth);
    if (!authOut.ok) die(authOut.stderr.trim() || authOut.stdout.trim() || "claude auth status failed");
  try {
    const parsed = JSON.parse((authOut.stdout || "").trim() || "{}");
    if (!parsed?.loggedIn) die("Claude credential not accepted by claude CLI (loggedIn=false)");
    // Persist org/email into ~/.claude.json so the TUI behaves like a normal subscription login
    // (otherwise it may show "Claude API" and /usage can fail/rate-limit).
    try {
      const statePath = path.join(tmpHome, ".claude.json");
      const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
      state.hasCompletedOnboarding = true;
      state.oauthAccount = {
        ...(state.oauthAccount && typeof state.oauthAccount === "object" ? state.oauthAccount : {}),
        emailAddress: typeof parsed.email === "string" ? parsed.email : state.oauthAccount?.emailAddress || "",
        organizationUuid: typeof parsed.orgId === "string" ? parsed.orgId : state.oauthAccount?.organizationUuid || null,
        organizationName: typeof parsed.orgName === "string" ? parsed.orgName : state.oauthAccount?.organizationName || null,
        billingType: typeof parsed.subscriptionType === "string" ? parsed.subscriptionType : state.oauthAccount?.billingType || cred.plan_type || "unknown",
      };
      fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
    } catch {}
  } catch {
    die("Failed to parse `claude auth status --json` output");
  }

    const { Terminal } = headless;
    const tTerm = nowMs();
    const terminal = new Terminal({ cols: 120, rows: 40, allowProposedApi: true });
    profLog("xterm_init", tTerm);

    const tSpawn = nowMs();
    const child = pty.spawn("claude", ["--dangerously-skip-permissions"], {
      name: "xterm-256color",
      cols: 120,
      rows: 40,
      cwd: tmpHome,
      env: {
        ...process.env,
        HOME: tmpHome,
      },
    });
    profLog("pty_spawn", tSpawn);

    const timeoutMs = 60000;
    const started = Date.now();
    let resolved = false;
    let firstFrameAt = 0;
    let themeChosenAt = 0;
    let themeAttempts = 0;
    let trustAcceptedAt = 0;
    let trustAttempts = 0;
    let usageSentAt = 0;
    let usageSentMonoMs = 0;
    let usageEchoAt = 0;
    let usageRenderedAt = 0;
    let usageRetryCount = 0;
    let lastRetryAt = 0;

    const getScreen = () => {
      const lines = [];
      for (let i = 0; i < terminal.rows; i++) {
        const line = terminal.buffer.active.getLine(i);
        lines.push(line ? line.translateToString(true) : "");
      }
      return lines.join("\n");
    };

    const done = (fn) => {
      if (resolved) return;
      resolved = true;
      try { child.kill(); } catch {}
      fn();
    };

    function parseClaudeUsage(screen) {
      const lines = screen.split("\n").map((l) => l.trimEnd());

      const findSection = (title) => {
        const idx = lines.findIndex((l) => l.trim() === title);
        if (idx === -1) return null;
        // Heuristic: in the next few lines, find "<n>% used" and "Resets ..."
        const window = lines.slice(idx, idx + 10);
        const pctLine = window.find((l) => /([0-9]+)% used/.test(l)) || "";
        const resetsLine = window.find((l) => /\bResets\b/.test(l)) || "";
        const pct = /([0-9]+)% used/.exec(pctLine)?.[1] || "";
        const resets = /\bResets\b\s+(.+)$/.exec(resetsLine)?.[1] || "";
        return { pct, resets };
      };

      const session = findSection("Current session");
      const weekAll = findSection("Current week (all models)");
      const weekSonnet = findSection("Current week (Sonnet only)");
      const extraDisabled = screen.includes("Extra usage not enabled");

      return { session, weekAll, weekSonnet, extraDisabled };
    }

    const tWait = nowMs();
    await new Promise((resolve) => {
      child.onData((data) => {
        terminal.write(data, () => {
          const screen = getScreen();

          if (!firstFrameAt && screen.trim()) {
            firstFrameAt = nowMs();
            profLogDelta("tui_to_first_frame", tSpawn, firstFrameAt);
          }

          // Fresh HOME can trigger onboarding: theme picker.
          if (!themeChosenAt && screen.includes("Choose the text style")) {
            themeChosenAt = nowMs();
            profLogDelta("tui_to_theme_picker", tSpawn, themeChosenAt);
            // Default selection is already chosen; press Enter to continue.
            setTimeout(() => {
              try { child.write("\r"); } catch {}
            }, 150);
            return;
          }

          if (!trustAcceptedAt && (screen.includes("Quick safety check") || screen.includes("Yes, I trust this folder"))) {
            trustAcceptedAt = nowMs();
            profLogDelta("tui_to_trust_prompt", tSpawn, trustAcceptedAt);
            setTimeout(() => {
              try { child.write("\r"); } catch {}
            }, 150);
            return;
          }

          if (!usageSentAt) {
            // If we're in the theme picker, don't send /usage yet.
            if (screen.includes("Choose the text style")) return;
            // If we're in the workspace trust prompt, don't send /usage yet.
            if (screen.includes("Quick safety check") || screen.includes("Yes, I trust this folder")) return;
            // Once onboarding is cleared, request /usage (it opens the usage panel).
            usageSentAt = Date.now();
            setTimeout(() => {
              usageSentMonoMs = nowMs();
              child.write("/usage\r");
            }, 250);
            return;
          }

          if (usageSentMonoMs && !usageEchoAt && screen.includes("/usage")) {
            usageEchoAt = nowMs();
            profLogDelta("send_/usage_to_echo", usageSentMonoMs, usageEchoAt);
          }

          if (usageSentMonoMs && screen.includes("Failed to load usage data")) {
            // Claude sometimes rate-limits /usage. Retry in-panel with backoff.
            if (screen.includes("r to retry") && usageRetryCount < 6) {
              const now = Date.now();
              const backoffMs = Math.min(30000, 1000 * Math.pow(2, usageRetryCount)); // 1s,2s,4s,8s,16s,30s
              if (!lastRetryAt || now - lastRetryAt >= backoffMs) {
                usageRetryCount++;
                lastRetryAt = now;
                setTimeout(() => {
                  try { child.write("r"); } catch {}
                }, 50);
              }
              return;
            }
            done(() => {
              if (raw) {
                process.stdout.write(`${screen}\n`);
                profLog("total", t0);
                resolve();
                return;
              }
              const lines = screen.split("\n").map((l) => l.trimEnd());
              const idx = lines.findIndex((l) => l.includes("Failed to load usage data"));
              const msgLines =
                idx === -1 ? ["Failed to load usage data"] : [lines[idx] || "", lines[idx + 1] || "", lines[idx + 2] || ""];
              const msg = msgLines
                .join(" ")
                .replace(/\s+/g, " ")
                .trim();
              die(msg || "Failed to load usage data");
            });
            return;
          }

          if (usageSentMonoMs && screen.includes("Current session") && screen.includes("Current week (all models)")) {
            done(() => {
              usageRenderedAt = nowMs();
              if (profEnabled()) {
                profLog("wait_for_usage", tWait);
                if (usageEchoAt) profLogDelta("echo_to_usage", usageEchoAt, usageRenderedAt);
                profLogDelta("send_/usage_to_usage", usageSentMonoMs, usageRenderedAt);
              }

              if (raw) {
                process.stdout.write(`${screen}\n`);
                profLog("total", t0);
                resolve();
                return;
              }

              const parsed = parseClaudeUsage(screen);
              const rows = [
                ...(parsed.session?.pct ? [{ key: "session_used_pct", value: parsed.session.pct }] : []),
                ...(parsed.session?.resets ? [{ key: "session_resets", value: parsed.session.resets }] : []),
                ...(parsed.weekAll?.pct ? [{ key: "weekly_all_used_pct", value: parsed.weekAll.pct }] : []),
                ...(parsed.weekAll?.resets ? [{ key: "weekly_all_resets", value: parsed.weekAll.resets }] : []),
                ...(parsed.weekSonnet?.pct ? [{ key: "weekly_sonnet_used_pct", value: parsed.weekSonnet.pct }] : []),
                ...(parsed.weekSonnet?.resets ? [{ key: "weekly_sonnet_resets", value: parsed.weekSonnet.resets }] : []),
                ...(typeof parsed.extraDisabled === "boolean" ? [{ key: "extra_usage_enabled", value: parsed.extraDisabled ? "false" : "true" }] : []),
              ].filter((r) => r.value);

              printTable(rows, [
                { key: "key", header: "KEY" },
                { key: "value", header: "VALUE" },
              ]);
              profLog("total", t0);
              resolve();
            });
          }
        });
      });

      const timer = setInterval(() => {
        if (resolved) {
          clearInterval(timer);
          return;
        }
        const screen = getScreen();
        if (screen.includes("Choose the text style")) {
          if (!themeChosenAt) themeChosenAt = nowMs();
          // Claude can ignore early keystrokes during init; keep nudging Enter.
          if (themeAttempts < 30) {
            themeAttempts++;
            try { child.write("\r"); } catch {}
          }
        }
        if (screen.includes("Quick safety check") || screen.includes("Yes, I trust this folder")) {
          if (!trustAcceptedAt) trustAcceptedAt = nowMs();
          if (trustAttempts < 30) {
            trustAttempts++;
            try { child.write("\r"); } catch {}
          }
        }
        if (usageSentAt && !usageRenderedAt && Date.now() - usageSentAt > 20000) {
          // Nudge /usage again once in case it was dropped during init.
          usageSentAt = 0;
          usageSentMonoMs = 0;
          usageEchoAt = 0;
          try { child.write("/usage\r"); } catch {}
        }
        if (Date.now() - started > timeoutMs) {
          done(() => {
            die(`Timed out waiting for /usage output.\n\nLast screen:\n${screen}`);
          });
        }
      }, 250);
    });
  } finally {
    // Intentionally keep tmpHome to preserve Claude CLI state between runs.
  }
}

async function cmdCredsUsagePiAnthropicProbe({ credId, raw, t0, token }) {
  // Direct HTTP probe of Anthropic API using the pi OAuth access token
  const { default: https } = await import("https");
  const body = JSON.stringify({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1,
    messages: [{ role: "user", content: "." }],
  });

  const limits = await new Promise((resolve) => {
    const req = https.request({
      hostname: "api.anthropic.com",
      path: "/v1/messages",
      method: "POST",
      headers: {
        "anthropic-version": "2023-06-01",
        "anthropic-beta": "oauth-2025-04-20",
        "content-type": "application/json",
        "authorization": `Bearer ${token}`,
        "content-length": Buffer.byteLength(body),
      },
      timeout: 15000,
    }, (res) => {
      const result = {};
      for (const [k, v] of Object.entries(res.headers)) {
        if (k.startsWith("anthropic-ratelimit-")) result[k.replace("anthropic-ratelimit-", "")] = v;
        if (k === "anthropic-organization-id") result.organization_id = v;
      }
      res.resume();
      resolve(result);
    });
    req.on("error", () => resolve({}));
    req.on("timeout", () => { req.destroy(); resolve({}); });
    req.write(body);
    req.end();
  });

  if (raw) {
    process.stdout.write(JSON.stringify({ id: credId, limits }, null, 2) + "\n");
    return;
  }

  const rows = Object.keys(limits).sort().map((k) => {
    const rawVal = String(limits[k]);
    if (/-reset$/.test(k) || k === "unified-reset") {
      const pretty = formatUnixSecondsToLocal(rawVal);
      return { key: `limit_${k}`, value: pretty ? `${rawVal} (${pretty})` : rawVal };
    }
    return { key: `limit_${k}`, value: rawVal };
  });

  if (rows.length === 0) {
    process.stdout.write("No rate limit headers returned (token may be invalid)\n");
  } else {
    printTable(rows, [{ key: "key", header: "KEY" }, { key: "value", header: "VALUE" }]);
  }
  profLog("total", t0);
}

async function cmdCredsUsageClaudeApiProbe({ credId, raw, t0 }) {
  const tFetch = nowMs();
  const res = await fetchJson("/api/claude-usage");
  profLog("fetch_claude_usage", tFetch);
  if (!res.ok) die(res.error);

  const tokens = res.data?.tokens || [];
  const entry = tokens.find((t) => t.id === credId);
  if (!entry) die(`Claude usage probe did not include credential '${credId}'`);

  if (raw) {
    process.stdout.write(`${JSON.stringify(entry, null, 2)}\n`);
    profLog("total", t0);
    return;
  }

  const limits = entry.limits || {};
  const rows = [
    { key: "status", value: entry.status },
    { key: "http_status", value: String(entry.http_status ?? "") },
    ...Object.keys(limits)
      .sort()
      .map((k) => {
        const rawVal = String(limits[k]);
        // Convert known reset timestamps (unix seconds) to readable datetime.
        if (/-reset$/.test(k) || k === "unified-reset") {
          const pretty = formatUnixSecondsToLocal(rawVal);
          return { key: `limit_${k}`, value: pretty ? `${rawVal} (${pretty})` : rawVal };
        }
        return { key: `limit_${k}`, value: rawVal };
      }),
  ].filter((r) => r.value);

  printTable(rows, [
    { key: "key", header: "KEY" },
    { key: "value", header: "VALUE" },
  ]);
  profLog("total", t0);
}

async function cmdCredsRemove({ credId, yes }) {
  if (!credId) die("Missing --id <credId>");
  await confirmOrDie({ prompt: `Delete credential '${credId}'?`, yesFlag: yes });
  const res = await fetchApi({
    method: "DELETE",
    urlPath: `/api/credentials/${encodeURIComponent(credId)}`,
    body: {},
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Credential deleted"}\n`);
}

function printTable(items, columns) {
  const widths = Object.fromEntries(columns.map((c) => [c.key, c.header.length]));
  for (const item of items) {
    for (const col of columns) {
      widths[col.key] = Math.max(widths[col.key], String(item[col.key] ?? "").length);
    }
  }

  const header = columns.map((c) => c.header.padEnd(widths[c.key])).join("  ");
  process.stdout.write(`${header}\n`);
  for (const item of items) {
    const line = columns.map((c) => String(item[c.key] ?? "").padEnd(widths[c.key])).join("  ");
    process.stdout.write(`${line}\n`);
  }
}

async function cmdTenantsTailscaleProxy({ tenantId, enable, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (enable === undefined) die("Must specify --enable or --disable");
  await confirmOrDie({
    prompt: `${enable ? "Enable" : "Disable"} Tailscale proxy for '${tenantId}'? Requires nixos-rebuild (~30s).`,
    yesFlag: yes,
  });
  process.stdout.write("Updating NixOS config and rebuilding...\n");
  const res = await fetchApi({
    method: "POST",
    urlPath: `/api/tenants/${encodeURIComponent(tenantId)}/tailscale-proxy`,
    body: { enable },
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message}\n`);
}

async function cmdTenantsList() {
  const mc = await fetchJson("/api/tenants/list");
  if (mc.ok) {
    printTable(mc.data, [{ key: "tenant", header: "TENANT" }, { key: "uid", header: "UID" }]);
    return;
  }
  process.stderr.write(`(MC unavailable: ${mc.error}; using local enumeration)\n`);
  const tenants = listTenantsFromTenantsNix();
  printTable(tenants, [{ key: "tenant", header: "TENANT" }, { key: "uid", header: "UID" }]);
}

async function cmdDefaultsGet({ json }) {
  const res = await fetchJson("/api/settings");
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  printTable(
    [
      { key: "default_provider", value: res.data.default_provider },
      { key: "default_model", value: res.data.default_model },
    ],
    [
      { key: "key", header: "KEY" },
      { key: "value", header: "VALUE" },
    ],
  );
}

async function cmdDefaultsSet({ provider, model, piBackend, json }) {
  const body = {};
  if (provider) body.default_provider = provider;
  if (model) body.default_model = model;
  if (piBackend) body.default_pi_backend = piBackend;
  if (!provider && !model && !piBackend) die("Provide at least one of --provider, --model, or --pi-backend");
  const res = await fetchApi({ method: "POST", urlPath: "/api/settings", body });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`${res.data.message || "Defaults updated"}\n`);
}

async function cmdAgentsPs({ tenantId, json }) {
  const qp = tenantId ? `?tenant=${encodeURIComponent(tenantId)}` : "";
  const res = await fetchJson(`/api/agents/containers${qp}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  printTable(res.data, [
    { key: "id", header: "ID" },
    { key: "tenant", header: "TENANT" },
    { key: "name", header: "NAME" },
    { key: "image", header: "IMAGE" },
    { key: "status", header: "STATUS" },
    { key: "agent_status", header: "AGENT" },
  ]);
}

async function cmdAgentsKill({ containerId, tenantId, all, yes }) {
  if (!containerId && !tenantId && !all) die("Provide --id, --tenant, or --all");
  await confirmOrDie({
    prompt: all
      ? "Kill ALL running agent runner containers (host Docker)?"
      : tenantId
        ? `Kill running agent runner containers for tenant '${tenantId}'?`
        : `Kill agent runner container '${containerId}'?`,
    yesFlag: yes,
  });
  const ids = containerId ? [containerId] : [];
  const res = await fetchApi({
    method: "POST",
    urlPath: "/api/agents/containers/kill",
    body: { ids, tenant: tenantId || "", all: Boolean(all), confirm: true },
  });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message || "Killed"}\n`);
}

async function cmdAgentsLogs({ containerId, tail }) {
  if (!containerId) die("Missing --id <containerId>");
  const qp = new URLSearchParams({ id: containerId });
  if (tail) qp.set("tail", String(tail));
  const res = await fetchJson(`/api/agents/containers/logs?${qp.toString()}`);
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.output}\n`);
}

async function cmdChatsList({ tenantId, limit, json }) {
  const qp = new URLSearchParams();
  if (tenantId) qp.set("tenant", tenantId);
  if (limit) qp.set("limit", String(limit));
  const res = await fetchJson(`/api/chats?${qp.toString()}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  printTable(res.data, [
    { key: "jid", header: "JID" },
    { key: "name", header: "NAME" },
    { key: "last_message_time", header: "LAST_MSG" },
    { key: "channel", header: "CHANNEL" },
    { key: "is_group", header: "IS_GROUP" },
  ]);
}

async function cmdMessagesTail({ tenantId, chatJid, groupFolder, limit, json }) {
  const qp = new URLSearchParams();
  if (groupFolder) qp.set("group_folder", groupFolder);
  else if (chatJid) qp.set("chat_jid", chatJid);
  else if (tenantId) qp.set("tenant", tenantId);
  if (limit) qp.set("limit", String(limit));
  const res = await fetchJson(`/api/messages?${qp.toString()}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  printTable(res.data, [
    { key: "timestamp", header: "TS" },
    { key: "chat_jid", header: "CHAT" },
    { key: "sender_name", header: "SENDER" },
    { key: "is_from_me", header: "ME" },
    { key: "content", header: "CONTENT" },
  ]);
}

async function cmdSessionTail({ groupFolder, limit }) {
  if (!groupFolder) die("--group <tenant/group> is required");
  const slashIdx = groupFolder.indexOf("/");
  if (slashIdx === -1) die("--group must be in <tenant/group> format");
  const tenant = groupFolder.slice(0, slashIdx);
  const group = groupFolder.slice(slashIdx + 1);

  // Look up active session_id for this group
  const dbPath = nanoclawDbPath();
  const sessionRes = sqliteJson(dbPath, `SELECT session_id FROM sessions WHERE group_folder = ${sqliteQuote(groupFolder)} LIMIT 1`);
  if (!sessionRes.ok) die(sessionRes.error);
  if (!sessionRes.rows.length) die(`No active session found for group ${groupFolder}`);
  const sessionId = sessionRes.rows[0].session_id;

  const jsonlPath = `/home/${tenant}/nanoclaw/data/sessions/${group}/.claude/projects/-workspace-group/${sessionId}.jsonl`;
  const res = run("sudo", ["cat", jsonlPath]);
  if (!res.ok) die(`Could not read session file: ${res.stderr.trim()}`);

  const maxTurns = limit ? parseInt(limit) : 20;
  const turns = [];

  for (const line of res.stdout.split("\n")) {
    if (!line.trim()) continue;
    let obj;
    try { obj = JSON.parse(line); } catch { continue; }
    const role = obj.message?.role;
    if (!role) continue;

    if (role === "user") {
      const raw = typeof obj.message.content === "string" ? obj.message.content : "";
      // Extract text from XML: <message sender="X" time="T">text</message>
      const parts = [];
      const re = /<message sender="([^"]*)" time="([^"]*)">([\s\S]*?)<\/message>/g;
      let m;
      while ((m = re.exec(raw)) !== null) {
        const text = m[3].replace(/&quot;/g, '"').replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").trim();
        parts.push({ sender: m[1], time: m[2], text });
      }
      if (parts.length) {
        for (const p of parts) turns.push({ role: "user", sender: p.sender, time: p.time, text: p.text });
      }
    } else if (role === "assistant") {
      const content = obj.message.content;
      if (!Array.isArray(content)) continue;
      const textBlocks = content.filter((b) => b.type === "text").map((b) => b.text).join("\n").trim();
      if (textBlocks) turns.push({ role: "assistant", sender: "agent", time: obj.timestamp || "", text: textBlocks });
    }
  }

  const recent = turns.slice(-maxTurns);
  for (const turn of recent) {
    const ts = turn.time ? new Date(turn.time).toLocaleString("en-GB", { month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }) : "";
    const label = turn.role === "user" ? `[${ts}] ${turn.sender}` : `[${ts}] agent`;
    process.stdout.write(`\n${label}:\n${turn.text}\n`);
  }
}

async function cmdGroupsList({ tenantId, json }) {
  const dbPath = nanoclawDbPath();
  const where = tenantId ? `WHERE rg.tenant_id = ${sqliteQuote(tenantId)}` : "";
  const res = sqliteJson(
    dbPath,
    `
      SELECT
        rg.tenant_id,
        rg.name AS group_name,
        rg.folder,
        rg.jid,
        c.name AS chat_name,
        c.channel,
        c.is_group,
        rg.is_main,
        rg.requires_trigger
      FROM registered_groups rg
      LEFT JOIN chats c ON c.jid = rg.jid
      ${where}
      ORDER BY rg.tenant_id, rg.folder;
    `.trim(),
  );
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.rows, null, 2)}\n`);
    return;
  }
  printTable(res.rows, [
    { key: "tenant_id", header: "TENANT" },
    { key: "folder", header: "FOLDER" },
    { key: "group_name", header: "GROUP" },
    { key: "jid", header: "JID" },
    { key: "channel", header: "CHANNEL" },
    { key: "is_main", header: "MAIN" },
    { key: "requires_trigger", header: "TRIGGER" },
    { key: "chat_name", header: "CHAT_NAME" },
  ]);
}

async function cmdGroupsShow({ folder, json }) {
  if (!folder) die("Missing --folder <tenant/group>");
  const dbPath = nanoclawDbPath();
  const res = sqliteJson(
    dbPath,
    `
      SELECT
        rg.tenant_id,
        rg.name AS group_name,
        rg.folder,
        rg.jid,
        rg.trigger_pattern,
        rg.added_at,
        rg.is_main,
        rg.requires_trigger,
        c.name AS chat_name,
        c.channel,
        c.is_group
      FROM registered_groups rg
      LEFT JOIN chats c ON c.jid = rg.jid
      WHERE rg.folder = ${sqliteQuote(folder)}
      LIMIT 1;
    `.trim(),
  );
  if (!res.ok) die(res.error);
  const row = res.rows[0];
  if (!row) die(`Group not found: ${folder}`, 2);
  if (json) {
    process.stdout.write(`${JSON.stringify(row, null, 2)}\n`);
    return;
  }
  const rows = Object.entries(row).map(([key, value]) => ({ key, value: value ?? "" }));
  printTable(rows, [
    { key: "key", header: "KEY" },
    { key: "value", header: "VALUE" },
  ]);
}


async function cmdGroupsHooks({ folder, subaction, json }) {
  if (!folder) die("Missing --folder <tenant/group>");
  const dbPath = nanoclawDbPath();

  if (!subaction || subaction === "show") {
    const res = sqliteJson(dbPath,
      `SELECT jid, name, folder, hooks_token, hooks_enabled FROM registered_groups WHERE folder = ${sqliteQuote(folder)} LIMIT 1`.trim()
    );
    if (!res.ok) die(res.error);
    const row = res.rows[0];
    if (!row) die(`Group not found: ${folder}`, 2);
    if (json) { process.stdout.write(JSON.stringify(row, null, 2) + "\n"); return; }
    console.log(`folder:        ${row.folder}`);
    console.log(`hooks_enabled: ${row.hooks_enabled ? "yes" : "no"}`);
    console.log(`hooks_token:   ${row.hooks_token ?? "(none)"}`);
    return;
  }

  if (subaction === "enable" || subaction === "disable") {
    const enabled = subaction === "enable";
    // Check if token exists; generate if enabling and none present
    const res = sqliteJson(dbPath,
      `SELECT jid, hooks_token FROM registered_groups WHERE folder = ${sqliteQuote(folder)} LIMIT 1`.trim()
    );
    if (!res.ok) die(res.error);
    const row = res.rows[0];
    if (!row) die(`Group not found: ${folder}`, 2);

    let sql;
    if (enabled && !row.hooks_token) {
      const token = randomHex(32);
      sql = `UPDATE registered_groups SET hooks_enabled = 1, hooks_token = ${sqliteQuote(token)} WHERE jid = ${sqliteQuote(row.jid)}`;
    } else {
      sql = `UPDATE registered_groups SET hooks_enabled = ${enabled ? 1 : 0} WHERE jid = ${sqliteQuote(row.jid)}`;
    }
    const upd = sqliteExec(dbPath, sql);
    if (!upd.ok) die(upd.error);
    console.log(`Hooks ${enabled ? "enabled" : "disabled"} for ${folder}`);
    // SIGHUP nanoclaw
    spawnSync("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw.service"], { stdio: "inherit" });
    return;
  }

  if (subaction === "rotate") {
    const token = randomHex(32);
    const res = sqliteJson(dbPath,
      `SELECT jid FROM registered_groups WHERE folder = ${sqliteQuote(folder)} LIMIT 1`.trim()
    );
    if (!res.ok) die(res.error);
    const row = res.rows[0];
    if (!row) die(`Group not found: ${folder}`, 2);
    const upd = sqliteExec(dbPath, `UPDATE registered_groups SET hooks_token = ${sqliteQuote(token)} WHERE jid = ${sqliteQuote(row.jid)}`);
    if (!upd.ok) die(upd.error);
    console.log(`Token rotated: ${token}`);
    spawnSync("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw.service"], { stdio: "inherit" });
    return;
  }

  die(`Unknown hooks subaction: ${subaction}. Use show, enable, disable, or rotate.`);
}

async function cmdGroupsHooksList({ tenantId, json }) {
  const dbPath = nanoclawDbPath();
  const where = tenantId ? `WHERE rg.tenant_id = ${sqliteQuote(tenantId)}` : "";
  const res = sqliteJson(dbPath, `
    SELECT rg.tenant_id, rg.folder, rg.jid, rg.hooks_token, rg.hooks_enabled, rg.is_main
    FROM registered_groups rg
    ${where}
    ORDER BY rg.tenant_id, rg.folder
  `.trim());
  if (!res.ok) die(res.error);
  if (json) { process.stdout.write(JSON.stringify(res.rows, null, 2) + "\n"); return; }
  printTable(res.rows, [
    { key: "tenant_id", header: "TENANT" },
    { key: "folder", header: "FOLDER" },
    { key: "is_main", header: "MAIN" },
    { key: "hooks_enabled", header: "ENABLED" },
    { key: "hooks_token", header: "TOKEN" },
  ]);
}

async function cmdGroupsHooksFind({ token, json }) {
  if (!token) die("Missing --token <token>");
  const dbPath = nanoclawDbPath();
  const res = sqliteJson(dbPath,
    `SELECT rg.tenant_id, rg.folder, rg.jid, rg.hooks_token, rg.hooks_enabled, rg.is_main
     FROM registered_groups rg
     WHERE rg.hooks_token = ${sqliteQuote(token)}
     LIMIT 1`.trim()
  );
  if (!res.ok) die(res.error);
  const row = res.rows[0];
  if (!row) die("No group found with that token", 2);
  if (json) { process.stdout.write(JSON.stringify(row, null, 2) + "\n"); return; }
  console.log(`tenant:        ${row.tenant_id}`);
  console.log(`folder:        ${row.folder}`);
  console.log(`jid:           ${row.jid}`);
  console.log(`hooks_enabled: ${row.hooks_enabled ? "yes" : "no"}`);
}

async function cmdGroupsHooksEnableAll({ yes }) {
  const dbPath = nanoclawDbPath();
  // Find all groups that don't have hooks enabled
  const res = sqliteJson(dbPath,
    `SELECT jid, folder, tenant_id, hooks_token, hooks_enabled FROM registered_groups ORDER BY tenant_id, folder`.trim()
  );
  if (!res.ok) die(res.error);
  const toEnable = res.rows.filter(r => !r.hooks_enabled);
  if (toEnable.length === 0) { console.log("All groups already have hooks enabled."); return; }
  console.log(`Groups to enable hooks on: ${toEnable.length}`);
  for (const r of toEnable) console.log(`  ${r.folder}`);
  if (!yes) {
    const { createInterface } = await import("readline");
    const rl = createInterface({ input: process.stdin, output: process.stdout });
    const answer = await new Promise(resolve => rl.question("\nProceed? [y/N] ", resolve));
    rl.close();
    if (answer.trim().toLowerCase() !== "y") { console.log("Aborted."); return; }
  }
  let updated = 0;
  for (const r of toEnable) {
    const token = r.hooks_token ?? randomHex(32);
    const sql = `UPDATE registered_groups SET hooks_enabled = 1, hooks_token = ${sqliteQuote(token)} WHERE jid = ${sqliteQuote(r.jid)}`;
    const upd = sqliteExec(dbPath, sql);
    if (!upd.ok) { console.error(`  FAILED: ${r.folder}: ${upd.error}`); continue; }
    console.log(`  Enabled: ${r.folder}`);
    updated++;
  }
  console.log(`\nEnabled hooks on ${updated} groups.`);
  spawnSync("sudo", ["systemctl", "kill", "-s", "HUP", "nanoclaw.service"], { stdio: "inherit" });
}

async function cmdGroupsDangling({ tenantId, doDelete, json }) {
  // Get bot token from env or nanoclaw .env
  let token = process.env.DISCORD_BOT_TOKEN;
  if (!token) {
    const envRes = run("sudo", ["cat", "/var/lib/nanoclaw/.env"]);
    if (envRes.ok) {
      const match = envRes.stdout.match(/^DISCORD_BOT_TOKEN=(.+)$/m);
      if (match) token = match[1].trim();
    }
  }
  if (!token) die("DISCORD_BOT_TOKEN not found in environment or /var/lib/nanoclaw/.env");

  const dbPath = nanoclawDbPath();
  const where = tenantId ? `WHERE tenant_id = ${sqliteQuote(tenantId)}` : "";
  const res = sqliteJson(
    dbPath,
    `SELECT jid, name, folder, tenant_id FROM registered_groups ${where} ORDER BY tenant_id, folder`.trim(),
  );
  if (!res.ok) die(res.error);

  const discordGroups = res.rows.filter(r => r.jid.startsWith("dc:"));
  const dangling = [];

  for (const row of discordGroups) {
    const channelId = row.jid.slice(3);
    const url = `https://discord.com/api/v10/channels/${channelId}`;
    try {
      const resp = await fetch(url, {
        headers: { Authorization: `Bot ${token}` },
      });
      if (resp.status === 404 || resp.status === 401) {
        dangling.push({ ...row, status: resp.status });
      } else if (!resp.ok) {
        // Don't treat other errors (rate limit, server error) as dangling
        process.stderr.write(`  SKIP ${row.folder}: HTTP ${resp.status}\n`);
      }
    } catch (e) {
      process.stderr.write(`  SKIP ${row.folder}: ${e?.message || e}\n`);
    }
  }

  if (json) {
    process.stdout.write(`${JSON.stringify(dangling, null, 2)}\n`);
    return;
  }

  if (dangling.length === 0) {
    process.stdout.write("No dangling channels found.\n");
    return;
  }

  printTable(dangling, [
    { key: "tenant_id", header: "TENANT" },
    { key: "folder", header: "FOLDER" },
    { key: "name", header: "NAME" },
    { key: "jid", header: "JID" },
    { key: "status", header: "HTTP" },
  ]);

  if (doDelete) {
    process.stdout.write(`\nDeleting ${dangling.length} dangling group(s)...\n`);
    for (const row of dangling) {
      // Remove from DB
      const del = sqliteExec(dbPath, `DELETE FROM registered_groups WHERE jid = ${sqliteQuote(row.jid)}`);
      if (!del.ok) {
        process.stderr.write(`  FAILED to remove ${row.folder} from DB: ${del.error}\n`);
        continue;
      }
      // Delete folder
      const tenantHome = `/home/${row.tenant_id}`;
      const groupFolder = `${tenantHome}/nanoclaw/groups/${row.folder.split("/").slice(1).join("/")}`;
      const rmRes = run("sudo", ["rm", "-rf", groupFolder]);
      if (!rmRes.ok) {
        process.stderr.write(`  WARN: failed to delete folder ${groupFolder}\n`);
      }
      process.stdout.write(`  Deleted ${row.folder}\n`);
    }
    process.stdout.write("Done. Run \`treehouse nanoclaw hup\` to reload nanoclaw.\n");
  } else {
    process.stdout.write(`\nRun with --delete to remove these ${dangling.length} dangling group(s).\n`);
  }
}

function cmdTenantsUsage({ date, json }) {
  // Backed by MC API
  // eslint-disable-next-line no-undef
  return (async () => {
    const qp = date ? `?date=${encodeURIComponent(date)}` : "";
    const res = await fetchJson(`/api/tenants/usage${qp}`);
    if (!res.ok) die(res.error);
    if (json) {
      process.stdout.write(`${JSON.stringify({ source: "mc", results: res.data }, null, 2)}\n`);
      return;
    }
    printTable(res.data, [
      { key: "tenant_id", header: "TENANT" },
      { key: "runs", header: "RUNS" },
      { key: "duration_ms", header: "DURATION_MS" },
      { key: "input_tokens", header: "INPUT_TOKENS" },
      { key: "daily_requests", header: "LIMIT_REQ" },
      { key: "daily_duration_ms", header: "LIMIT_MS" },
    ]);
  })();
}

function cmdTenantsPorts({ json }) {
  return (async () => {
    const res = await fetchJson("/api/tenants/ports");
    if (!res.ok) die(res.error);
    if (json) {
      process.stdout.write(`${JSON.stringify({ source: "mc", results: res.data }, null, 2)}\n`);
      return;
    }
    printTable(res.data, [
      { key: "tenant_id", header: "TENANT" },
      { key: "port_range_start", header: "PORT_START" },
      { key: "port_range_size", header: "PORT_SIZE" },
    ]);
  })();
}

function cmdAppsList({ tenantFilter, json }) {
  return (async () => {
    const qp = tenantFilter ? `?tenant=${encodeURIComponent(tenantFilter)}` : "";
    const res = await fetchJson(`/api/apps/list${qp}`);
    if (!res.ok) die(res.error);
    if (json) {
      process.stdout.write(`${JSON.stringify({ source: "mc", results: res.data }, null, 2)}\n`);
      return;
    }
    printTable(res.data, [
      { key: "tenant", header: "TENANT" },
      { key: "app", header: "APP" },
      { key: "app_dir", header: "APP_DIR" },
      { key: "note", header: "NOTE" },
    ]);
  })();
}

function renderAppsPsTable(results) {
  const flat = [];
  for (const r of results) {
    const rows = Array.isArray(r.rows) ? r.rows : [];
    if (rows.length === 0) {
      flat.push({
        tenant: r.tenant,
        name: "",
        image: "",
        status: r.note ?? (r.ok ? "(no containers)" : "(error)"),
        ports: r.error ?? "",
      });
      continue;
    }
    for (const row of rows) {
      flat.push({ tenant: r.tenant, ...row });
    }
  }

  printTable(flat, [
    { key: "tenant", header: "TENANT" },
    { key: "name", header: "NAME" },
    { key: "image", header: "IMAGE" },
    { key: "status", header: "STATUS" },
    { key: "ports", header: "PORTS" },
  ]);
}

async function cmdAppsPs({ json }) {
  // Prefer MC API as the single source of truth; fall back to local enumeration if MC is unavailable.
  const mc = await fetchJson("/api/apps/ps");
  const mcResults = mc.ok ? mc.data : null;

  if (mcResults) {
    if (json) {
      process.stdout.write(`${JSON.stringify({ source: "mc", results: mcResults }, null, 2)}\n`);
      return;
    }
    return renderAppsPsTable(mcResults);
  }

  const tenants = listTenantsFromTenantsNix();
  const localResults = [];
  for (const t of tenants) {
    const ps = nerdctlPsAsTenant(t);
    localResults.push({
      tenant: t.tenant,
      uid: t.uid,
      socket: containerdSocket(t.uid),
      ...ps,
    });
  }

  if (json) {
    process.stdout.write(`${JSON.stringify({ source: "local", error: mc.error, results: localResults }, null, 2)}\n`);
    return;
  }
  process.stderr.write(`(MC unavailable: ${mc.error}; using local enumeration)\n`);
  return renderAppsPsTable(localResults);
}

async function fetchAppInventory({ tenantId, app }) {
  const qp = new URLSearchParams({ tenant: tenantId });
  if (app) qp.set("app", app);
  const res = await fetchJson(`/api/apps/status?${qp.toString()}`);
  if (!res.ok) die(res.error);
  return res.data;
}

function appIssueSummary(item) {
  if (!Array.isArray(item?.issues) || item.issues.length === 0) return "ok";
  return item.issues.map((issue) => issue.code).join(",");
}

function appHealthIndicator(item) {
  const issues = Array.isArray(item?.issues) ? item.issues : [];
  if (issues.some((issue) => issue.severity === "fail")) return "✗";
  if (issues.some((issue) => issue.severity === "warn")) return "⚠";
  return "✓";
}

function formatUnitState(unit) {
  if (!unit?.present) return "missing";
  return `${unit.activeState}/${unit.subState}`;
}

function statusForDisplay(item) {
  return item?.effectiveStatus || item?.latestStatus || null;
}

function summarizeAppPresence(item) {
  return {
    dbPresent: !!item?.db?.present,
    caddyPresent: !!item?.caddy?.present,
    unitPresent: !!item?.unit?.present,
    containerCount: Array.isArray(item?.containers) ? item.containers.length : 0,
  };
}

async function requestAppDeploy({ tenantId, app, forceRebuild = false }) {
  const res = await fetchApi({
    method: "POST",
    urlPath: "/api/apps/deploy",
    body: { tenant: tenantId, app, forceRebuild },
  });
  if (!res.ok) {
    throw new Error(res.error);
  }
  return res.data;
}

async function triggerAppDeploy(args) {
  try {
    await requestAppDeploy(args);
  } catch (error) {
    die(error?.message || String(error));
  }
}

async function requestAppRemove({ tenantId, app, timeoutMs = 5000 }) {
  const res = await fetchApi({
    method: "POST",
    urlPath: "/api/apps/remove",
    body: { tenant: tenantId, app },
    timeoutMs,
  });
  if (!res.ok) {
    throw new Error(res.error);
  }
  return res.data;
}

async function triggerAppRemove(args) {
  try {
    await requestAppRemove(args);
  } catch (error) {
    die(error?.message || String(error));
  }
}

async function waitForAppState({ tenantId, app, description, timeoutMs = 180000, predicate }) {
  const deadline = Date.now() + timeoutMs;
  let lastInventory = null;
  while (Date.now() < deadline) {
    lastInventory = await fetchAppInventory({ tenantId, app });
    const item = Array.isArray(lastInventory.apps) ? lastInventory.apps.find((entry) => entry.appName === app) : null;
    if (await predicate(item, lastInventory)) {
      return { inventory: lastInventory, item };
    }
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
  throw new Error(`Timed out waiting for ${description}`);
}

async function fetchUrlText(url) {
  const res = await fetch(url, {
    redirect: "follow",
    signal: AbortSignal.timeout(10000),
    headers: { "User-Agent": "Treehouse-CLI/1.0" },
  });
  const text = await res.text();
  return { ok: res.ok, status: res.status, text };
}

async function cmdAppsDeploy({ tenantId, app, forceRebuild }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!app) die("Missing --app <app>");
  await triggerAppDeploy({ tenantId, app, forceRebuild });
  process.stdout.write(forceRebuild ? "Deploy triggered (force rebuild).\n" : "Deploy triggered.\n");
}

async function cmdAppsRemove({ tenantId, app, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!app) die("Missing --app <app>");
  await confirmOrDie({ prompt: `Remove app ${app} for tenant ${tenantId}?`, yesFlag: yes });
  await triggerAppRemove({ tenantId, app });
  process.stdout.write("Remove triggered.\n");
}

async function cmdAppsStatus({ tenantId, app }) {
  if (!tenantId) die("Missing --tenant <id>");
  const inventory = await fetchAppInventory({ tenantId, app });
  const apps = Array.isArray(inventory.apps) ? inventory.apps : [];
  if (apps.length === 0) {
    process.stdout.write("No app status available.\n");
    return;
  }

  printTable(
    apps.map((item) => ({
      health: appHealthIndicator(item),
      app: item.appName,
      subdomain: item.subdomain || "",
      db_port: item.db?.present ? String(item.db.config?.port ?? "") : "",
      caddy_port: item.caddy?.present ? String(item.caddy.port ?? "") : "",
      unit: formatUnitState(item.unit),
      containers: String((item.containers || []).length),
      status: statusForDisplay(item)?.stage || "",
      url: item.publicUrl || "",
      issues: appIssueSummary(item),
    })),
    [
      { key: "health", header: "" },
      { key: "app", header: "APP" },
      { key: "subdomain", header: "SUBDOMAIN" },
      { key: "db_port", header: "DB PORT" },
      { key: "caddy_port", header: "CADDY" },
      { key: "unit", header: "UNIT" },
      { key: "containers", header: "CTR" },
      { key: "status", header: "STAGE" },
      { key: "url", header: "URL" },
      { key: "issues", header: "ISSUES" },
    ],
  );

  const problematic = apps.filter((item) => Array.isArray(item.issues) && item.issues.length > 0);
  if (problematic.length > 0) {
    process.stdout.write(`\n--- issues ---\n`);
    for (const item of problematic) {
      for (const issue of item.issues) {
        process.stdout.write(`${item.appName}: [${issue.severity}] ${issue.code} - ${issue.message}\n`);
      }
    }
  }

  if (app) {
    const item = apps[0];
    if (item.effectiveStatus) {
      process.stdout.write(`\n--- effective status ---\n`);
      process.stdout.write(`${JSON.stringify(item.effectiveStatus, null, 2)}\n`);
    }
    if (item.latestStatus) {
      process.stdout.write(`\n--- latest status ---\n`);
      process.stdout.write(`${JSON.stringify(item.latestStatus, null, 2)}\n`);
    }
    if (Array.isArray(item.statusArtifacts) && item.statusArtifacts.length > 0) {
      process.stdout.write(`\n--- status artifacts ---\n`);
      for (const artifact of item.statusArtifacts) {
        process.stdout.write(`${artifact.group}: ${artifact.path}\n`);
      }
    }
  }
}

async function cmdAppsPorts({ tenantId }) {
  if (!tenantId) die("Missing --tenant <id>");
  const tenantRes = await fetchJson(`/api/tenants/${tenantId}`);
  const portRangeStart = tenantRes?.data?.port_range_start ?? null;
  const portRangeSize = tenantRes?.data?.port_range_size ?? 20;
  const portRange = portRangeStart != null
    ? `${portRangeStart}-${portRangeStart + portRangeSize - 1}`
    : null;
  if (portRange) process.stdout.write(`Port range: ${portRange}\n\n`);

  const inventory = await fetchAppInventory({ tenantId });
  const portMap = new Map();
  for (const item of inventory.apps || []) {
    const dbPort = Number(item.db?.config?.port);
    if (Number.isInteger(dbPort) && dbPort > 0) {
      const entry = portMap.get(dbPort) || { dbApps: [], caddyApps: [], containers: [] };
      entry.dbApps.push(item.appName);
      portMap.set(dbPort, entry);
    }
    if (item.caddy?.present && Number.isInteger(item.caddy.port)) {
      const entry = portMap.get(item.caddy.port) || { dbApps: [], caddyApps: [], containers: [] };
      entry.caddyApps.push(item.subdomain || item.appName);
      portMap.set(item.caddy.port, entry);
    }
    for (const container of item.containers || []) {
      for (const hostPort of container.hostPorts || []) {
        const entry = portMap.get(hostPort) || { dbApps: [], caddyApps: [], containers: [] };
        entry.containers.push(`${container.name} (${container.status})`);
        portMap.set(hostPort, entry);
      }
    }
  }

  const ports = [...portMap.keys()].sort((a, b) => a - b);
  if (ports.length === 0) {
    process.stdout.write("No ports allocated or containers running.\n");
    return;
  }

  printTable(
    ports.map((port) => {
      const entry = portMap.get(port);
      const flags = [];
      if ((entry.dbApps || []).length > 1) flags.push("DB-CONFLICT");
      if ((entry.dbApps || []).length === 0 && (entry.containers || []).length > 0) flags.push("LIVE-ONLY");
      return {
        port: String(port),
        db: [...new Set(entry.dbApps)].join(","),
        caddy: [...new Set(entry.caddyApps)].join(","),
        live: [...new Set(entry.containers)].join(", "),
        flags: flags.join(","),
      };
    }),
    [
      { key: "port", header: "PORT" },
      { key: "db", header: "DB APP" },
      { key: "caddy", header: "CADDY APP" },
      { key: "live", header: "LIVE" },
      { key: "flags", header: "FLAGS" },
    ],
  );
}

async function cmdAppsReconcile({ tenantId, app, fix, json }) {
  if (!tenantId) die("Missing --tenant <id>");
  const res = await fetchApi({
    method: "POST",
    urlPath: "/api/apps/reconcile",
    body: { tenant: tenantId, app, fix },
    timeoutMs: 30000,
  });
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  process.stdout.write(`Apps: ${res.data.summary.apps}  warn: ${res.data.summary.warn}  fail: ${res.data.summary.fail}\n`);
  if (Array.isArray(res.data.actions) && res.data.actions.length > 0) {
    process.stdout.write(`\n--- actions ---\n`);
    for (const action of res.data.actions) {
      const outcome = action.ok === false ? "✗" : "✓";
      process.stdout.write(`${outcome} ${action.type} ${action.app ? `${action.app} ` : ""}${action.path || action.output || action.error || ""}\n`);
    }
  }
}

async function cmdAppsSmoke({ tenantId, app, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  await confirmOrDie({ prompt: `Run app lifecycle smoke test against tenant ${tenantId}?`, yesFlag: yes });
  const preflight = await fetchApi({
    method: "POST",
    urlPath: "/api/apps/reconcile",
    body: { tenant: tenantId, fix: true },
    timeoutMs: 30000,
  });
  if (!preflight.ok) die(preflight.error);

  const pathMod = await import("node:path");
  const path = pathMod.default;
  const appName = app || `hosting-smoke-${Date.now().toString(36)}`;
  const appDir = path.join("/home", tenantId, "workspace", "apps", appName);
  const appYmlPath = path.join(appDir, "app.yml");
  const versionPath = path.join(appDir, "version.txt");
  const publicUrl = `https://${appName}.${tenantId}.devitltd.uk`;
  const baselineInventory = await fetchAppInventory({ tenantId });
  const baselineApps = new Map(
    (baselineInventory.apps || [])
      .filter((item) => item.appName !== appName)
      .map((item) => [item.appName, summarizeAppPresence(item)]),
  );
  if ((baselineInventory.apps || []).some((item) => item.appName === appName)) {
    die(`App ${tenantId}/${appName} already exists; choose an unused app name for the smoke test.`);
  }

  function runCheckedCommand(cmd, args, options = {}) {
    const result = spawnSync(cmd, args, {
      encoding: "utf8",
      maxBuffer: 20 * 1024 * 1024,
      ...options,
    });
    if (result.error) throw result.error;
    if (result.status !== 0) {
      throw new Error((result.stderr || result.stdout || `${cmd} ${args.join(" ")} failed`).trim());
    }
    return result;
  }

  function runAsTenant(args, options = {}) {
    return runCheckedCommand("sudo", ["-u", tenantId, ...args], options);
  }

  function writeTenantFile(targetPath, content) {
    runAsTenant(["mkdir", "-p", path.dirname(targetPath)]);
    runAsTenant(["tee", targetPath], { input: content });
  }

  function readTenantFile(targetPath) {
    return runAsTenant(["cat", targetPath]).stdout || "";
  }

  function removeTenantPath(targetPath) {
    runCheckedCommand("sudo", ["rm", "-rf", targetPath]);
  }

  async function verifyPublicBody(expected, timeoutMs = 120000) {
    const deadline = Date.now() + timeoutMs;
    let lastFailure = "no response";
    while (Date.now() < deadline) {
      try {
        const result = await fetchUrlText(publicUrl);
        if (result.ok && result.text.includes(expected)) return;
        lastFailure = `HTTP ${result.status}`;
      } catch (error) {
        lastFailure = error?.message || String(error);
      }
      await new Promise((resolve) => setTimeout(resolve, 2000));
    }
    throw new Error(`expected ${publicUrl} to include "${expected}", got ${lastFailure}`);
  }

  async function verifyExistingAppsUnaffected() {
    const inventory = await fetchAppInventory({ tenantId });
    for (const [existingApp, baseline] of baselineApps.entries()) {
      const item = (inventory.apps || []).find((entry) => entry.appName === existingApp);
      const after = summarizeAppPresence(item);
      if (baseline.dbPresent && !after.dbPresent) {
        throw new Error(`existing app ${existingApp} lost its DB registration during smoke test`);
      }
      if (baseline.caddyPresent && !after.caddyPresent) {
        throw new Error(`existing app ${existingApp} lost its Caddy route during smoke test`);
      }
      if (baseline.unitPresent && !after.unitPresent) {
        throw new Error(`existing app ${existingApp} lost its systemd unit during smoke test`);
      }
      if (baseline.containerCount > 0 && after.containerCount === 0) {
        throw new Error(`existing app ${existingApp} lost its running containers during smoke test`);
      }
    }
  }

  async function cleanup() {
    try { await requestAppRemove({ tenantId, app: appName, timeoutMs: 15000 }); } catch {}
    try {
      await waitForAppState({
        tenantId,
        app: appName,
        description: "app removal",
        timeoutMs: 120000,
        predicate: async (item) => !item || (!item.db?.present && !item.caddy?.present && !item.unit?.present && (item.containers || []).length === 0),
      });
    } catch {}
    try { removeTenantPath(appDir); } catch {}
    try {
      await fetchApi({
        method: "POST",
        urlPath: "/api/apps/reconcile",
        body: { tenant: tenantId, app: appName, fix: true },
        timeoutMs: 30000,
      });
    } catch {}
  }

  try {
    runAsTenant(["mkdir", "-p", appDir]);
    writeTenantFile(
      path.join(appDir, "Dockerfile"),
      [
        "FROM docker.io/library/node:20-alpine",
        "WORKDIR /app",
        "COPY server.js version.txt ./",
        "CMD [\"node\", \"server.js\"]",
        "",
      ].join("\n"),
    );
    writeTenantFile(
      path.join(appDir, "server.js"),
      [
        "const http = require('http');",
        "const fs = require('fs');",
        "const port = Number(process.env.PORT || 3000);",
        "http.createServer((_req, res) => {",
        "  const version = fs.readFileSync('/app/version.txt', 'utf8').trim();",
        "  res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });",
        "  res.end(`treehouse-smoke:${version}`);",
        "}).listen(port, '0.0.0.0');",
        "",
      ].join("\n"),
    );
    writeTenantFile(versionPath, "v1\n");
    writeTenantFile(
      appYmlPath,
      [
        "services:",
        "  web:",
        "    build:",
        "      context: .",
        "      dockerfile: Dockerfile",
        "    internal_port: 3000",
        "    public: true",
        "    environment:",
        "      PORT: 3000",
        "",
      ].join("\n"),
    );

    await requestAppDeploy({ tenantId, app: appName, forceRebuild: false });
    const initialDeploy = await waitForAppState({
      tenantId,
      app: appName,
      description: "initial deploy",
      predicate: async (item) =>
        !!item &&
        item.db?.present &&
        item.caddy?.present &&
        item.unit?.present &&
        (item.containers || []).length > 0,
    });
    const initialPort = Number(initialDeploy.item?.db?.config?.port);
    if (!Number.isInteger(initialPort) || initialPort < 1) {
      throw new Error("smoke app did not get a valid allocated host port");
    }
    if (Number(initialDeploy.item?.caddy?.port) !== initialPort) {
      throw new Error("smoke app DB and Caddy ports diverged after deploy");
    }
    await verifyPublicBody("treehouse-smoke:v1");
    const deployedSpec = readTenantFile(appYmlPath);
    if (/^\s+ports:\s*$/m.test(deployedSpec)) {
      throw new Error("app.yml was mutated with a host ports binding during deploy");
    }
    await verifyExistingAppsUnaffected();

    writeTenantFile(versionPath, "v2\n");
    await requestAppDeploy({ tenantId, app: appName, forceRebuild: true });
    const updatedDeploy = await waitForAppState({
      tenantId,
      app: appName,
      description: "updated deploy",
      predicate: async (item) => {
        const result = await fetchUrlText(publicUrl);
        return (
          !!item &&
          result.ok &&
          result.text.includes("treehouse-smoke:v2") &&
          Number(item.db?.config?.port) === initialPort &&
          Number(item.caddy?.port) === initialPort
        );
      },
    });
    if (Number(updatedDeploy.item?.db?.config?.port) !== initialPort) {
      throw new Error("smoke app host port changed across deploy update");
    }
    await verifyExistingAppsUnaffected();

    const restartRes = await fetchApi({
      method: "POST",
      urlPath: "/api/apps/restart",
      body: { tenant: tenantId, app: appName },
      timeoutMs: 30000,
    });
    if (!restartRes.ok) {
      throw new Error(restartRes.error);
    }
    await waitForAppState({
      tenantId,
      app: appName,
      description: "restart recovery",
      predicate: async (item) =>
        !!item &&
        item.unit?.present &&
        item.unit.activeState !== "failed" &&
        (item.containers || []).length > 0,
    });
    await verifyPublicBody("treehouse-smoke:v2");
    await verifyExistingAppsUnaffected();

    await requestAppRemove({ tenantId, app: appName, timeoutMs: 15000 });
    await waitForAppState({
      tenantId,
      app: appName,
      description: "remove cleanup",
      predicate: async (item) =>
        !item ||
        (
          !item.db?.present &&
          !item.caddy?.present &&
          !item.unit?.present &&
          (item.containers || []).length === 0
        ),
    });

    removeTenantPath(appDir);
    const reconcileRes = await fetchApi({
      method: "POST",
      urlPath: "/api/apps/reconcile",
      body: { tenant: tenantId, app: appName, fix: true },
      timeoutMs: 30000,
    });
    if (!reconcileRes.ok) {
      throw new Error(reconcileRes.error);
    }
    const finalInventory = await fetchAppInventory({ tenantId, app: appName });
    const finalItem = (finalInventory.apps || []).find((item) => item.appName === appName);
    if (finalItem && (finalItem.db?.present || finalItem.caddy?.present || finalItem.unit?.present || (finalItem.containers || []).length > 0 || (finalItem.statusArtifacts || []).length > 0)) {
      throw new Error("app artifacts remained after remove/reconcile");
    }
    await verifyExistingAppsUnaffected();

    process.stdout.write(`Smoke test passed for ${tenantId}/${appName}\n`);
    process.stdout.write(`${publicUrl}\n`);
  } catch (error) {
    await cleanup();
    die(error?.message || String(error));
  }
}

function cmdAppsLogs({ tenantId, name, tail, follow }) {
  if (follow) die("--follow not supported via MC API yet");
  return (async () => {
    if (!tenantId) die("Missing --tenant <id>");
    if (!name) die("Missing --name <container>");
    const qp = new URLSearchParams({ tenant: tenantId, name });
    if (tail) qp.set("tail", String(tail));
    const res = await fetchJson(`/api/apps/logs?${qp.toString()}`);
    if (!res.ok) die(res.error);
    process.stdout.write(`${res.data.output}\n`);
  })();
}

async function cmdAppsRestart({ tenantId, app, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!app) die("Missing --app <app>");

  await confirmOrDie({ prompt: `Restart nanoclaw-app-${app}.service for tenant ${tenantId}?`, yesFlag: yes });
  const res = await fetchApi({
    method: "POST",
    urlPath: "/api/apps/restart",
    body: { tenant: tenantId, app },
    timeoutMs: 30000,
  });
  if (!res.ok) die(res.error);
}

async function cmdAppsAuth({ tenantId, app, subaction, user, password, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!app) die("Missing --app <app>");
  if (subaction === "set") {
    if (!user) die("Missing --user <username>");
    if (!password) die("Missing --password <password>");
    await confirmOrDie({ prompt: `Set basic auth on app "${app}" for tenant ${tenantId}?`, yesFlag: yes });
    const res = await fetchApi({ method: "POST", urlPath: "/api/apps/auth", body: { tenant: tenantId, subdomain: app, user, password } });
    if (!res.ok) die(res.error);
    process.stdout.write(`Basic auth set: user="${res.data.user}" on https://${app}.${tenantId}.devitltd.uk\n`);
  } else if (subaction === "remove") {
    await confirmOrDie({ prompt: `Remove basic auth from app "${app}" for tenant ${tenantId}?`, yesFlag: yes });
    const res = await fetchApi({ method: "POST", urlPath: "/api/apps/auth", body: { tenant: tenantId, subdomain: app, remove: true } });
    if (!res.ok) die(res.error);
    process.stdout.write(`Basic auth removed from https://${app}.${tenantId}.devitltd.uk\n`);
  } else {
    die("Usage:\n  treehouse apps auth set --tenant <id> --app <app> --user <user> --password <pass>\n  treehouse apps auth remove --tenant <id> --app <app>");
  }
}

async function cmdAppsImages({ tenantFilter, all, json }) {
  const allTenants = listTenantsFromTenantsNix();
  const tenants = tenantFilter ? allTenants.filter((t) => t.tenant === tenantFilter) : allTenants;
  if (tenants.length === 0) die(tenantFilter ? `Tenant not found: ${tenantFilter}` : "No tenants found");

  const results = [];
  for (const t of tenants) {
    const imgs = nerdctlImagesAsTenant({ ...t, all });
    results.push({ tenant: t.tenant, uid: t.uid, ...imgs });
  }

  if (json) {
    process.stdout.write(`${JSON.stringify(results, null, 2)}\n`);
    return;
  }

  const flat = [];
  for (const r of results) {
    const rows = Array.isArray(r.rows) ? r.rows : [];
    if (rows.length === 0) {
      flat.push({
        tenant: r.tenant,
        repository: r.note ?? (r.ok ? "(no images)" : "(error)"),
        tag: "",
        id: "",
        size: r.error ?? "",
        created: "",
      });
      continue;
    }
    for (const row of rows) {
      flat.push({ tenant: r.tenant, ...row });
    }
  }

  printTable(flat, [
    { key: "tenant", header: "TENANT" },
    { key: "repository", header: "REPOSITORY" },
    { key: "tag", header: "TAG" },
    { key: "id", header: "IMAGE ID" },
    { key: "size", header: "SIZE" },
    { key: "created", header: "CREATED" },
  ]);
}

async function cmdAppsImagePrune({ tenantFilter, all, yes }) {
  const allTenants = listTenantsFromTenantsNix();
  const tenants = tenantFilter ? allTenants.filter((t) => t.tenant === tenantFilter) : allTenants;
  if (tenants.length === 0) die(tenantFilter ? `Tenant not found: ${tenantFilter}` : "No tenants found");

  const scope = tenantFilter ? `tenant ${tenantFilter}` : "all tenants";
  const mode = all ? "all unused images" : "dangling images";
  await confirmOrDie({ prompt: `Prune ${mode} for ${scope}?`, yesFlag: yes });

  for (const t of tenants) {
    const sock = containerdSocket(t.uid);
    const allFlag = all ? " -a" : "";
    const cmd = [
      "-u",
      t.tenant,
      "sh",
      "-lc",
      [
        "cd ~",
        `sock='${sock}'`,
        `if [ ! -S "$sock" ]; then echo '__NOSOCK__' ; exit 0; fi`,
        `XDG_RUNTIME_DIR=/run/user/${t.uid} CONTAINERD_ADDRESS=$sock nerdctl image prune${allFlag} --force`,
      ].join(" && "),
    ];

    process.stdout.write(`\n=== ${t.tenant} ===\n`);
    const res = run("sudo", cmd);
    if (!res.ok) {
      process.stderr.write(`  error: ${res.stderr.trim() || `nerdctl failed (${res.status})`}\n`);
      continue;
    }
    if (res.stdout.trim() === "__NOSOCK__") {
      process.stdout.write(`  (no containerd socket)\n`);
      continue;
    }
    process.stdout.write(res.stdout || "  (nothing to prune)\n");
  }
}

async function cmdAppsImageRm({ tenantFilter, image, yes }) {
  if (!tenantFilter) die("Missing --tenant <id>");
  if (!image) die("Missing --image <image>");

  const allTenants = listTenantsFromTenantsNix();
  const t = allTenants.find((t) => t.tenant === tenantFilter);
  if (!t) die(`Tenant not found: ${tenantFilter}`);

  await confirmOrDie({ prompt: `Remove image ${image} from tenant ${tenantFilter}?`, yesFlag: yes });

  const sock = containerdSocket(t.uid);
  const cmd = [
    "-u",
    t.tenant,
    "sh",
    "-lc",
    [
      "cd ~",
      `sock='${sock}'`,
      `if [ ! -S "$sock" ]; then echo '__NOSOCK__' ; exit 0; fi`,
      `XDG_RUNTIME_DIR=/run/user/${t.uid} CONTAINERD_ADDRESS=$sock nerdctl rmi ${image}`,
    ].join(" && "),
  ];

  const res = run("sudo", cmd);
  if (!res.ok) die(res.stderr.trim() || `nerdctl failed (${res.status})`);
  if (res.stdout.trim() === "__NOSOCK__") die(`No containerd socket for tenant ${tenantFilter}`);
  process.stdout.write(res.stdout);
}

function cmdAppsCleanStuck({ tenantFilter, app, yes }) {
  if (!tenantFilter) die("Missing --tenant <id>");

  const allTenants = listTenantsFromTenantsNix();
  const t = allTenants.find((t) => t.tenant === tenantFilter);
  if (!t) die(`Tenant not found: ${tenantFilter}`);

  // Build project prefix: nanoclaw-<tenant>-<app> or nanoclaw-<tenant> (all apps)
  const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").replace(/-+/g, "-");
  const prefix = app
    ? `nanoclaw-${sanitize(t.tenant)}-${sanitize(app)}`
    : `nanoclaw-${sanitize(t.tenant)}`;

  const sock = containerdSocket(t.uid);

  // First: list stopped containers that would be cleaned
  const listRes = run("sudo", [
    "-u", t.tenant, "sh", "-lc",
    `cd ~ && sock='${sock}' && [ -S "$sock" ] && XDG_RUNTIME_DIR=/run/user/${t.uid} CONTAINERD_ADDRESS=$sock nerdctl ps -a --format "{{.Names}}\t{{.Status}}" 2>/dev/null || true`,
  ]);
  const stopped = (listRes.stdout || "")
    .split("\n")
    .map((l) => l.trimEnd())
    .filter((l) => l.startsWith(prefix + "-") && l.includes("Exited"));

  if (stopped.length === 0) {
    process.stdout.write(`No stopped containers found with prefix "${prefix}".\n`);
    return;
  }

  process.stdout.write(`Found ${stopped.length} stopped container(s):\n`);
  for (const line of stopped) process.stdout.write(`  ${line}\n`);
  process.stdout.write("\n");

  if (!yes) {
    const readline = require("node:readline");
    const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
    rl.question(`Fix oplocks and remove these containers? [y/N] `, (answer) => {
      rl.close();
      if (answer.trim().toLowerCase() !== "y") {
        process.stdout.write("Aborted.\n");
        process.exit(0);
      }
      doClean();
    });
    return;
  }
  doClean();

  function doClean() {
    const res = run("sudo", [
      "/run/current-system/sw/bin/nanoclaw-tenant-runtime",
      t.tenant,
      "clean-project-containers",
      prefix,
    ], { stdio: ["ignore", "pipe", "pipe"] });
    if (!res.ok) die(res.stderr.trim() || `clean-project-containers failed (${res.status})`);
    if (res.stderr.trim()) process.stderr.write(res.stderr + "\n");
    process.stdout.write(`Done. Removed stopped containers with prefix "${prefix}".\n`);
  }
}

function cmdRuntimePing({ tenantFilter, json }) {
  return (async () => {
    const qp = tenantFilter ? `?tenant=${encodeURIComponent(tenantFilter)}` : "";
    const res = await fetchJson(`/api/runtime/ping${qp}`);
    if (!res.ok) die(res.error);
    if (json) {
      process.stdout.write(`${JSON.stringify({ source: "mc", results: res.data }, null, 2)}\n`);
      return;
    }
    const rows = res.data.map((r) => ({
      tenant: r.tenant,
      socket: r.socket,
      status: r.note ?? (r.ok ? "ok" : "error"),
      error: r.error ?? "",
    }));
    printTable(rows, [
      { key: "tenant", header: "TENANT" },
      { key: "socket", header: "SOCKET" },
      { key: "status", header: "STATUS" },
      { key: "error", header: "ERROR" },
    ]);
  })();
}

function cmdRuntimeDf({ tenantFilter }) {
  return (async () => {
    if (!tenantFilter) die("Missing --tenant <id>");
    const res = await fetchJson(`/api/runtime/df?tenant=${encodeURIComponent(tenantFilter)}`);
    if (!res.ok) die(res.error);
    process.stdout.write(`\n===== ${tenantFilter} =====\n`);
    if (res.data.note) process.stdout.write(`${res.data.note}\n`);
    if (res.data.output) process.stdout.write(`${res.data.output}\n`);
  })();
}

async function cmdVolumesList({ json }) {
  const res = await fetchJson("/api/volumes");
  if (!res.ok) die(res.error);
  const volumes = res.data;
  if (json) {
    process.stdout.write(`${JSON.stringify(volumes, null, 2)}\n`);
    return;
  }
  const col = (s, w) => String(s ?? "-").padEnd(w);
  process.stdout.write(`${"TENANT".padEnd(16)} ${"LIMIT(MB)".padStart(10)} ${"IMG(MB)".padStart(9)} ${"USED(MB)".padStart(9)} ${"AVAIL(MB)".padStart(10)} ${"USE%".padStart(6)}\n`);
  for (const v of volumes) {
    const imgMb = v.img_bytes != null ? Math.round(v.img_bytes / 1024 / 1024) : null;
    const usePct = (v.used_mb != null && v.avail_mb != null)
      ? `${Math.round((v.used_mb / (v.used_mb + v.avail_mb)) * 100)}%`
      : "-";
    process.stdout.write(
      `${col(v.tenant_id, 16)} ${String(v.max_disk_mb ?? "-").padStart(10)} ${String(imgMb ?? "-").padStart(9)} ${String(v.used_mb ?? "-").padStart(9)} ${String(v.avail_mb ?? "-").padStart(10)} ${usePct.padStart(6)}\n`
    );
  }
}

async function cmdVolumesResize({ tenantId, sizeMb, yes }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!sizeMb) die("Missing --size <MB>");
  const mb = parseInt(sizeMb);
  if (!Number.isFinite(mb) || mb < 100) die("--size must be a number >= 100");

  // Show current state before confirming
  const cur = await fetchJson("/api/volumes");
  const v = cur.ok ? cur.data.find((x) => x.tenant_id === tenantId) : null;
  if (v) {
    const imgMb = v.img_bytes != null ? Math.round(v.img_bytes / 1024 / 1024) : "?";
    process.stdout.write(`Current: limit=${v.max_disk_mb}MB  img=${imgMb}MB  used=${v.used_mb ?? "?"}MB  avail=${v.avail_mb ?? "?"}MB\n`);
  }

  const shrinking = v?.max_disk_mb != null && mb < v.max_disk_mb;
  const prompt = shrinking
    ? `Shrink volume for '${tenantId}' to ${mb}MB? This requires unmounting — tenant apps will be briefly interrupted.`
    : `Resize volume for '${tenantId}' to ${mb}MB?`;
  await confirmOrDie({ prompt, yesFlag: yes });

  const res = await fetchApi({ method: "POST", urlPath: `/api/volumes/${encodeURIComponent(tenantId)}/resize`, body: { size_mb: mb } });
  if (!res.ok) die(res.error);
  process.stdout.write(`${res.data.message}\n`);
}

function cmdCaddyLint() {
  return (async () => {
    const res = await fetchJson("/api/caddy/lint");
    if (!res.ok) die(res.error);
    if (res.data.stdout) process.stdout.write(`${res.data.stdout}\n`);
    if (res.data.stderr) process.stderr.write(`${res.data.stderr}\n`);
  })();
}

function cmdCaddyReload() {
  return (async () => {
    const res = await fetchApi({ method: "POST", urlPath: "/api/caddy/reload" });
    if (!res.ok) die(res.error);
    process.stdout.write("Reloaded caddy.service\n");
  })();
}

function cmdNanoclawHup() {
  return (async () => {
    const res = await fetchApi({ method: "POST", urlPath: "/api/nanoclaw/hup" });
    if (!res.ok) die(res.error);
    process.stdout.write("Sent SIGHUP to nanoclaw.service\n");
  })();
}

function cmdNanoclawStatus() {
  return (async () => {
    const res = await fetchJson("/api/nanoclaw/status");
    if (!res.ok) die(res.error);
    process.stdout.write(`${res.data.output}\n`);
  })();
}

function cmdNanoclawLogs({ lines }) {
  return (async () => {
    const qp = lines ? `?lines=${encodeURIComponent(String(lines))}` : "";
    const res = await fetchJson(`/api/nanoclaw/logs${qp}`);
    if (!res.ok) die(res.error);
    process.stdout.write(`${res.data.output}\n`);
  })();
}

async function cmdNanoclawRestart({ yes }) {
  await confirmOrDie({
    prompt: "Restart nanoclaw.service? This kills active agent containers and interrupts ongoing sessions.",
    yesFlag: yes,
  });
  const res = await fetchApi({ method: "POST", urlPath: "/api/nanoclaw/restart", body: { confirm: true } });
  if (!res.ok) die(res.error);
  process.stdout.write("Restarted nanoclaw.service\n");
}

function cmdDoctor({ json = false } = {}) {
  return (async () => {
    const res = await fetchJson("/api/doctor", { timeoutMs: 15000 });
    if (!res.ok) die(res.error);

    const { nixos_version, checks = [], summary = {} } = res.data;

    if (json) {
      process.stdout.write(JSON.stringify(res.data, null, 2) + "\n");
      process.exit((summary.fail || 0) > 0 ? 2 : (summary.warn || 0) > 0 ? 1 : 0);
      return;
    }

    const SYM = { ok: "✓", warn: "⚠", fail: "✗" };
    const LABEL_W = 24;

    process.stdout.write("== treehouse doctor");
    if (nixos_version) process.stdout.write(`  [${nixos_version}]`);
    process.stdout.write(" ==\n\n");

    for (const c of checks) {
      const sym = SYM[c.status] ?? "?";
      const label = c.label.padEnd(LABEL_W);
      process.stdout.write(`${sym} ${label} ${c.message}\n`);
      if (c.status !== "ok" && c.remediation) {
        process.stdout.write(`  → ${c.remediation}\n`);
      }
    }

    const { ok = 0, warn = 0, fail = 0 } = summary;
    process.stdout.write(`\n${ok} ok  ${warn} warnings  ${fail} failures\n`);

    process.exit(fail > 0 ? 2 : warn > 0 ? 1 : 0);
  })();
}

function cmdBackupsStatus() {
  return (async () => {
    const res = await fetchJson("/api/backups/status");
    if (!res.ok) die(res.error);
    process.stdout.write(`${res.data.output}\n`);
  })();
}

async function cmdBackupsRun({ yes }) {
  await confirmOrDie({ prompt: "Run borgmatic backup now?", yesFlag: yes });
  const res = await fetchApi({ method: "POST", urlPath: "/api/backups/run" });
  if (!res.ok) die(res.error);
  if (res.data.output) process.stdout.write(`${res.data.output}\n`);
}

async function cmdMcRestart({ buildUi, detach, yes }) {
  await confirmOrDie({ prompt: "Restart Mission Control (MC) server?", yesFlag: yes });

  if (buildUi) {
    const uiRes = runInherit("sh", ["-lc", "cd /home/admin/treehouse/mc-ui && npm run build"]);
    if (!uiRes.ok) process.exit(1);
  }

  run("sudo", ["pkill", "-f", "server.mjs"]);

  if (detach) {
    const res = runInherit("sudo", [
      "sh",
      "-lc",
      "cd /home/admin/treehouse/mc && nohup node server.mjs > /home/admin/treehouse/mc/server.log 2>&1 &",
    ]);
    if (!res.ok) process.exit(1);
    process.stdout.write("MC restarted (detached). Logs: /home/admin/treehouse/mc/server.log\n");
    return;
  }

  const res = runInherit("sudo", ["sh", "-lc", "cd /home/admin/treehouse/mc && node server.mjs"]);
  if (!res.ok) process.exit(1);
}

// --- tasks commands (direct SQLite — no MC API for tasks) ---

function fmtTs(iso) {
  if (!iso) return "";
  try {
    return new Date(iso).toLocaleString("en-GB", {
      year: "numeric", month: "short", day: "2-digit",
      hour: "2-digit", minute: "2-digit", second: "2-digit",
      hour12: false, timeZoneName: "short",
    });
  } catch {
    return iso;
  }
}

function fmtDuration(ms) {
  if (ms == null || ms === "") return "";
  const n = Number(ms);
  if (!Number.isFinite(n)) return "";
  if (n < 1000) return `${n}ms`;
  if (n < 60000) return `${(n / 1000).toFixed(1)}s`;
  return `${Math.floor(n / 60000)}m${Math.floor((n % 60000) / 1000)}s`;
}

async function cmdTasksList({ tenantId, json }) {
  const dbPath = nanoclawDbPath();
  const where = tenantId ? `WHERE st.tenant_id = ${sqliteQuote(tenantId)}` : "";
  const res = sqliteJson(
    dbPath,
    `SELECT
       st.id,
       st.tenant_id,
       st.group_folder,
       st.schedule_type,
       st.schedule_value,
       st.status,
       st.next_run,
       st.last_run,
       st.last_result,
       st.context_mode,
       st.created_at
     FROM scheduled_tasks st
     ${where}
     ORDER BY st.tenant_id, st.group_folder, st.created_at;`.trim(),
  );
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.rows, null, 2)}\n`);
    return;
  }
  if (res.rows.length === 0) {
    process.stdout.write("No scheduled tasks found.\n");
    return;
  }
  printTable(
    res.rows.map((r) => ({
      ...r,
      next_run: fmtTs(r.next_run),
      last_run: fmtTs(r.last_run),
      last_result: r.last_result ? String(r.last_result).slice(0, 40) : "",
    })),
    [
      { key: "id", header: "ID" },
      { key: "tenant_id", header: "TENANT" },
      { key: "group_folder", header: "GROUP" },
      { key: "status", header: "STATUS" },
      { key: "schedule_type", header: "TYPE" },
      { key: "schedule_value", header: "SCHEDULE" },
      { key: "next_run", header: "NEXT_RUN" },
      { key: "last_run", header: "LAST_RUN" },
      { key: "last_result", header: "LAST_RESULT" },
    ],
  );
}

async function cmdTasksShow({ taskId, json }) {
  if (!taskId) die("Missing --id <taskId>");
  const dbPath = nanoclawDbPath();
  const res = sqliteJson(dbPath, `SELECT * FROM scheduled_tasks WHERE id = ${sqliteQuote(taskId)} LIMIT 1;`);
  if (!res.ok) die(res.error);
  const row = res.rows[0];
  if (!row) die(`Task not found: ${taskId}`, 2);
  if (json) {
    process.stdout.write(`${JSON.stringify(row, null, 2)}\n`);
    return;
  }
  const display = Object.entries(row).map(([key, value]) => ({
    key,
    value: (key === "next_run" || key === "last_run" || key === "created_at") ? (fmtTs(value) || "") : (value ?? ""),
  }));
  printTable(display, [{ key: "key", header: "KEY" }, { key: "value", header: "VALUE" }]);
}

async function cmdTasksLogs({ taskId, limit, json }) {
  if (!taskId) die("Missing --id <taskId>");
  const dbPath = nanoclawDbPath();
  const limitClause = limit ? `LIMIT ${Number(limit)}` : "LIMIT 50";
  const res = sqliteJson(
    dbPath,
    `SELECT trl.id, trl.task_id, trl.run_at, trl.duration_ms, trl.status, trl.result, trl.error
     FROM task_run_logs trl
     WHERE trl.task_id = ${sqliteQuote(taskId)}
     ORDER BY trl.run_at DESC
     ${limitClause};`.trim(),
  );
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.rows, null, 2)}\n`);
    return;
  }
  if (res.rows.length === 0) {
    process.stdout.write("No run logs found for this task.\n");
    return;
  }
  printTable(
    res.rows.map((r) => ({
      ...r,
      run_at: fmtTs(r.run_at),
      duration_ms: fmtDuration(r.duration_ms),
      result: r.result ? String(r.result).slice(0, 50) : "",
      error: r.error ? String(r.error).slice(0, 50) : "",
    })),
    [
      { key: "id", header: "LOG_ID" },
      { key: "run_at", header: "RAN_AT" },
      { key: "duration_ms", header: "DURATION" },
      { key: "status", header: "STATUS" },
      { key: "result", header: "RESULT" },
      { key: "error", header: "ERROR" },
    ],
  );
}

async function cmdTasksWatch({ tenantId, interval }) {
  const dbPath = nanoclawDbPath();
  const pollMs = Math.max(2000, Number(interval || 5) * 1000);
  let lastId = 0;

  // Find the current max id so we only show new entries going forward
  const initRes = sqliteJson(dbPath, "SELECT COALESCE(MAX(id), 0) AS max_id FROM task_run_logs;");
  if (initRes.ok && initRes.rows[0]) lastId = Number(initRes.rows[0].max_id) || 0;

  const tenantJoin = tenantId
    ? `JOIN scheduled_tasks st ON st.id = trl.task_id AND st.tenant_id = ${sqliteQuote(tenantId)}`
    : "";

  process.stdout.write(`Watching task runs${tenantId ? ` for tenant ${tenantId}` : ""} (polling every ${pollMs / 1000}s)... Ctrl-C to stop\n\n`);

  const header = ["LOG_ID", "TASK_ID", "GROUP", "RAN_AT", "DURATION", "STATUS", "RESULT/ERROR"];
  process.stdout.write(header.map((h) => h.padEnd(h === "RESULT/ERROR" ? 60 : h === "GROUP" ? 20 : h === "RAN_AT" ? 25 : h === "TASK_ID" ? 36 : 10)).join("  ") + "\n");

  const poll = () => {
    const res = sqliteJson(
      dbPath,
      `SELECT trl.id, trl.task_id, st.group_folder, trl.run_at, trl.duration_ms, trl.status, trl.result, trl.error
       FROM task_run_logs trl
       JOIN scheduled_tasks st ON st.id = trl.task_id
       ${tenantJoin ? `AND st.tenant_id = ${sqliteQuote(tenantId)}` : ""}
       WHERE trl.id > ${lastId}
       ORDER BY trl.id ASC;`.trim(),
    );
    if (!res.ok) {
      process.stderr.write(`poll error: ${res.error}\n`);
    } else {
      for (const r of res.rows) {
        lastId = Math.max(lastId, Number(r.id));
        const summary = r.status === "error"
          ? (r.error || "error").slice(0, 60)
          : (r.result || "ok").slice(0, 60);
        const cols = [
          String(r.id).padEnd(10),
          String(r.task_id).padEnd(36),
          String(r.group_folder || "").padEnd(20),
          fmtTs(r.run_at).padEnd(25),
          fmtDuration(r.duration_ms).padEnd(10),
          String(r.status).padEnd(10),
          summary,
        ];
        process.stdout.write(cols.join("  ") + "\n");
      }
    }
    setTimeout(poll, pollMs);
  };

  poll();
  // Keep process alive
  process.stdin.resume();
}

async function cmdTasksPause({ taskId, yes }) {
  if (!taskId) die("Missing --id <taskId>");
  await confirmOrDie({ prompt: `Pause task ${taskId}?`, yesFlag: yes });
  const dbPath = nanoclawDbPath();
  const res = run("sudo", ["sqlite3", dbPath,
    `UPDATE scheduled_tasks SET status = 'paused' WHERE id = ${sqliteQuote(taskId)} AND status = 'active'; SELECT changes();`]);
  if (!res.ok) die(res.stderr.trim() || "sqlite3 failed");
  const changed = Number(res.stdout.trim());
  if (changed === 0) process.stdout.write(`No active task found with id ${taskId} (already paused, completed, or not found).\n`);
  else process.stdout.write(`Task ${taskId} paused.\n`);
}

async function cmdTasksResume({ taskId, yes }) {
  if (!taskId) die("Missing --id <taskId>");
  await confirmOrDie({ prompt: `Resume task ${taskId}?`, yesFlag: yes });
  const dbPath = nanoclawDbPath();
  const res = run("sudo", ["sqlite3", dbPath,
    `UPDATE scheduled_tasks SET status = 'active' WHERE id = ${sqliteQuote(taskId)} AND status = 'paused'; SELECT changes();`]);
  if (!res.ok) die(res.stderr.trim() || "sqlite3 failed");
  const changed = Number(res.stdout.trim());
  if (changed === 0) process.stdout.write(`No paused task found with id ${taskId}.\n`);
  else process.stdout.write(`Task ${taskId} resumed.\n`);
}

async function cmdTasksCancel({ taskId, yes }) {
  if (!taskId) die("Missing --id <taskId>");
  await confirmOrDie({ prompt: `Cancel and delete task ${taskId} (and all its run logs)?`, yesFlag: yes });
  const dbPath = nanoclawDbPath();
  const res = run("sudo", ["sqlite3", dbPath,
    `DELETE FROM task_run_logs WHERE task_id = ${sqliteQuote(taskId)}; DELETE FROM scheduled_tasks WHERE id = ${sqliteQuote(taskId)}; SELECT changes();`]);
  if (!res.ok) die(res.stderr.trim() || "sqlite3 failed");
  const changed = Number(res.stdout.trim());
  if (changed === 0) process.stdout.write(`Task ${taskId} not found.\n`);
  else process.stdout.write(`Task ${taskId} cancelled and deleted.\n`);
}

// --- domains commands (MC API — custom domain management) ---

async function cmdDomainsList({ tenantId, json }) {
  const qs = tenantId ? `?tenant_id=${encodeURIComponent(tenantId)}` : '';
  const res = await fetchJson(`/api/custom-domains${qs}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  if (res.data.length === 0) {
    process.stdout.write('No custom domains found.\n');
    return;
  }
  printTable(
    res.data.map((r) => ({ ...r, verified_at: fmtTs(r.verified_at), created_at: fmtTs(r.created_at) })),
    [
      { key: 'id', header: 'ID' },
      { key: 'tenant_id', header: 'TENANT' },
      { key: 'domain', header: 'DOMAIN' },
      { key: 'target', header: 'TARGET' },
      { key: 'status', header: 'STATUS' },
      { key: 'verified_at', header: 'VERIFIED' },
    ],
  );
}

async function cmdDomainsShow({ domainId, json }) {
  if (!domainId) die('Missing --id <domainId>');
  const res = await fetchJson(`/api/custom-domains/${encodeURIComponent(domainId)}`);
  if (!res.ok) die(res.error);
  if (json) {
    process.stdout.write(`${JSON.stringify(res.data, null, 2)}\n`);
    return;
  }
  const r = res.data;
  const display = Object.entries(r).map(([key, value]) => ({
    key,
    value: (key === 'created_at' || key === 'verified_at') ? (fmtTs(value) || '') : (value ?? ''),
  }));
  printTable(display, [{ key: 'key', header: 'KEY' }, { key: 'value', header: 'VALUE' }]);
  if (r.status === 'pending') {
    process.stdout.write(`\nTo verify ownership, add this DNS TXT record:\n`);
    process.stdout.write(`  Name:  _nanoclaw-verify.${r.domain}\n`);
    process.stdout.write(`  Value: ${r.verification_token}\n`);
    process.stdout.write(`\nVerification is checked every 5 minutes automatically.\n`);
  }
}

async function cmdDomainsAdd({ tenantId, domain, target }) {
  if (!tenantId) die('Missing --tenant <id>');
  if (!domain) die('Missing --domain <domain>');
  const res = await fetchApi({
    method: 'POST',
    urlPath: '/api/custom-domains',
    body: { tenant_id: tenantId, domain, target: target || 'dashboard' },
  });
  if (!res.ok) die(res.error);
  const r = res.data;
  process.stdout.write(`Domain registered: ${r.domain} (id: ${r.id})\n`);
  process.stdout.write(`Status: ${r.status}\n`);
  if (r.status === 'pending') {
    process.stdout.write(`\nAdd this DNS TXT record to verify ownership:\n`);
    process.stdout.write(`  Name:  _nanoclaw-verify.${r.domain}\n`);
    process.stdout.write(`  Value: ${r.verification_token}\n`);
    process.stdout.write(`\nVerification is checked every 5 minutes. Or run: treehouse domains activate --id ${r.id}\n`);
  }
}

async function cmdDomainsActivate({ domainId, yes }) {
  if (!domainId) die('Missing --id <domainId>');
  await confirmOrDie({ prompt: `Force-activate domain ${domainId} (bypasses DNS verification)?`, yesFlag: yes });
  const res = await fetchApi({ method: 'POST', urlPath: `/api/custom-domains/${encodeURIComponent(domainId)}/activate`, body: {} });
  if (!res.ok) die(res.error);
  process.stdout.write(`Domain ${res.data.domain} activated.\n`);
}

async function cmdDomainsRemove({ domainId, yes }) {
  if (!domainId) die('Missing --id <domainId>');
  await confirmOrDie({ prompt: `Delete custom domain ${domainId}?`, yesFlag: yes });
  const res = await fetchApi({ method: 'DELETE', urlPath: `/api/custom-domains/${encodeURIComponent(domainId)}`, body: {} });
  if (!res.ok) die(res.error);
  process.stdout.write(`Domain deleted.\n`);
}

// --- mctasks commands (MC API — project/work tracking tasks) ---

async function cmdMcTasksList({ status, json }) {
  const res = await fetchJson("/api/tasks");
  if (!res.ok) die(res.error);
  const filtered = status ? res.data.filter((r) => r.status === status) : res.data;
  if (json) {
    process.stdout.write(`${JSON.stringify(filtered, null, 2)}\n`);
    return;
  }
  if (filtered.length === 0) {
    process.stdout.write("No MC tasks found.\n");
    return;
  }
  printTable(
    filtered.map((r) => ({
      ...r,
      labels: Array.isArray(r.labels) ? r.labels.join(",") : String(r.labels || ""),
      comments: String(Array.isArray(r.comments) ? r.comments.length : 0),
      updated_at: fmtTs(r.updated_at),
    })),
    [
      { key: "id", header: "ID" },
      { key: "title", header: "TITLE" },
      { key: "status", header: "STATUS" },
      { key: "priority", header: "PRIORITY" },
      { key: "assigned_to", header: "ASSIGNED" },
      { key: "labels", header: "LABELS" },
      { key: "comments", header: "CMTS" },
      { key: "updated_at", header: "UPDATED" },
    ],
  );
}

async function cmdMcTasksShow({ taskId, json }) {
  if (!taskId) die("Missing --id <taskId>");
  const res = await fetchJson(`/api/tasks/${encodeURIComponent(taskId)}`);
  if (!res.ok) die(res.error);
  const row = res.data;
  if (json) {
    process.stdout.write(`${JSON.stringify(row, null, 2)}\n`);
    return;
  }
  const comments = Array.isArray(row.comments) ? row.comments : [];
  const display = Object.entries(row)
    .filter(([k]) => k !== "comments")
    .map(([key, value]) => ({
      key,
      value: (key === "created_at" || key === "updated_at") ? (fmtTs(value) || "")
           : Array.isArray(value) ? value.join(", ")
           : (value ?? ""),
    }));
  printTable(display, [{ key: "key", header: "KEY" }, { key: "value", header: "VALUE" }]);
  if (comments.length > 0) {
    process.stdout.write(`\nComments (${comments.length}):\n`);
    for (const c of comments) {
      process.stdout.write(`  [${fmtTs(c.created_at)}] ${c.author || "?"}: ${c.content}\n`);
    }
  }
}

async function cmdMcTasksCreate({ title, description, status, priority, assignedTo }) {
  if (!title) die("Missing --title <title>");
  const body = { title };
  if (description) body.description = description;
  if (status) body.status = status;
  if (priority) body.priority = priority;
  if (assignedTo) body.assigned_to = assignedTo;
  const res = await fetchApi({ method: "POST", urlPath: "/api/tasks", body });
  if (!res.ok) die(res.error);
  process.stdout.write(`Created MC task: ${res.data.id}\n`);
}

async function cmdMcTasksUpdate({ taskId, title, description, status, priority, assignedTo }) {
  if (!taskId) die("Missing --id <taskId>");
  const body = {};
  if (title !== null) body.title = title;
  if (description !== null) body.description = description;
  if (status !== null) body.status = status;
  if (priority !== null) body.priority = priority;
  if (assignedTo !== null) body.assigned_to = assignedTo;
  if (Object.keys(body).length === 0) die("Provide at least one field to update (--title, --description, --status, --priority, --assigned-to)");
  const res = await fetchApi({ method: "PUT", urlPath: `/api/tasks/${encodeURIComponent(taskId)}`, body });
  if (!res.ok) die(res.error);
  process.stdout.write(`Updated MC task: ${res.data.id}\n`);
}

async function cmdMcTasksComment({ taskId, text }) {
  if (!taskId) die("Missing --id <taskId>");
  if (!text) die("Missing --text <text>");
  const res = await fetchApi({ method: "POST", urlPath: `/api/tasks/${encodeURIComponent(taskId)}/comments`, body: { content: text, author: "cli" } });
  if (!res.ok) die(res.error);
  const count = Array.isArray(res.data.comments) ? res.data.comments.length : 0;
  process.stdout.write(`Comment added to task ${res.data.id}. Total comments: ${count}\n`);
}

async function cmdMcTasksDelete({ taskId, yes }) {
  if (!taskId) die("Missing --id <taskId>");
  await confirmOrDie({ prompt: `Delete MC task ${taskId}?`, yesFlag: yes });
  const res = await fetchApi({ method: "DELETE", urlPath: `/api/tasks/${encodeURIComponent(taskId)}`, body: {} });
  if (!res.ok) die(res.error);
  process.stdout.write(`Deleted MC task ${taskId}.\n`);
}

async function cmdUiScreenshot({ pathArg, out, fullPage }) {
  const base = mcBaseUrl();
  const url = new URL(pathArg || "/credentials", base);
  const outPath = out || "/tmp/mc-ui.png";

  const script = "/home/admin/treehouse/cli/scripts/ui_screenshot.py";
  if (!fs.existsSync(script)) die(`Missing screenshot script at ${script}`);

  const browsersPathRes = run("sh", [
    "-lc",
    "timeout 5s nix eval --raw --impure --expr '(import <nixpkgs> {}).playwright-driver.browsers'",
  ]);
  if (!browsersPathRes.ok) die(browsersPathRes.stderr.trim() || "Failed to resolve Playwright browsers path via nix");
  const browsersPath = browsersPathRes.stdout.trim();
  if (!browsersPath) die("Resolved empty Playwright browsers path");

  const args = [
    "30s",
    "python3",
    script,
    "--url",
    url.toString(),
    "--token",
    mcToken(),
    "--out",
    outPath,
    ...(fullPage ? ["--full-page"] : []),
  ];

  const res = runInheritEnv("timeout", args, {
    PLAYWRIGHT_BROWSERS_PATH: browsersPath,
    PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: "1",
  });
  if (!res.ok) process.exit(1);
  if (!fs.existsSync(outPath)) die(`Screenshot command succeeded but file was not created: ${outPath}`);
  process.stdout.write(`Wrote screenshot: ${outPath}\n`);
}

async function cmdAnnounce({ message }) {
  if (!message) die("Usage: treehouse announce <message>");
  let token = process.env.DISCORD_BOT_TOKEN;
  if (!token) {
    const envRes = run("sudo", ["cat", "/var/lib/nanoclaw/.env"]);
    if (envRes.ok) {
      const match = envRes.stdout.match(/^DISCORD_BOT_TOKEN=(.+)$/m);
      if (match) token = match[1].trim();
    }
  }
  if (!token) die("DISCORD_BOT_TOKEN not found in environment or /var/lib/nanoclaw/.env");
  const db = nanoclawDbPath();
  const sql = `SELECT jid, name, tenant_id FROM registered_groups WHERE folder LIKE '%/announcements'`;
  const result = sqliteJson(db, sql);
  if (!result.ok) die(`DB query failed: ${result.error}`);
  if (result.rows.length === 0) {
    process.stdout.write("No announcements channels found.\n");
    return;
  }
  let sent = 0;
  let failed = 0;
  for (const row of result.rows) {
    const channelId = row.jid.replace(/^dc:/, "");
    const url = `https://discord.com/api/v10/channels/${channelId}/messages`;
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" },
        body: JSON.stringify({ content: message }),
      });
      if (res.ok) {
        process.stdout.write(`  ✓ [${row.tenant_id}] #${row.name}\n`);
        sent++;
      } else {
        const body = await res.text();
        process.stdout.write(`  ✗ [${row.tenant_id}] #${row.name}: HTTP ${res.status} ${body}\n`);
        failed++;
      }
    } catch (e) {
      process.stdout.write(`  ✗ [${row.tenant_id}] #${row.name}: ${e?.message || e}\n`);
      failed++;
    }
  }
  process.stdout.write(`\nAnnouncement sent to ${sent} channel(s)${failed > 0 ? `, ${failed} failed` : ""}.\n`);
}

async function cmdBotsList({ json }) {
  const dbPath = nanoclawDbPath();
  const res = sqliteJson(
    dbPath,
    `SELECT id, name, discord_bot_token, discord_user_id FROM tenants WHERE discord_bot_token IS NOT NULL AND discord_bot_token != '' ORDER BY id;`
  );
  if (!res.ok) die(res.error);
  const rows = res.rows.map(r => ({
    tenant: r.id,
    name: r.name,
    discord_user_id: r.discord_user_id || '',
    token_preview: r.discord_bot_token ? r.discord_bot_token.slice(0, 20) + '...' : '',
  }));
  if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
  printTable(rows, [
    { key: 'tenant', header: 'TENANT' },
    { key: 'name', header: 'NAME' },
    { key: 'discord_user_id', header: 'DISCORD_USER_ID' },
    { key: 'token_preview', header: 'TOKEN' },
  ]);
}

async function cmdBotsSend({ tenantId, channel, message }) {
  if (!tenantId) die("Missing --tenant <id>");
  if (!channel) die("Missing --channel <jid-or-id>");
  if (!message) die("Missing --message <text>");

  const dbPath = nanoclawDbPath();
  const res = sqliteJson(dbPath, `SELECT discord_bot_token FROM tenants WHERE id = ${sqliteQuote(tenantId)};`);
  if (!res.ok) die(res.error);
  if (!res.rows.length || !res.rows[0].discord_bot_token) die(`Tenant "${tenantId}" has no Discord bot token`);

  const token = res.rows[0].discord_bot_token;
  const headers = { "Authorization": `Bot ${token}`, "Content-Type": "application/json" };
  let channelId = channel.startsWith("dc:") ? channel.slice(3) : channel;

  // Try sending directly; if 404, treat as user ID and open a DM channel first
  let response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
    method: "POST",
    headers,
    body: JSON.stringify({ content: message }),
  });

  if (response.status === 404) {
    const dmRes = await fetch("https://discord.com/api/v10/users/@me/channels", {
      method: "POST",
      headers,
      body: JSON.stringify({ recipient_id: channelId }),
    });
    const dmData = await dmRes.json();
    if (!dmRes.ok) die(`Discord API error ${dmRes.status} opening DM: ${dmData.message || JSON.stringify(dmData)}`);
    channelId = dmData.id;
    response = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, {
      method: "POST",
      headers,
      body: JSON.stringify({ content: message }),
    });
  }

  const data = await response.json();
  if (!response.ok) die(`Discord API error ${response.status}: ${data.message || JSON.stringify(data)}`);
  console.log(`✓ Message sent (id: ${data.id}) to channel ${channelId} via ${tenantId} bot`);
}

async function getDiscordBotToken(tenantId) {
  const dbPath = nanoclawDbPath();
  if (tenantId) {
    const res = sqliteJson(dbPath, `SELECT discord_bot_token FROM tenants WHERE id = ${sqliteQuote(tenantId)};`);
    if (!res.ok) die(res.error);
    if (!res.rows.length || !res.rows[0].discord_bot_token) die(`Tenant "${tenantId}" has no Discord bot token`);
    return res.rows[0].discord_bot_token;
  }
  // Fall back to treebot (from nanoclaw .env)
  let token = process.env.DISCORD_BOT_TOKEN;
  if (!token) {
    const envPath = "/home/admin/treehouse/nanoclaw/.env";
    const envRes = run("cat", [envPath]);
    if (envRes.ok) {
      const match = envRes.stdout.match(/^DISCORD_BOT_TOKEN=(.+)$/m);
      if (match) token = match[1].trim();
    }
  }
  if (!token) die("DISCORD_BOT_TOKEN not found");
  return token;
}

async function getDiscordGuildId() {
  const mcEnvRes = run("cat", ["/home/admin/treehouse/mc/.env"]);
  if (mcEnvRes.ok) {
    const match = mcEnvRes.stdout.match(/^DISCORD_GUILD_ID=(.+)$/m);
    if (match) return match[1].trim();
  }
  die("DISCORD_GUILD_ID not found in mc/.env");
}

async function getBotTenantToken(tenantId) {
  const dbPath = nanoclawDbPath();
  const res = sqliteJson(dbPath, `SELECT discord_bot_token FROM tenants WHERE id = ${sqliteQuote(tenantId)};`);
  if (!res.ok) die(res.error);
  if (!res.rows.length || !res.rows[0].discord_bot_token) die(`Tenant "${tenantId}" has no Discord bot token`);
  return res.rows[0].discord_bot_token;
}

async function cmdDiscordMembers({ limit = 50, json }) {
  const token = await getDiscordBotToken(null);
  const guildId = await getDiscordGuildId();
  const resp = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members?limit=${limit}`, {
    headers: { Authorization: `Bot ${token}` },
  });
  const members = await resp.json();
  if (!resp.ok) die(`Discord API error ${resp.status}: ${JSON.stringify(members)}`);

  const rows = members
    .map(m => ({
      username: m.user?.username || '',
      display_name: m.user?.global_name || m.nick || '',
      user_id: m.user?.id || '',
      joined_at: m.joined_at || '',
      bot: m.user?.bot ? 'yes' : '',
    }))
    .sort((a, b) => b.joined_at.localeCompare(a.joined_at));

  if (json) { process.stdout.write(JSON.stringify(rows, null, 2) + '\n'); return; }
  printTable(rows, [
    { key: 'joined_at', header: 'JOINED' },
    { key: 'username', header: 'USERNAME' },
    { key: 'display_name', header: 'DISPLAY_NAME' },
    { key: 'user_id', header: 'USER_ID' },
    { key: 'bot', header: 'BOT' },
  ]);
}

async function cmdCredsWorkspaceList({ tenantId, json }) {
  if (!tenantId) die("Usage: treehouse creds workspace list <tenant>");
  const credsPath = `/home/${tenantId}/workspace/credentials.json`;
  const fs2 = await import("fs");
  if (!fs2.default.existsSync(credsPath)) {
    if (json) process.stdout.write(JSON.stringify([]) + "\n");
    else process.stdout.write("No credentials stored.\n");
    return;
  }
  const creds = JSON.parse(fs2.default.readFileSync(credsPath, "utf-8"));
  if (json) { process.stdout.write(JSON.stringify(creds, null, 2) + "\n"); return; }
  if (creds.length === 0) { process.stdout.write("No credentials stored.\n"); return; }
  for (const c of creds) {
    process.stdout.write(`${c.name}${c.description ? `  # ${c.description}` : ""}\n`);
  }
}

async function cmdCredsWorkspaceSet({ tenantId, credName, value, description }) {
  if (!tenantId || !credName || !value) die("Usage: treehouse creds workspace set <tenant> <name> <value> [--description <desc>]");
  const fs2 = await import("fs");
  const path2 = await import("path");
  const tenantWorkspace = `/home/${tenantId}/workspace`;
  const credsPath = path2.default.join(tenantWorkspace, "credentials.json");
  fs2.default.mkdirSync(tenantWorkspace, { recursive: true });
  let creds = [];
  if (fs2.default.existsSync(credsPath)) {
    try { creds = JSON.parse(fs2.default.readFileSync(credsPath, "utf-8")); } catch { creds = []; }
  }
  const entry = { name: credName, value, ...(description != null && { description }) };
  const idx = creds.findIndex((c) => c.name === credName);
  if (idx >= 0) { creds[idx] = entry; process.stdout.write(`Updated credential '${credName}'.\n`); }
  else { creds.push(entry); process.stdout.write(`Created credential '${credName}'.\n`); }
  const tempPath = `${credsPath}.tmp`;
  fs2.default.writeFileSync(tempPath, JSON.stringify(creds, null, 2));
  fs2.default.renameSync(tempPath, credsPath);
}

async function cmdCredsWorkspaceRemove({ tenantId, credName, yes }) {
  if (!tenantId || !credName) die("Usage: treehouse creds workspace remove <tenant> <name> [--yes]");
  const fs2 = await import("fs");
  const credsPath = `/home/${tenantId}/workspace/credentials.json`;
  if (!fs2.default.existsSync(credsPath)) die(`No credentials.json found for tenant ${tenantId}`);
  let creds = JSON.parse(fs2.default.readFileSync(credsPath, "utf-8"));
  const filtered = creds.filter((c) => c.name !== credName);
  if (filtered.length === creds.length) die(`Credential not found: ${credName}`);
  if (!yes) {
    const readline2 = await import("readline");
    const rl = readline2.default.createInterface({ input: process.stdin, output: process.stdout });
    const answer = await new Promise((resolve) => rl.question(`Remove credential '${credName}' for tenant '${tenantId}'? [y/N] `, resolve));
    rl.close();
    if (answer.toLowerCase() !== "y") { process.stdout.write("Aborted.\n"); return; }
  }
  const tempPath = `${credsPath}.tmp`;
  fs2.default.writeFileSync(tempPath, JSON.stringify(filtered, null, 2));
  fs2.default.renameSync(tempPath, credsPath);
  process.stdout.write(`Removed credential '${credName}'.\n`);
}

async function cmdDiscordWelcomeCheck({ botTenant = 'gaia', days = 1, json }) {
  const token = await getDiscordBotToken(null);
  const guildId = await getDiscordGuildId();

  // Get the bot token for the welcome bot (default: gaia)
  const botToken = await getBotTenantToken(botTenant);
  const botResp = await fetch('https://discord.com/api/v10/users/@me', {
    headers: { Authorization: `Bot ${botToken}` },
  });
  const botUser = await botResp.json();
  if (!botResp.ok) die(`Discord API error ${botResp.status}: ${JSON.stringify(botUser)}`);
  const botUserId = botUser.id;
  const botUsername = botUser.username;

  // Get guild members
  const membersResp = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members?limit=100`, {
    headers: { Authorization: `Bot ${token}` },
  });
  const members = await membersResp.json();
  if (!membersResp.ok) die(`Discord API error ${membersResp.status}: ${JSON.stringify(members)}`);

  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
  const recent = members.filter(m => !m.user?.bot && m.joined_at && new Date(m.joined_at) >= cutoff);

  const results = [];
  for (const m of recent) {
    const userId = m.user?.id;
    const username = m.user?.username || '';
    const displayName = m.user?.global_name || m.nick || '';

    // Open DM channel as the welcome bot and check for messages from bot
    const dmResp = await fetch('https://discord.com/api/v10/users/@me/channels', {
      method: 'POST',
      headers: { Authorization: `Bot ${botToken}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ recipient_id: userId }),
    });
    const dm = await dmResp.json();
    let welcomed = false;
    let last_message = '';
    if (dmResp.ok && dm.id) {
      const msgsResp = await fetch(`https://discord.com/api/v10/channels/${dm.id}/messages?limit=10`, {
        headers: { Authorization: `Bot ${botToken}` },
      });
      const msgs = await msgsResp.json();
      if (Array.isArray(msgs)) {
        const botMsgs = msgs.filter(msg => msg.author?.id === botUserId);
        if (botMsgs.length > 0) {
          welcomed = true;
          last_message = botMsgs[0].timestamp;
        }
      }
    }
    results.push({ username, display_name: displayName, user_id: userId, joined_at: m.joined_at, welcomed: welcomed ? 'yes' : 'NO', last_message });
  }

  results.sort((a, b) => b.joined_at.localeCompare(a.joined_at));

  if (json) { process.stdout.write(JSON.stringify(results, null, 2) + '\n'); return; }

  if (results.length === 0) {
    process.stdout.write(`No non-bot members joined in the last ${days} day(s).\n`);
    return;
  }
  process.stdout.write(`Welcome check via ${botUsername} (last ${days} day(s)):\n\n`);
  printTable(results, [
    { key: 'joined_at', header: 'JOINED' },
    { key: 'username', header: 'USERNAME' },
    { key: 'display_name', header: 'DISPLAY_NAME' },
    { key: 'welcomed', header: 'WELCOMED' },
    { key: 'last_message', header: 'LAST_MSG_AT' },
  ]);
}

async function main() {
  const { positional, flags, options } = parseArgs(process.argv.slice(2));

  if (flags.has("--help") || positional.length === 0) {
    process.stdout.write(usage());
    return;
  }

  const [topic, action, subaction] = positional;
  const wantJson = flags.has("--json");
  const tenantFilter = options.get("--tenant") ?? null;
  const date = options.get("--date") ?? null;
  const yes = flags.has("--yes");
  const follow = flags.has("--follow");
  const buildUi = flags.has("--build-ui");
  const detach = flags.has("--detach");
  const fullPage = flags.has("--full-page");
  const app = options.get("--app") ?? null;
  const name = options.get("--name") ?? null;
  const tail = options.get("--tail") ?? null;
  const lines = options.get("--lines") ?? null;
  const id = options.get("--id") ?? null;
  const email = options.get("--email") ?? null;
  const pathArg = options.get("--path") ?? null;
  const out = options.get("--out") ?? null;
  const setValues = parseSetOptions(options);
  const type = options.get("--type") ?? null;
  const code = options.get("--code") ?? null;
  const provider = options.get("--provider") ?? null;
  const model = options.get("--model") ?? null;
  const piBackend = options.get("--pi-backend") ?? null;
  const all = flags.has("--all");
  const noHup = flags.has("--no-hup");
  const limit = options.get("--limit") ?? null;
  const chatJid = options.get("--chat_jid") ?? null;
  const groupFlag = options.get("--group") ?? null;
  const raw = flags.has("--raw");
  const folder = options.get("--folder") ?? null;
  const interval = options.get("--interval") ?? null;
  const title = options.get("--title") ?? null;
  const description = options.get("--description") ?? null;
  const status = options.get("--status") ?? null;
  const priority = options.get("--priority") ?? null;
  const assignedTo = options.get("--assigned-to") ?? null;
  const text = options.get("--text") ?? null;
  const domain = options.get("--domain") ?? null;
  const target = options.get("--target") ?? null;
  const image = options.get("--image") ?? null;

  if (topic === "doctor") return cmdDoctor({ json: wantJson });
  if (topic === "ui" && action === "screenshot") return await cmdUiScreenshot({ pathArg, out, fullPage });
  if (topic === "defaults" && action === "get") return await cmdDefaultsGet({ json: wantJson });
  if (topic === "defaults" && action === "set") return await cmdDefaultsSet({ provider, model, piBackend, json: wantJson });
  if (topic === "agents" && action === "ps") return await cmdAgentsPs({ tenantId: tenantFilter, json: wantJson });
  if (topic === "agents" && action === "kill") return await cmdAgentsKill({ containerId: id, tenantId: tenantFilter, all, yes });
  if (topic === "agents" && action === "logs") return await cmdAgentsLogs({ containerId: id, tail });
  if (topic === "chats" && action === "list") return await cmdChatsList({ tenantId: tenantFilter, limit, json: wantJson });
  if (topic === "messages" && action === "tail") return await cmdMessagesTail({ tenantId: tenantFilter, chatJid, groupFolder: groupFlag, limit, json: wantJson });
  if (topic === "session" && action === "tail") return await cmdSessionTail({ groupFolder: groupFlag ?? folder, limit });
  if (topic === "groups" && action === "list") return await cmdGroupsList({ tenantId: tenantFilter, json: wantJson });
  if (topic === "groups" && action === "show") return await cmdGroupsShow({ folder, json: wantJson });
  if (topic === "groups" && action === "hooks") return await cmdGroupsHooks({ folder, subaction, json: wantJson });
  if (topic === "groups" && action === "hooks-list") return await cmdGroupsHooksList({ tenantId: tenantFilter, json: wantJson });
  if (topic === "groups" && action === "hooks-find") return await cmdGroupsHooksFind({ token: options.get("--token"), json: wantJson });
  if (topic === "groups" && action === "hooks-enable-all") return await cmdGroupsHooksEnableAll({ yes });
  if (topic === "groups" && action === "dangling") return await cmdGroupsDangling({ tenantId: tenantFilter, doDelete: flags.has("--delete"), json: wantJson });
  if (topic === "tenants" && action === "list") return await cmdTenantsList();
  if (topic === "tenants" && action === "show") return await cmdTenantsShow({ tenantId: tenantFilter, json: wantJson });
  if (topic === "tenants" && action === "create") return await cmdTenantsCreate({ id, email, setValues, json: wantJson });
  if (topic === "tenants" && action === "update") return await cmdTenantsUpdate({ tenantId: tenantFilter, setValues, json: wantJson });
  if (topic === "tenants" && action === "clear-runtime-overrides") return await cmdTenantsClearRuntimeOverrides({ tenantId: tenantFilter, yes, noHup, json: wantJson });
  if (topic === "tenants" && action === "remove") return await cmdTenantsRemove({ tenantId: tenantFilter, yes });
  if (topic === "tenants" && action === "usage") return await cmdTenantsUsage({ date, json: wantJson });
  if (topic === "tenants" && action === "ports") return await cmdTenantsPorts({ json: wantJson });
  if (topic === "tenants" && action === "tailscale-proxy") return await cmdTenantsTailscaleProxy({ tenantId: tenantFilter, enable: flags.has("--enable") ? true : flags.has("--disable") ? false : undefined, yes });
  if (topic === "creds" && action === "list") return await cmdCredsList({ json: wantJson });
  if (topic === "creds" && action === "show") return await cmdCredsShow({ credId: id, json: wantJson });
  if (topic === "creds" && action === "create") return await cmdCredsCreate({ credId: id, type, setValues, json: wantJson });
  if (topic === "creds" && action === "update") return await cmdCredsUpdate({ credId: id, setValues, json: wantJson });
  if (topic === "creds" && action === "login") return await cmdCredsLogin({ credId: id, setValues, json: wantJson });
  if (topic === "creds" && action === "import-auth") return await cmdCredsImportAuth({ credId: id });
  if (topic === "creds" && action === "import-pi-auth") return await cmdCredsImportPiAuth({ credId: id });
  if (topic === "creds" && action === "pi-login") return await cmdCredsPiLogin({ credId: id });
  if (topic === "creds" && action === "logout") return await cmdCredsLogout({ credId: id });
  if (topic === "creds" && action === "resume") return await cmdCredsResume({ credId: id });
  if (topic === "creds" && action === "login-status") return await cmdCredsLoginStatus({ credId: id, raw });
  if (topic === "creds" && action === "usage") return await cmdCredsUsage({ credId: id, raw });
  if (topic === "creds" && action === "refresh") return await cmdCredsRefresh({ credId: id });
  if (topic === "creds" && action === "callback") return await cmdCredsCallback({ credId: id, code });
  if (topic === "creds" && action === "remove") return await cmdCredsRemove({ credId: id, yes });
  if (topic === "creds" && action === "workspace" && subaction === "list") return await cmdCredsWorkspaceList({ tenantId: positional[3], json: wantJson });
  if (topic === "creds" && action === "workspace" && subaction === "set") return await cmdCredsWorkspaceSet({ tenantId: positional[3], credName: positional[4], value: positional[5], description });
  if (topic === "creds" && action === "workspace" && subaction === "remove") return await cmdCredsWorkspaceRemove({ tenantId: positional[3], credName: positional[4], yes });
  if (topic === "apps" && action === "auth") return await cmdAppsAuth({ tenantId: tenantFilter, app, subaction, user: options.get("--user") ?? null, password: options.get("--password") ?? null, yes });
  if (topic === "apps" && action === "list") return await cmdAppsList({ tenantFilter, json: wantJson });
  if (topic === "apps" && action === "ps") return await cmdAppsPs({ json: wantJson });
  if (topic === "apps" && action === "deploy") return await cmdAppsDeploy({ tenantId: tenantFilter, app, forceRebuild: flags.has("--force-rebuild") || flags.has("--force") });
  if (topic === "apps" && action === "remove") return await cmdAppsRemove({ tenantId: tenantFilter, app, yes });
  if (topic === "apps" && action === "status") return await cmdAppsStatus({ tenantId: tenantFilter, app });
  if (topic === "apps" && action === "ports") return await cmdAppsPorts({ tenantId: tenantFilter });
  if (topic === "apps" && action === "reconcile") return await cmdAppsReconcile({ tenantId: tenantFilter, app, fix: flags.has("--fix"), json: wantJson });
  if (topic === "apps" && action === "smoke") return await cmdAppsSmoke({ tenantId: tenantFilter, app, yes });
  if (topic === "apps" && action === "logs") return await cmdAppsLogs({ tenantId: tenantFilter, name, tail, follow });
  if (topic === "apps" && action === "restart") return await cmdAppsRestart({ tenantId: tenantFilter, app, yes });
  if (topic === "apps" && action === "images") return await cmdAppsImages({ tenantFilter, all: flags.has("--all"), json: wantJson });
  if (topic === "apps" && action === "image-prune") return await cmdAppsImagePrune({ tenantFilter, all: flags.has("--all"), yes });
  if (topic === "apps" && action === "image-rm") return await cmdAppsImageRm({ tenantFilter, image, yes });
  if (topic === "apps" && action === "clean-stuck") return cmdAppsCleanStuck({ tenantFilter, app, yes });
  if (topic === "runtime" && action === "ping") return await cmdRuntimePing({ tenantFilter, json: wantJson });
  if (topic === "runtime" && action === "df") return await cmdRuntimeDf({ tenantFilter });
  if (topic === "volumes" && action === "list") return await cmdVolumesList({ json: wantJson });
  if (topic === "volumes" && action === "resize") return await cmdVolumesResize({ tenantId: tenantFilter, sizeMb: options.get("--size"), yes });
  if (topic === "caddy" && action === "lint") return await cmdCaddyLint();
  if (topic === "caddy" && action === "reload") return await cmdCaddyReload();
  if (topic === "nanoclaw" && action === "status") return await cmdNanoclawStatus();
  if (topic === "nanoclaw" && action === "logs") return await cmdNanoclawLogs({ lines });
  if (topic === "nanoclaw" && action === "hup") return await cmdNanoclawHup();
  if (topic === "nanoclaw" && action === "restart") return await cmdNanoclawRestart({ yes });
  if (topic === "backups" && action === "status") return await cmdBackupsStatus();
  if (topic === "backups" && action === "run") return await cmdBackupsRun({ yes });
  if (topic === "mc" && action === "restart") return await cmdMcRestart({ buildUi, detach, yes });
  if (topic === "tasks" && action === "list") return await cmdTasksList({ tenantId: tenantFilter, json: wantJson });
  if (topic === "tasks" && action === "show") return await cmdTasksShow({ taskId: id, json: wantJson });
  if (topic === "tasks" && action === "logs") return await cmdTasksLogs({ taskId: id, limit, json: wantJson });
  if (topic === "tasks" && action === "watch") return await cmdTasksWatch({ tenantId: tenantFilter, interval });
  if (topic === "tasks" && action === "pause") return await cmdTasksPause({ taskId: id, yes });
  if (topic === "tasks" && action === "resume") return await cmdTasksResume({ taskId: id, yes });
  if (topic === "tasks" && action === "cancel") return await cmdTasksCancel({ taskId: id, yes });
  if (topic === "domains" && action === "list") return await cmdDomainsList({ tenantId: tenantFilter, json: wantJson });
  if (topic === "domains" && action === "show") return await cmdDomainsShow({ domainId: id, json: wantJson });
  if (topic === "domains" && action === "add") return await cmdDomainsAdd({ tenantId: tenantFilter, domain, target });
  if (topic === "domains" && action === "activate") return await cmdDomainsActivate({ domainId: id, yes });
  if (topic === "domains" && action === "remove") return await cmdDomainsRemove({ domainId: id, yes });
  if (topic === "mctasks" && action === "list") return await cmdMcTasksList({ status, json: wantJson });
  if (topic === "mctasks" && action === "show") return await cmdMcTasksShow({ taskId: id, json: wantJson });
  if (topic === "mctasks" && action === "create") return await cmdMcTasksCreate({ title, description, status, priority, assignedTo });
  if (topic === "mctasks" && action === "update") return await cmdMcTasksUpdate({ taskId: id, title, description, status, priority, assignedTo });
  if (topic === "mctasks" && action === "comment") return await cmdMcTasksComment({ taskId: id, text });
  if (topic === "mctasks" && action === "delete") return await cmdMcTasksDelete({ taskId: id, yes });

  if (topic === "bots" && action === "list") return await cmdBotsList({ json: wantJson });
  if (topic === "bots" && action === "send") return await cmdBotsSend({ tenantId: tenantFilter, channel: options.get("--channel"), message: options.get("--message") });

  if (topic === "discord" && action === "members") return await cmdDiscordMembers({ limit: parseInt(options.get("--limit") ?? "50"), json: wantJson });
  if (topic === "discord" && action === "welcome-check") return await cmdDiscordWelcomeCheck({ botTenant: options.get("--bot-tenant") ?? "gaia", days: parseInt(options.get("--days") ?? "1"), json: wantJson });

  if (topic === "announce") return await cmdAnnounce({ message: action });

  die(`Unknown command: ${positional.join(" ")}\n\n${usage()}`);
}

main().catch((e) => die(e?.message || String(e)));
