Skip to content

Commit abc1510

Browse files
la14-1louisgvclaude
authored
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 <noreply@anthropic.com>
1 parent f862ee5 commit abc1510

3 files changed

Lines changed: 358 additions & 0 deletions

File tree

packages/cli/src/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,7 @@ export {
6262
resolveCloudKey,
6363
resolveDisplayName,
6464
} from "./shared.js";
65+
// status.ts — cmdStatus
66+
export { cmdStatus } from "./status.js";
6567
// update.ts — cmdUpdate
6668
export { cmdUpdate } from "./update.js";
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
import type { SpawnRecord } from "../history.js";
2+
import type { Manifest } from "../manifest.js";
3+
4+
import * as p from "@clack/prompts";
5+
import pc from "picocolors";
6+
import { filterHistory, markRecordDeleted } from "../history.js";
7+
import { loadManifest } from "../manifest.js";
8+
import { parseJsonObj } from "../shared/parse.js";
9+
import { isString, toRecord } from "../shared/type-guards.js";
10+
import { loadApiToken } from "../shared/ui.js";
11+
import { formatRelativeTime } from "./list.js";
12+
import { resolveDisplayName } from "./shared.js";
13+
14+
// ── Types ────────────────────────────────────────────────────────────────────
15+
16+
type LiveState = "running" | "stopped" | "gone" | "unknown";
17+
18+
interface ServerStatusResult {
19+
record: SpawnRecord;
20+
liveState: LiveState;
21+
}
22+
23+
interface JsonStatusEntry {
24+
id: string;
25+
agent: string;
26+
cloud: string;
27+
ip: string;
28+
name: string;
29+
state: LiveState;
30+
spawned_at: string;
31+
server_id: string;
32+
}
33+
34+
// ── Cloud status fetchers ────────────────────────────────────────────────────
35+
36+
async function fetchHetznerStatus(serverId: string, token: string): Promise<LiveState> {
37+
try {
38+
const resp = await fetch(`https://api.hetzner.cloud/v1/servers/${serverId}`, {
39+
headers: {
40+
Authorization: `Bearer ${token}`,
41+
},
42+
signal: AbortSignal.timeout(10_000),
43+
});
44+
if (resp.status === 404) {
45+
return "gone";
46+
}
47+
if (!resp.ok) {
48+
return "unknown";
49+
}
50+
const text = await resp.text();
51+
const data = parseJsonObj(text);
52+
const server = toRecord(data?.server);
53+
const serverStatus = server?.status;
54+
if (!isString(serverStatus)) {
55+
return "unknown";
56+
}
57+
if (serverStatus === "running") {
58+
return "running";
59+
}
60+
if (serverStatus === "off") {
61+
return "stopped";
62+
}
63+
return "unknown";
64+
} catch {
65+
return "unknown";
66+
}
67+
}
68+
69+
async function fetchDoStatus(dropletId: string, token: string): Promise<LiveState> {
70+
try {
71+
const resp = await fetch(`https://api.digitalocean.com/v2/droplets/${dropletId}`, {
72+
headers: {
73+
Authorization: `Bearer ${token}`,
74+
},
75+
signal: AbortSignal.timeout(10_000),
76+
});
77+
if (resp.status === 404) {
78+
return "gone";
79+
}
80+
if (!resp.ok) {
81+
return "unknown";
82+
}
83+
const text = await resp.text();
84+
const data = parseJsonObj(text);
85+
const droplet = toRecord(data?.droplet);
86+
const dropletStatus = droplet?.status;
87+
if (!isString(dropletStatus)) {
88+
return "unknown";
89+
}
90+
if (dropletStatus === "active") {
91+
return "running";
92+
}
93+
if (dropletStatus === "off" || dropletStatus === "archive") {
94+
return "stopped";
95+
}
96+
return "unknown";
97+
} catch {
98+
return "unknown";
99+
}
100+
}
101+
102+
async function checkServerStatus(record: SpawnRecord): Promise<LiveState> {
103+
const conn = record.connection;
104+
if (!conn) {
105+
return "unknown";
106+
}
107+
if (conn.deleted) {
108+
return "gone";
109+
}
110+
if (!conn.cloud || conn.cloud === "local") {
111+
return "running";
112+
}
113+
114+
const serverId = conn.server_id || conn.server_name || "";
115+
116+
switch (conn.cloud) {
117+
case "hetzner": {
118+
const token = loadApiToken("hetzner");
119+
if (!token) {
120+
return "unknown";
121+
}
122+
return fetchHetznerStatus(serverId, token);
123+
}
124+
125+
case "digitalocean": {
126+
const token = loadApiToken("digitalocean");
127+
if (!token) {
128+
return "unknown";
129+
}
130+
return fetchDoStatus(serverId, token);
131+
}
132+
133+
default:
134+
// Other clouds (aws, gcp, sprite, daytona) require CLI or complex auth;
135+
// report "unknown" rather than attempting a potentially interactive flow.
136+
return "unknown";
137+
}
138+
}
139+
140+
// ── Formatting ───────────────────────────────────────────────────────────────
141+
142+
function fmtState(state: LiveState): string {
143+
switch (state) {
144+
case "running":
145+
return pc.green("running");
146+
case "stopped":
147+
return pc.yellow("stopped");
148+
case "gone":
149+
return pc.dim("gone");
150+
case "unknown":
151+
return pc.dim("unknown");
152+
}
153+
}
154+
155+
function fmtIp(conn: SpawnRecord["connection"]): string {
156+
if (!conn) {
157+
return "—";
158+
}
159+
if (conn.cloud === "local") {
160+
return "localhost";
161+
}
162+
if (!conn.ip || conn.ip === "sprite-console" || conn.ip === "daytona-sandbox") {
163+
return "—";
164+
}
165+
return conn.ip;
166+
}
167+
168+
function col(s: string, width: number): string {
169+
const stripped = s.replace(/\x1b\[[0-9;]*m/g, "");
170+
const padding = Math.max(0, width - stripped.length);
171+
return s + " ".repeat(padding);
172+
}
173+
174+
// ── Table render ─────────────────────────────────────────────────────────────
175+
176+
function renderStatusTable(results: ServerStatusResult[], manifest: Manifest | null): void {
177+
const COL_ID = 8;
178+
const COL_AGENT = 12;
179+
const COL_CLOUD = 14;
180+
const COL_IP = 16;
181+
const COL_STATE = 12;
182+
const COL_SINCE = 12;
183+
184+
const header = [
185+
col(pc.dim("ID"), COL_ID),
186+
col(pc.dim("Agent"), COL_AGENT),
187+
col(pc.dim("Cloud"), COL_CLOUD),
188+
col(pc.dim("IP"), COL_IP),
189+
col(pc.dim("State"), COL_STATE),
190+
pc.dim("Since"),
191+
].join(" ");
192+
193+
const divider = pc.dim(
194+
[
195+
"-".repeat(COL_ID),
196+
"-".repeat(COL_AGENT),
197+
"-".repeat(COL_CLOUD),
198+
"-".repeat(COL_IP),
199+
"-".repeat(COL_STATE),
200+
"-".repeat(COL_SINCE),
201+
].join("-"),
202+
);
203+
204+
console.log();
205+
console.log(header);
206+
console.log(divider);
207+
208+
for (const { record, liveState } of results) {
209+
const conn = record.connection;
210+
const shortId = record.id ? record.id.slice(0, 6) : "??????";
211+
const agentDisplay = resolveDisplayName(manifest, record.agent, "agent");
212+
const cloudDisplay = resolveDisplayName(manifest, record.cloud, "cloud");
213+
const ip = fmtIp(conn);
214+
const state = fmtState(liveState);
215+
const since = formatRelativeTime(record.timestamp);
216+
217+
const row = [
218+
col(pc.dim(shortId), COL_ID),
219+
col(agentDisplay, COL_AGENT),
220+
col(cloudDisplay, COL_CLOUD),
221+
col(ip, COL_IP),
222+
col(state, COL_STATE),
223+
pc.dim(since),
224+
].join(" ");
225+
226+
console.log(row);
227+
}
228+
229+
console.log();
230+
}
231+
232+
// ── JSON output ──────────────────────────────────────────────────────────────
233+
234+
function renderStatusJson(results: ServerStatusResult[]): void {
235+
const entries: JsonStatusEntry[] = results.map(({ record, liveState }) => ({
236+
id: record.id || "",
237+
agent: record.agent,
238+
cloud: record.cloud,
239+
ip: fmtIp(record.connection),
240+
name: record.name || record.connection?.server_name || "",
241+
state: liveState,
242+
spawned_at: record.timestamp,
243+
server_id: record.connection?.server_id || record.connection?.server_name || "",
244+
}));
245+
console.log(JSON.stringify(entries, null, 2));
246+
}
247+
248+
// ── Main command ─────────────────────────────────────────────────────────────
249+
250+
export async function cmdStatus(opts: { prune?: boolean; json?: boolean } = {}): Promise<void> {
251+
const records = filterHistory();
252+
253+
const candidates = records.filter(
254+
(r) => r.connection && !r.connection.deleted && r.connection.cloud && r.connection.cloud !== "local",
255+
);
256+
257+
if (candidates.length === 0) {
258+
if (opts.json) {
259+
console.log("[]");
260+
return;
261+
}
262+
p.log.info("No active cloud servers found in history.");
263+
p.log.info(`Run ${pc.cyan("spawn <agent> <cloud>")} to launch your first agent.`);
264+
return;
265+
}
266+
267+
let manifest: Manifest | null = null;
268+
try {
269+
manifest = await loadManifest();
270+
} catch {
271+
// Manifest unavailable — show raw keys
272+
}
273+
274+
if (!opts.json) {
275+
p.log.step(`Checking status of ${candidates.length} server${candidates.length !== 1 ? "s" : ""}...`);
276+
}
277+
278+
const results: ServerStatusResult[] = await Promise.all(
279+
candidates.map(async (record) => {
280+
const liveState = await checkServerStatus(record);
281+
return {
282+
record,
283+
liveState,
284+
};
285+
}),
286+
);
287+
288+
if (opts.json) {
289+
renderStatusJson(results);
290+
return;
291+
}
292+
293+
renderStatusTable(results, manifest);
294+
295+
const goneRecords = results.filter((r) => r.liveState === "gone").map((r) => r.record);
296+
297+
if (opts.prune && goneRecords.length > 0) {
298+
const s = p.spinner();
299+
s.start(`Pruning ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""}...`);
300+
for (const record of goneRecords) {
301+
markRecordDeleted(record);
302+
}
303+
s.stop(`Pruned ${goneRecords.length} gone server${goneRecords.length !== 1 ? "s" : ""} from history.`);
304+
} else if (!opts.prune && goneRecords.length > 0) {
305+
p.log.info(
306+
pc.dim(
307+
`${goneRecords.length} server${goneRecords.length !== 1 ? "s" : ""} marked as gone. Run ${pc.cyan("spawn status --prune")} to remove them.`,
308+
),
309+
);
310+
}
311+
312+
const unknown = results.filter((r) => r.liveState === "unknown");
313+
if (unknown.length > 0) {
314+
const clouds = [
315+
...new Set(unknown.map((r) => r.record.cloud)),
316+
].join(", ");
317+
p.log.info(
318+
pc.dim(
319+
`${unknown.length} server${unknown.length !== 1 ? "s" : ""} on ${clouds}: live check not supported (credentials not found or cloud not yet supported).`,
320+
),
321+
);
322+
}
323+
324+
const running = results.filter((r) => r.liveState === "running").length;
325+
if (running > 0) {
326+
p.log.info(
327+
pc.dim(`${running} server${running !== 1 ? "s" : ""} running. Use ${pc.cyan("spawn list")} to reconnect.`),
328+
);
329+
}
330+
}

packages/cli/src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
cmdPick,
1919
cmdRun,
2020
cmdRunHeadless,
21+
cmdStatus,
2122
cmdUpdate,
2223
findClosestKeyByNameOrKey,
2324
isInteractiveTTY,
@@ -482,6 +483,12 @@ const DELETE_COMMANDS = new Set([
482483
"kill",
483484
]);
484485

486+
// status handled separately for --prune/--json flag parsing
487+
const STATUS_COMMANDS = new Set([
488+
"status",
489+
"ps",
490+
]);
491+
485492
// Common verb prefixes that users naturally try (e.g. "spawn run claude sprite")
486493
// These are not real subcommands -- we strip them and forward to the default handler
487494
const VERB_ALIASES = new Set([
@@ -573,6 +580,21 @@ async function dispatchDeleteCommand(filteredArgs: string[]): Promise<void> {
573580
await cmdDelete(agentFilter, cloudFilter);
574581
}
575582

583+
/** Handle status/ps commands with --prune and --json flags */
584+
async function dispatchStatusCommand(filteredArgs: string[]): Promise<void> {
585+
if (hasTrailingHelpFlag(filteredArgs)) {
586+
cmdHelp();
587+
return;
588+
}
589+
const args = filteredArgs.slice(1);
590+
const prune = args.includes("--prune");
591+
const json = args.includes("--json");
592+
await cmdStatus({
593+
prune,
594+
json,
595+
});
596+
}
597+
576598
/** Handle named subcommands (agents, clouds, matrix, etc.) */
577599
async function dispatchSubcommand(cmd: string, filteredArgs: string[]): Promise<void> {
578600
if (hasTrailingHelpFlag(filteredArgs)) {
@@ -661,6 +683,10 @@ async function dispatchCommand(
661683
await dispatchDeleteCommand(filteredArgs);
662684
return;
663685
}
686+
if (STATUS_COMMANDS.has(cmd)) {
687+
await dispatchStatusCommand(filteredArgs);
688+
return;
689+
}
664690
if (SUBCOMMANDS[cmd]) {
665691
await dispatchSubcommand(cmd, filteredArgs);
666692
return;

0 commit comments

Comments
 (0)