refactor(commands): split CLI commands
This commit is contained in:
407
src/commands/status.command.ts
Normal file
407
src/commands/status.command.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { info } from "../globals.js";
|
||||
import {
|
||||
formatUsageReportLines,
|
||||
loadProviderUsageSummary,
|
||||
} from "../infra/provider-usage.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { renderTable } from "../terminal/table.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import { formatHealthChannelLines, type HealthSummary } from "./health.js";
|
||||
import { resolveControlUiLinks } from "./onboard-helpers.js";
|
||||
import { getDaemonStatusSummary } from "./status.daemon.js";
|
||||
import {
|
||||
formatAge,
|
||||
formatDuration,
|
||||
formatKTokens,
|
||||
formatTokensCompact,
|
||||
shortenText,
|
||||
} from "./status.format.js";
|
||||
import { resolveGatewayProbeAuth } from "./status.gateway-probe.js";
|
||||
import { scanStatus } from "./status.scan.js";
|
||||
import { formatUpdateOneLiner } from "./status.update.js";
|
||||
import { formatGatewayAuthUsed } from "./status-all/format.js";
|
||||
import { statusAllCommand } from "./status-all.js";
|
||||
|
||||
export async function statusCommand(
|
||||
opts: {
|
||||
json?: boolean;
|
||||
deep?: boolean;
|
||||
usage?: boolean;
|
||||
timeoutMs?: number;
|
||||
verbose?: boolean;
|
||||
all?: boolean;
|
||||
},
|
||||
runtime: RuntimeEnv,
|
||||
) {
|
||||
if (opts.all && !opts.json) {
|
||||
await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const scan = await scanStatus(
|
||||
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
|
||||
runtime,
|
||||
);
|
||||
const {
|
||||
cfg,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
tailscaleHttpsUrl,
|
||||
update,
|
||||
gatewayConnection,
|
||||
remoteUrlMissing,
|
||||
gatewayMode,
|
||||
gatewayProbe,
|
||||
gatewayReachable,
|
||||
gatewaySelf,
|
||||
channelIssues,
|
||||
agentStatus,
|
||||
channels,
|
||||
summary,
|
||||
} = scan;
|
||||
|
||||
const usage = opts.usage
|
||||
? await withProgress(
|
||||
{
|
||||
label: "Fetching usage snapshot…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }),
|
||||
)
|
||||
: undefined;
|
||||
const health: HealthSummary | undefined = opts.deep
|
||||
? await withProgress(
|
||||
{
|
||||
label: "Checking gateway health…",
|
||||
indeterminate: true,
|
||||
enabled: opts.json !== true,
|
||||
},
|
||||
async () =>
|
||||
await callGateway<HealthSummary>({
|
||||
method: "health",
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (opts.json) {
|
||||
runtime.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
...summary,
|
||||
os: osSummary,
|
||||
update,
|
||||
gateway: {
|
||||
mode: gatewayMode,
|
||||
url: gatewayConnection.url,
|
||||
urlSource: gatewayConnection.urlSource,
|
||||
misconfigured: remoteUrlMissing,
|
||||
reachable: gatewayReachable,
|
||||
connectLatencyMs: gatewayProbe?.connectLatencyMs ?? null,
|
||||
self: gatewaySelf,
|
||||
error: gatewayProbe?.error ?? null,
|
||||
},
|
||||
agents: agentStatus,
|
||||
...(health || usage ? { health, usage } : {}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rich = true;
|
||||
const muted = (value: string) => (rich ? theme.muted(value) : value);
|
||||
const ok = (value: string) => (rich ? theme.success(value) : value);
|
||||
const warn = (value: string) => (rich ? theme.warn(value) : value);
|
||||
|
||||
if (opts.verbose) {
|
||||
const details = buildGatewayConnectionDetails();
|
||||
runtime.log(info("Gateway connection:"));
|
||||
for (const line of details.message.split("\n")) runtime.log(` ${line}`);
|
||||
runtime.log("");
|
||||
}
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
const dashboard = (() => {
|
||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
||||
if (!controlUiEnabled) return "disabled";
|
||||
const links = resolveControlUiLinks({
|
||||
port: resolveGatewayPort(cfg),
|
||||
bind: cfg.gateway?.bind,
|
||||
customBindHost: cfg.gateway?.customBindHost,
|
||||
basePath: cfg.gateway?.controlUi?.basePath,
|
||||
});
|
||||
return links.httpUrl;
|
||||
})();
|
||||
|
||||
const gatewayValue = (() => {
|
||||
const target = remoteUrlMissing
|
||||
? `fallback ${gatewayConnection.url}`
|
||||
: `${gatewayConnection.url}${gatewayConnection.urlSource ? ` (${gatewayConnection.urlSource})` : ""}`;
|
||||
const reach = remoteUrlMissing
|
||||
? warn("misconfigured (remote.url missing)")
|
||||
: gatewayReachable
|
||||
? ok(`reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}`)
|
||||
: warn(
|
||||
gatewayProbe?.error
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable",
|
||||
);
|
||||
const auth =
|
||||
gatewayReachable && !remoteUrlMissing
|
||||
? ` · auth ${formatGatewayAuthUsed(resolveGatewayProbeAuth(cfg))}`
|
||||
: "";
|
||||
const self =
|
||||
gatewaySelf?.host || gatewaySelf?.version || gatewaySelf?.platform
|
||||
? [
|
||||
gatewaySelf?.host ? gatewaySelf.host : null,
|
||||
gatewaySelf?.ip ? `(${gatewaySelf.ip})` : null,
|
||||
gatewaySelf?.version ? `app ${gatewaySelf.version}` : null,
|
||||
gatewaySelf?.platform ? gatewaySelf.platform : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
: null;
|
||||
const suffix = self ? ` · ${self}` : "";
|
||||
return `${gatewayMode} · ${target} · ${reach}${auth}${suffix}`;
|
||||
})();
|
||||
|
||||
const agentsValue = (() => {
|
||||
const pending =
|
||||
agentStatus.bootstrapPendingCount > 0
|
||||
? `${agentStatus.bootstrapPendingCount} bootstrapping`
|
||||
: "no bootstraps";
|
||||
const def = agentStatus.agents.find((a) => a.id === agentStatus.defaultId);
|
||||
const defActive =
|
||||
def?.lastActiveAgeMs != null ? formatAge(def.lastActiveAgeMs) : "unknown";
|
||||
const defSuffix = def ? ` · default ${def.id} active ${defActive}` : "";
|
||||
return `${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`;
|
||||
})();
|
||||
|
||||
const daemon = await getDaemonStatusSummary();
|
||||
const daemonValue = (() => {
|
||||
if (daemon.installed === false) return `${daemon.label} not installed`;
|
||||
const installedPrefix = daemon.installed === true ? "installed · " : "";
|
||||
return `${daemon.label} ${installedPrefix}${daemon.loadedText}${daemon.runtimeShort ? ` · ${daemon.runtimeShort}` : ""}`;
|
||||
})();
|
||||
|
||||
const defaults = summary.sessions.defaults;
|
||||
const defaultCtx = defaults.contextTokens
|
||||
? ` (${formatKTokens(defaults.contextTokens)} ctx)`
|
||||
: "";
|
||||
const eventsValue =
|
||||
summary.queuedSystemEvents.length > 0
|
||||
? `${summary.queuedSystemEvents.length} queued`
|
||||
: "none";
|
||||
|
||||
const probesValue = health ? ok("enabled") : muted("skipped (use --deep)");
|
||||
|
||||
const overviewRows = [
|
||||
{ Item: "Dashboard", Value: dashboard },
|
||||
{ Item: "OS", Value: `${osSummary.label} · node ${process.versions.node}` },
|
||||
{
|
||||
Item: "Tailscale",
|
||||
Value:
|
||||
tailscaleMode === "off"
|
||||
? muted("off")
|
||||
: tailscaleDns && tailscaleHttpsUrl
|
||||
? `${tailscaleMode} · ${tailscaleDns} · ${tailscaleHttpsUrl}`
|
||||
: warn(`${tailscaleMode} · magicdns unknown`),
|
||||
},
|
||||
{
|
||||
Item: "Update",
|
||||
Value: formatUpdateOneLiner(update).replace(/^Update:\s*/i, ""),
|
||||
},
|
||||
{ Item: "Gateway", Value: gatewayValue },
|
||||
{ Item: "Daemon", Value: daemonValue },
|
||||
{ Item: "Agents", Value: agentsValue },
|
||||
{ Item: "Probes", Value: probesValue },
|
||||
{ Item: "Events", Value: eventsValue },
|
||||
{ Item: "Heartbeat", Value: `${summary.heartbeatSeconds}s` },
|
||||
{
|
||||
Item: "Sessions",
|
||||
Value: `${summary.sessions.count} active · default ${defaults.model ?? "unknown"}${defaultCtx} · store ${summary.sessions.path}`,
|
||||
},
|
||||
];
|
||||
|
||||
runtime.log(theme.heading("Clawdbot status"));
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Overview"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 12 },
|
||||
{ key: "Value", header: "Value", flex: true, minWidth: 32 },
|
||||
],
|
||||
rows: overviewRows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Channels"));
|
||||
const channelIssuesByChannel = (() => {
|
||||
const map = new Map<string, typeof channelIssues>();
|
||||
for (const issue of channelIssues) {
|
||||
const key = issue.channel;
|
||||
const list = map.get(key);
|
||||
if (list) list.push(issue);
|
||||
else map.set(key, [issue]);
|
||||
}
|
||||
return map;
|
||||
})();
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Channel", header: "Channel", minWidth: 10 },
|
||||
{ key: "Enabled", header: "Enabled", minWidth: 7 },
|
||||
{ key: "State", header: "State", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 24 },
|
||||
],
|
||||
rows: channels.rows.map((row) => {
|
||||
const issues = channelIssuesByChannel.get(row.id) ?? [];
|
||||
const effectiveState =
|
||||
row.state === "off" ? "off" : issues.length > 0 ? "warn" : row.state;
|
||||
const issueSuffix =
|
||||
issues.length > 0
|
||||
? ` · ${warn(`gateway: ${shortenText(issues[0]?.message ?? "issue", 84)}`)}`
|
||||
: "";
|
||||
return {
|
||||
Channel: row.label,
|
||||
Enabled: row.enabled ? ok("ON") : muted("OFF"),
|
||||
State:
|
||||
effectiveState === "ok"
|
||||
? ok("OK")
|
||||
: effectiveState === "warn"
|
||||
? warn("WARN")
|
||||
: effectiveState === "off"
|
||||
? muted("OFF")
|
||||
: theme.accentDim("SETUP"),
|
||||
Detail: `${row.detail}${issueSuffix}`,
|
||||
};
|
||||
}),
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Sessions"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Key", header: "Key", minWidth: 20, flex: true },
|
||||
{ key: "Kind", header: "Kind", minWidth: 6 },
|
||||
{ key: "Age", header: "Age", minWidth: 9 },
|
||||
{ key: "Model", header: "Model", minWidth: 14 },
|
||||
{ key: "Tokens", header: "Tokens", minWidth: 16 },
|
||||
],
|
||||
rows:
|
||||
summary.sessions.recent.length > 0
|
||||
? summary.sessions.recent.map((sess) => ({
|
||||
Key: shortenText(sess.key, 32),
|
||||
Kind: sess.kind,
|
||||
Age: sess.updatedAt ? formatAge(sess.age) : "no activity",
|
||||
Model: sess.model ?? "unknown",
|
||||
Tokens: formatTokensCompact(sess),
|
||||
}))
|
||||
: [
|
||||
{
|
||||
Key: muted("no sessions yet"),
|
||||
Kind: "",
|
||||
Age: "",
|
||||
Model: "",
|
||||
Tokens: "",
|
||||
},
|
||||
],
|
||||
}).trimEnd(),
|
||||
);
|
||||
|
||||
if (summary.queuedSystemEvents.length > 0) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("System events"));
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [{ key: "Event", header: "Event", flex: true, minWidth: 24 }],
|
||||
rows: summary.queuedSystemEvents.slice(0, 5).map((event) => ({
|
||||
Event: event,
|
||||
})),
|
||||
}).trimEnd(),
|
||||
);
|
||||
if (summary.queuedSystemEvents.length > 5) {
|
||||
runtime.log(muted(`… +${summary.queuedSystemEvents.length - 5} more`));
|
||||
}
|
||||
}
|
||||
|
||||
if (health) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Health"));
|
||||
const rows: Array<Record<string, string>> = [];
|
||||
rows.push({
|
||||
Item: "Gateway",
|
||||
Status: ok("reachable"),
|
||||
Detail: `${health.durationMs}ms`,
|
||||
});
|
||||
|
||||
for (const line of formatHealthChannelLines(health)) {
|
||||
const colon = line.indexOf(":");
|
||||
if (colon === -1) continue;
|
||||
const item = line.slice(0, colon).trim();
|
||||
const detail = line.slice(colon + 1).trim();
|
||||
const normalized = detail.toLowerCase();
|
||||
const status = (() => {
|
||||
if (normalized.startsWith("ok")) return ok("OK");
|
||||
if (normalized.startsWith("failed")) return warn("WARN");
|
||||
if (normalized.startsWith("not configured")) return muted("OFF");
|
||||
if (normalized.startsWith("configured")) return ok("OK");
|
||||
if (normalized.startsWith("linked")) return ok("LINKED");
|
||||
if (normalized.startsWith("not linked")) return warn("UNLINKED");
|
||||
return warn("WARN");
|
||||
})();
|
||||
rows.push({ Item: item, Status: status, Detail: detail });
|
||||
}
|
||||
|
||||
runtime.log(
|
||||
renderTable({
|
||||
width: tableWidth,
|
||||
columns: [
|
||||
{ key: "Item", header: "Item", minWidth: 10 },
|
||||
{ key: "Status", header: "Status", minWidth: 8 },
|
||||
{ key: "Detail", header: "Detail", flex: true, minWidth: 28 },
|
||||
],
|
||||
rows,
|
||||
}).trimEnd(),
|
||||
);
|
||||
}
|
||||
|
||||
if (usage) {
|
||||
runtime.log("");
|
||||
runtime.log(theme.heading("Usage"));
|
||||
for (const line of formatUsageReportLines(usage)) {
|
||||
runtime.log(line);
|
||||
}
|
||||
}
|
||||
|
||||
runtime.log("");
|
||||
runtime.log("FAQ: https://docs.clawd.bot/faq");
|
||||
runtime.log("Troubleshooting: https://docs.clawd.bot/troubleshooting");
|
||||
runtime.log("");
|
||||
runtime.log("Next steps:");
|
||||
runtime.log(" Need to share? clawdbot status --all");
|
||||
runtime.log(" Need to debug live? clawdbot logs --follow");
|
||||
if (gatewayReachable) {
|
||||
runtime.log(" Need to test channels? clawdbot status --deep");
|
||||
} else {
|
||||
runtime.log(" Fix reachability first: clawdbot gateway status");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user