From abc15107eb2d08daf0f76b4eeb58fca2048ff4ab Mon Sep 17 00:00:00 2001 From: A <258483684+la14-1@users.noreply.github.com> Date: Fri, 6 Mar 2026 15:13:24 -0800 Subject: [PATCH] feat: add spawn status command to show live server state (#2254) Implements the `spawn status` command requested in #2253. The command: - Reads active (non-deleted) cloud servers from history - Queries Hetzner and DigitalOcean REST APIs in parallel using saved tokens - Shows a live-state table: ID, Agent, Cloud, IP, State, Since - States: running (green), stopped (yellow), gone (dim), unknown (dim) - --prune flag marks gone servers as deleted in history - --json flag outputs machine-readable JSON for scripting - `spawn ps` is an alias for `spawn status` Other clouds (AWS, GCP, Sprite, Daytona) require CLI auth flows that cannot run non-interactively; they report "unknown" with a helpful hint. Agent: issue-fixer Co-authored-by: B <6723574+louisgv@users.noreply.github.com> Co-authored-by: Claude Sonnet 4.5 --- packages/cli/src/commands/index.ts | 2 + packages/cli/src/commands/status.ts | 330 ++++++++++++++++++++++++++++ packages/cli/src/index.ts | 26 +++ 3 files changed, 358 insertions(+) create mode 100644 packages/cli/src/commands/status.ts diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 5c5cddab..8e23891f 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -62,5 +62,7 @@ export { resolveCloudKey, resolveDisplayName, } from "./shared.js"; +// status.ts — cmdStatus +export { cmdStatus } from "./status.js"; // update.ts — cmdUpdate export { cmdUpdate } from "./update.js"; diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts new file mode 100644 index 00000000..2393602d --- /dev/null +++ b/packages/cli/src/commands/status.ts @@ -0,0 +1,330 @@ +import type { SpawnRecord } from "../history.js"; +import type { Manifest } from "../manifest.js"; + +import * as p from "@clack/prompts"; +import pc from "picocolors"; +import { filterHistory, markRecordDeleted } from "../history.js"; +import { loadManifest } from "../manifest.js"; +import { parseJsonObj } from "../shared/parse.js"; +import { isString, toRecord } from "../shared/type-guards.js"; +import { loadApiToken } from "../shared/ui.js"; +import { formatRelativeTime } from "./list.js"; +import { resolveDisplayName } from "./shared.js"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +type LiveState = "running" | "stopped" | "gone" | "unknown"; + +interface ServerStatusResult { + record: SpawnRecord; + liveState: LiveState; +} + +interface JsonStatusEntry { + id: string; + agent: string; + cloud: string; + ip: string; + name: string; + state: LiveState; + spawned_at: string; + server_id: string; +} + +// ── Cloud status fetchers ──────────────────────────────────────────────────── + +async function fetchHetznerStatus(serverId: string, token: string): Promise { + try { + const resp = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: AbortSignal.timeout(10_000), + }); + if (resp.status === 404) { + return "gone"; + } + if (!resp.ok) { + return "unknown"; + } + const text = await resp.text(); + const data = parseJsonObj(text); + const server = toRecord(data?.server); + const serverStatus = server?.status; + if (!isString(serverStatus)) { + return "unknown"; + } + if (serverStatus === "running") { + return "running"; + } + if (serverStatus === "off") { + return "stopped"; + } + return "unknown"; + } catch { + return "unknown"; + } +} + +async function fetchDoStatus(dropletId: string, token: string): Promise { + try { + const resp = await fetch(`https://api.digitalocean.com/v2/droplets/${dropletId}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: AbortSignal.timeout(10_000), + }); + if (resp.status === 404) { + return "gone"; + } + if (!resp.ok) { + return "unknown"; + } + const text = await resp.text(); + const data = parseJsonObj(text); + const droplet = toRecord(data?.droplet); + const dropletStatus = droplet?.status; + if (!isString(dropletStatus)) { + return "unknown"; + } + if (dropletStatus === "active") { + return "running"; + } + if (dropletStatus === "off" || dropletStatus === "archive") { + return "stopped"; + } + return "unknown"; + } catch { + return "unknown"; + } +} + +async function checkServerStatus(record: SpawnRecord): Promise { + const conn = record.connection; + if (!conn) { + return "unknown"; + } + if (conn.deleted) { + return "gone"; + } + if (!conn.cloud || conn.cloud === "local") { + return "running"; + } + + const serverId = conn.server_id || conn.server_name || ""; + + switch (conn.cloud) { + case "hetzner": { + const token = loadApiToken("hetzner"); + if (!token) { + return "unknown"; + } + return fetchHetznerStatus(serverId, token); + } + + case "digitalocean": { + const token = loadApiToken("digitalocean"); + if (!token) { + return "unknown"; + } + return fetchDoStatus(serverId, token); + } + + default: + // Other clouds (aws, gcp, sprite, daytona) require CLI or complex auth; + // report "unknown" rather than attempting a potentially interactive flow. + return "unknown"; + } +} + +// ── Formatting ─────────────────────────────────────────────────────────────── + +function fmtState(state: LiveState): string { + switch (state) { + case "running": + return pc.green("running"); + case "stopped": + return pc.yellow("stopped"); + case "gone": + return pc.dim("gone"); + case "unknown": + return pc.dim("unknown"); + } +} + +function fmtIp(conn: SpawnRecord["connection"]): string { + if (!conn) { + return "—"; + } + if (conn.cloud === "local") { + return "localhost"; + } + if (!conn.ip || conn.ip === "sprite-console" || conn.ip === "daytona-sandbox") { + return "—"; + } + return conn.ip; +} + +function col(s: string, width: number): string { + const stripped = s.replace(/\x1b\[[0-9;]*m/g, ""); + const padding = Math.max(0, width - stripped.length); + return s + " ".repeat(padding); +} + +// ── Table render ───────────────────────────────────────────────────────────── + +function renderStatusTable(results: ServerStatusResult[], manifest: Manifest | null): void { + const COL_ID = 8; + const COL_AGENT = 12; + const COL_CLOUD = 14; + const COL_IP = 16; + const COL_STATE = 12; + const COL_SINCE = 12; + + const header = [ + col(pc.dim("ID"), COL_ID), + col(pc.dim("Agent"), COL_AGENT), + col(pc.dim("Cloud"), COL_CLOUD), + col(pc.dim("IP"), COL_IP), + col(pc.dim("State"), COL_STATE), + pc.dim("Since"), + ].join(" "); + + const divider = pc.dim( + [ + "-".repeat(COL_ID), + "-".repeat(COL_AGENT), + "-".repeat(COL_CLOUD), + "-".repeat(COL_IP), + "-".repeat(COL_STATE), + "-".repeat(COL_SINCE), + ].join("-"), + ); + + console.log(); + console.log(header); + console.log(divider); + + for (const { record, liveState } of results) { + const conn = record.connection; + const shortId = record.id ? record.id.slice(0, 6) : "??????"; + const agentDisplay = resolveDisplayName(manifest, record.agent, "agent"); + const cloudDisplay = resolveDisplayName(manifest, record.cloud, "cloud"); + const ip = fmtIp(conn); + const state = fmtState(liveState); + const since = formatRelativeTime(record.timestamp); + + const row = [ + col(pc.dim(shortId), COL_ID), + col(agentDisplay, COL_AGENT), + col(cloudDisplay, COL_CLOUD), + col(ip, COL_IP), + col(state, COL_STATE), + pc.dim(since), + ].join(" "); + + console.log(row); + } + + console.log(); +} + +// ── JSON output ────────────────────────────────────────────────────────────── + +function renderStatusJson(results: ServerStatusResult[]): void { + const entries: JsonStatusEntry[] = results.map(({ record, liveState }) => ({ + id: record.id || "", + agent: record.agent, + cloud: record.cloud, + ip: fmtIp(record.connection), + name: record.name || record.connection?.server_name || "", + state: liveState, + spawned_at: record.timestamp, + server_id: record.connection?.server_id || record.connection?.server_name || "", + })); + console.log(JSON.stringify(entries, null, 2)); +} + +// ── Main command ───────────────────────────────────────────────────────────── + +export async function cmdStatus(opts: { prune?: boolean; json?: boolean } = {}): Promise { + const records = filterHistory(); + + const candidates = records.filter( + (r) => r.connection && !r.connection.deleted && r.connection.cloud && r.connection.cloud !== "local", + ); + + if (candidates.length === 0) { + if (opts.json) { + console.log("[]"); + return; + } + p.log.info("No active cloud servers found in history."); + p.log.info(`Run ${pc.cyan("spawn ")} to launch your first agent.`); + return; + } + + let manifest: Manifest | null = null; + try { + manifest = await loadManifest(); + } catch { + // Manifest unavailable — show raw keys + } + + if (!opts.json) { + p.log.step(`Checking status of ${candidates.length} server${candidates.length !== 1 ? "s" : ""}...`); + } + + const results: ServerStatusResult[] = await Promise.all( + candidates.map(async (record) => { + const liveState = await checkServerStatus(record); + return { + record, + liveState, + }; + }), + ); + + if (opts.json) { + renderStatusJson(results); + return; + } + + renderStatusTable(results, manifest); + + const goneRecords = results.filter((r) => r.liveState === "gone").map((r) => r.record); + + if (opts.prune && goneRecords.length > 0) { + const s = p.spinner(); + s.start(`Pruning ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""}...`); + for (const record of goneRecords) { + markRecordDeleted(record); + } + s.stop(`Pruned ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""} from history.`); + } else if (!opts.prune && goneRecords.length > 0) { + p.log.info( + pc.dim( + `${goneRecords.length} server${goneRecords.length !== 1 ? "s" : ""} marked as gone. Run ${pc.cyan("spawn status --prune")} to remove them.`, + ), + ); + } + + const unknown = results.filter((r) => r.liveState === "unknown"); + if (unknown.length > 0) { + const clouds = [ + ...new Set(unknown.map((r) => r.record.cloud)), + ].join(", "); + p.log.info( + pc.dim( + `${unknown.length} server${unknown.length !== 1 ? "s" : ""} on ${clouds}: live check not supported (credentials not found or cloud not yet supported).`, + ), + ); + } + + const running = results.filter((r) => r.liveState === "running").length; + if (running > 0) { + p.log.info( + pc.dim(`${running} server${running !== 1 ? "s" : ""} running. Use ${pc.cyan("spawn list")} to reconnect.`), + ); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 13af7951..67fd4bef 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,7 @@ import { cmdPick, cmdRun, cmdRunHeadless, + cmdStatus, cmdUpdate, findClosestKeyByNameOrKey, isInteractiveTTY, @@ -482,6 +483,12 @@ const DELETE_COMMANDS = new Set([ "kill", ]); +// status handled separately for --prune/--json flag parsing +const STATUS_COMMANDS = new Set([ + "status", + "ps", +]); + // Common verb prefixes that users naturally try (e.g. "spawn run claude sprite") // These are not real subcommands -- we strip them and forward to the default handler const VERB_ALIASES = new Set([ @@ -573,6 +580,21 @@ async function dispatchDeleteCommand(filteredArgs: string[]): Promise { await cmdDelete(agentFilter, cloudFilter); } +/** Handle status/ps commands with --prune and --json flags */ +async function dispatchStatusCommand(filteredArgs: string[]): Promise { + if (hasTrailingHelpFlag(filteredArgs)) { + cmdHelp(); + return; + } + const args = filteredArgs.slice(1); + const prune = args.includes("--prune"); + const json = args.includes("--json"); + await cmdStatus({ + prune, + json, + }); +} + /** Handle named subcommands (agents, clouds, matrix, etc.) */ async function dispatchSubcommand(cmd: string, filteredArgs: string[]): Promise { if (hasTrailingHelpFlag(filteredArgs)) { @@ -661,6 +683,10 @@ async function dispatchCommand( await dispatchDeleteCommand(filteredArgs); return; } + if (STATUS_COMMANDS.has(cmd)) { + await dispatchStatusCommand(filteredArgs); + return; + } if (SUBCOMMANDS[cmd]) { await dispatchSubcommand(cmd, filteredArgs); return;