From 1eb50ffac476c5f54a0d3dfaa1ee4c9c28ce5e05 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 23:31:24 +0100 Subject: [PATCH] feat(status): improve status output --- AGENTS.md | 1 + CHANGELOG.md | 1 + docs/gateway/health.md | 3 +- docs/start/clawd.md | 1 + src/auto-reply/reply.ts | 1 - src/cli/program.ts | 5 +- src/cli/progress.ts | 7 +- src/commands/docs.ts | 15 - src/commands/status-all.ts | 567 ++++++++++++++++++++++++ src/commands/status-all/agents.ts | 77 ++++ src/commands/status-all/format.ts | 28 ++ src/commands/status-all/gateway.ts | 33 ++ src/commands/status-all/providers.ts | 211 +++++++++ src/commands/status.test.ts | 57 +++ src/commands/status.ts | 629 ++++++++++++++++++++++++++- src/gateway/call.test.ts | 16 +- src/gateway/call.ts | 38 +- src/infra/os-summary.ts | 31 ++ src/infra/provider-summary.ts | 22 + src/infra/update-check.ts | 364 ++++++++++++++++ src/logging.ts | 6 +- src/terminal/ansi.ts | 14 + src/terminal/table.test.ts | 19 + src/terminal/table.ts | 265 +++++++++++ src/terminal/theme.ts | 11 +- 25 files changed, 2382 insertions(+), 40 deletions(-) create mode 100644 src/commands/status-all.ts create mode 100644 src/commands/status-all/agents.ts create mode 100644 src/commands/status-all/format.ts create mode 100644 src/commands/status-all/gateway.ts create mode 100644 src/commands/status-all/providers.ts create mode 100644 src/infra/os-summary.ts create mode 100644 src/infra/update-check.ts create mode 100644 src/terminal/ansi.ts create mode 100644 src/terminal/table.test.ts create mode 100644 src/terminal/table.ts diff --git a/AGENTS.md b/AGENTS.md index d7e3cc8aa..1112a609e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,6 +69,7 @@ - Vocabulary: "makeup" = "mac app". - When answering questions, respond with high-confidence answers only: verify in code; do not guess. - Never update the Carbon dependency. +- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars. - Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.** - macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`. - If shared guardrails are available locally, review them; otherwise follow this repo's guidance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef2d5055..6d24844dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Agents/OpenCode Zen: update fallback models + defaults, keep legacy alias mappings. (#669) — thanks @magimetal. - Providers: unify group history context wrappers across providers with per-provider/per-account `historyLimit` overrides (fallback to `messages.groupChat.historyLimit`). Set `0` to disable. (#672) — thanks @steipete. - CLI: add `clawdbot update` (safe-ish git checkout update) + `--update` shorthand. (#673) — thanks @fm1randa. +- CLI: improve `clawdbot status` (OS/update/gateway/daemon/agents/sessions) + add `status --all` for full read-only diagnosis with tables, log tails, and scan progress (OSC-9 + spinner). - Gateway: add OpenAI-compatible `/v1/chat/completions` HTTP endpoint (auth, SSE streaming, per-agent routing). (#680) — thanks @steipete. - Gateway/Heartbeat: optionally deliver heartbeat `Reasoning:` output (`agents.defaults.heartbeat.includeReasoning`). (#690) - Docker: allow optional home volume + extra bind mounts in `docker-setup.sh`. (#679) — thanks @gabriel-trigo. diff --git a/docs/gateway/health.md b/docs/gateway/health.md index a83b1ccd5..068be3615 100644 --- a/docs/gateway/health.md +++ b/docs/gateway/health.md @@ -8,7 +8,8 @@ read_when: Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## Quick checks -- `clawdbot status` — local summary: whether creds exist, auth age, session store path + recent sessions. +- `clawdbot status` — local summary: gateway reachability/mode, update hint, creds/auth age, sessions + recent activity. +- `clawdbot status --all` — full local diagnosis (read-only, color, safe to paste for debugging). - `clawdbot status --deep` — also probes the running Gateway (WhatsApp connect + Telegram + Discord APIs). - `clawdbot health --json` — asks the running Gateway for a full health snapshot (WS-only; no direct Baileys socket). - Send `/status` as a standalone message in WhatsApp/WebChat to get a status reply without invoking the agent. diff --git a/docs/start/clawd.md b/docs/start/clawd.md index a5118baaf..13e17cd5d 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -206,6 +206,7 @@ Clawdbot extracts these and sends them as media alongside the text. ```bash clawdbot status # local status (creds, sessions, queued events) +clawdbot status --all # full diagnosis (read-only, pasteable) clawdbot status --deep # also probes the running Gateway (WA connect + Telegram) clawdbot health --json # gateway health snapshot (WS) ``` diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 1021ca0e9..6f9be3111 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -589,7 +589,6 @@ export async function getReplyFromConfig( (agentCfg?.elevatedDefault as ElevatedLevel | undefined) ?? "on") : "off"; - const _providerKey = sessionCtx.Provider?.trim().toLowerCase(); const resolvedBlockStreaming = opts?.disableBlockStreaming === true ? "off" diff --git a/src/cli/program.ts b/src/cli/program.ts index faccbf2b3..d4de1f6b5 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1148,8 +1148,9 @@ ${theme.muted("Docs:")} ${formatDocsLink( program .command("status") - .description("Show web session health and recent session recipients") + .description("Show local status (gateway, agents, sessions, auth)") .option("--json", "Output JSON instead of text", false) + .option("--all", "Full diagnosis (read-only, pasteable)", false) .option("--usage", "Show provider usage/quota snapshots", false) .option( "--deep", @@ -1164,6 +1165,7 @@ ${theme.muted("Docs:")} ${formatDocsLink( ` Examples: clawdbot status # show linked account + session store summary + clawdbot status --all # full diagnosis (read-only) clawdbot status --json # machine-readable output clawdbot status --usage # show provider usage/quota snapshots clawdbot status --deep # run provider probes (WA + Telegram + Discord + Slack + Signal) @@ -1187,6 +1189,7 @@ Examples: await statusCommand( { json: Boolean(opts.json), + all: Boolean(opts.all), deep: Boolean(opts.deep), usage: Boolean(opts.usage), timeoutMs: timeout, diff --git a/src/cli/progress.ts b/src/cli/progress.ts index 976bd79c5..871ae0992 100644 --- a/src/cli/progress.ts +++ b/src/cli/progress.ts @@ -1,6 +1,5 @@ import { spinner } from "@clack/prompts"; import { createOscProgressController, supportsOscProgress } from "osc-progress"; - import { theme } from "../terminal/theme.js"; const DEFAULT_DELAY_MS = 300; @@ -47,8 +46,7 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter { typeof options.delayMs === "number" ? options.delayMs : DEFAULT_DELAY_MS; const canOsc = supportsOscProgress(process.env, stream.isTTY); const allowSpinner = - !canOsc && - (options.fallback === undefined || options.fallback === "spinner"); + options.fallback === undefined || options.fallback === "spinner"; let started = false; let label = options.label; @@ -77,7 +75,8 @@ export function createCliProgress(options: ProgressOptions): ProgressReporter { if (controller) { if (indeterminate) controller.setIndeterminate(label); else controller.setPercent(label, percent); - } else if (spin) { + } + if (spin) { spin.message(theme.accent(label)); } }; diff --git a/src/commands/docs.ts b/src/commands/docs.ts index 94c7b3a80..089220c02 100644 --- a/src/commands/docs.ts +++ b/src/commands/docs.ts @@ -6,7 +6,6 @@ import { isRich, theme } from "../terminal/theme.js"; const SEARCH_TOOL = "https://docs.clawd.bot/mcp.SearchClawdbot"; const SEARCH_TIMEOUT_MS = 30_000; -const RENDER_TIMEOUT_MS = 10_000; const DEFAULT_SNIPPET_MAX = 220; type DocResult = { @@ -154,20 +153,6 @@ function renderRichResults( } async function renderMarkdown(markdown: string, runtime: RuntimeEnv) { - const width = process.stdout.columns ?? 0; - const args = width > 0 ? ["--width", String(width)] : []; - try { - const res = await runTool("markdansi", args, { - timeoutMs: RENDER_TIMEOUT_MS, - input: markdown, - }); - if (res.code === 0 && res.stdout.trim()) { - runtime.log(res.stdout.trimEnd()); - return; - } - } catch { - // Fall back to plain Markdown if renderer fails or cannot be installed. - } runtime.log(markdown.trimEnd()); } diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts new file mode 100644 index 000000000..a585ffc13 --- /dev/null +++ b/src/commands/status-all.ts @@ -0,0 +1,567 @@ +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; +import { + loadConfig, + readConfigFileSnapshot, + resolveGatewayPort, +} from "../config/config.js"; +import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; +import { resolveGatewayLogPaths } from "../daemon/launchd.js"; +import { resolveGatewayService } from "../daemon/service.js"; +import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { probeGateway } from "../gateway/probe.js"; +import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; +import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; +import { + readRestartSentinel, + summarizeRestartSentinel, +} from "../infra/restart-sentinel.js"; +import { + checkUpdateStatus, + compareSemverStrings, +} from "../infra/update-check.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { renderTable } from "../terminal/table.js"; +import { isRich, theme } from "../terminal/theme.js"; +import { VERSION } from "../version.js"; +import { resolveControlUiLinks } from "./onboard-helpers.js"; +import { getAgentLocalStatuses } from "./status-all/agents.js"; +import { + formatAge, + formatDuration, + redactSecrets, +} from "./status-all/format.js"; +import { + pickGatewaySelfPresence, + readFileTailLines, +} from "./status-all/gateway.js"; +import { buildProvidersTable } from "./status-all/providers.js"; + +export async function statusAllCommand( + runtime: RuntimeEnv, + opts?: { timeoutMs?: number }, +): Promise { + const cfg = loadConfig(); + const osSummary = resolveOsSummary(); + const snap = await readConfigFileSnapshot().catch(() => null); + + const root = await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + const update = await checkUpdateStatus({ + root, + timeoutMs: 6500, + fetchGit: true, + includeRegistry: true, + }); + + const connection = buildGatewayConnectionDetails({ config: cfg }); + const isRemoteMode = cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof cfg.gateway?.remote?.url === "string" + ? cfg.gateway.remote.url.trim() + : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw; + const gatewayMode = isRemoteMode ? "remote" : "local"; + + const resolveProbeAuth = (mode: "local" | "remote") => { + const authToken = cfg.gateway?.auth?.token; + const authPassword = cfg.gateway?.auth?.password; + const remote = cfg.gateway?.remote; + const token = + mode === "remote" + ? typeof remote?.token === "string" && remote.token.trim() + ? remote.token.trim() + : undefined + : process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + (typeof authToken === "string" && authToken.trim() + ? authToken.trim() + : undefined); + const password = + process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + (mode === "remote" + ? typeof remote?.password === "string" && remote.password.trim() + ? remote.password.trim() + : undefined + : typeof authPassword === "string" && authPassword.trim() + ? authPassword.trim() + : undefined); + return { token, password }; + }; + + const localFallbackAuth = resolveProbeAuth("local"); + const remoteAuth = resolveProbeAuth("remote"); + + const gatewayProbe = await probeGateway({ + url: connection.url, + auth: remoteUrlMissing ? localFallbackAuth : remoteAuth, + timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000), + }).catch(() => null); + const gatewayReachable = gatewayProbe?.ok === true; + const gatewaySelf = pickGatewaySelfPresence(gatewayProbe?.presence ?? null); + + const daemon = await (async () => { + try { + const service = resolveGatewayService(); + const [loaded, runtimeInfo] = await Promise.all([ + service.isLoaded({ env: process.env }).catch(() => false), + service.readRuntime(process.env).catch(() => undefined), + ]); + return { + label: service.label, + loaded, + loadedText: loaded ? service.loadedText : service.notLoadedText, + runtime: runtimeInfo, + }; + } catch { + return null; + } + })(); + + const agentStatus = await getAgentLocalStatuses(cfg); + const providers = await buildProvidersTable(cfg); + + const connectionDetailsForReport = (() => { + if (!remoteUrlMissing) return connection.message; + const bindMode = cfg.gateway?.bind ?? "loopback"; + const configPath = snap?.path?.trim() + ? snap.path.trim() + : "(unknown config path)"; + return [ + "Gateway mode: remote", + "Gateway target: (missing gateway.remote.url)", + `Config: ${configPath}`, + `Bind: ${bindMode}`, + `Local fallback (used for probes): ${connection.url}`, + "Fix: set gateway.remote.url, or set gateway.mode=local.", + ].join("\n"); + })(); + + const callOverrides = remoteUrlMissing + ? { + url: connection.url, + token: localFallbackAuth.token, + password: localFallbackAuth.password, + } + : {}; + + const health = gatewayReachable + ? await callGateway({ + method: "health", + timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), + ...callOverrides, + }).catch((err) => ({ error: String(err) })) + : { error: gatewayProbe?.error ?? "gateway unreachable" }; + + const providersStatus = gatewayReachable + ? await callGateway>({ + method: "providers.status", + params: { probe: false, timeoutMs: opts?.timeoutMs ?? 10_000 }, + timeoutMs: Math.min(8000, opts?.timeoutMs ?? 10_000), + ...callOverrides, + }).catch(() => null) + : null; + const providerIssues = providersStatus + ? collectProvidersStatusIssues(providersStatus) + : []; + + const sentinel = await readRestartSentinel().catch(() => null); + const lastErr = await readLastGatewayErrorLine(process.env).catch(() => null); + const port = resolveGatewayPort(cfg); + const portUsage = await inspectPortUsage(port).catch(() => null); + + const defaultWorkspace = + agentStatus.agents.find((a) => a.id === agentStatus.defaultId) + ?.workspaceDir ?? + agentStatus.agents[0]?.workspaceDir ?? + null; + const skillStatus = + defaultWorkspace != null + ? (() => { + try { + return buildWorkspaceSkillStatus(defaultWorkspace, { config: cfg }); + } catch { + return null; + } + })() + : null; + + const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true; + const dashboard = controlUiEnabled + ? resolveControlUiLinks({ + port, + bind: cfg.gateway?.bind, + basePath: cfg.gateway?.controlUi?.basePath, + }).httpUrl + : null; + + const updateLine = (() => { + if (update.installKind === "git" && update.git) { + const parts: string[] = []; + parts.push(update.git.branch ? `git ${update.git.branch}` : "git"); + if (update.git.upstream) parts.push(`↔ ${update.git.upstream}`); + if (update.git.dirty) parts.push("dirty"); + if (update.git.behind != null && update.git.ahead != null) { + if (update.git.behind === 0 && update.git.ahead === 0) + parts.push("up to date"); + else if (update.git.behind > 0 && update.git.ahead === 0) + parts.push(`behind ${update.git.behind}`); + else if (update.git.behind === 0 && update.git.ahead > 0) + parts.push(`ahead ${update.git.ahead}`); + else + parts.push( + `diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`, + ); + } + if (update.git.fetchOk === false) parts.push("fetch failed"); + if (update.deps?.status === "stale") parts.push("deps stale"); + if (update.deps?.status === "missing") parts.push("deps missing"); + return parts.join(" · "); + } + const parts: string[] = []; + parts.push( + update.packageManager !== "unknown" ? update.packageManager : "pkg", + ); + const latest = update.registry?.latestVersion; + if (latest) { + const cmp = compareSemverStrings(VERSION, latest); + if (cmp === 0) parts.push(`latest ${latest}`); + else if (cmp != null && cmp < 0) parts.push(`update available ${latest}`); + else parts.push(`latest ${latest}`); + } else if (update.registry?.error) { + parts.push("latest unknown"); + } + if (update.deps?.status === "stale") parts.push("deps stale"); + if (update.deps?.status === "missing") parts.push("deps missing"); + return parts.join(" · "); + })(); + + const gatewayTarget = remoteUrlMissing + ? `fallback ${connection.url}` + : connection.url; + const gatewayStatus = gatewayReachable + ? `reachable ${formatDuration(gatewayProbe?.connectLatencyMs)}` + : gatewayProbe?.error + ? `unreachable (${gatewayProbe.error})` + : "unreachable"; + const gatewaySelfLine = + gatewaySelf?.host || + gatewaySelf?.ip || + 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 aliveThresholdMs = 10 * 60_000; + const aliveAgents = agentStatus.agents.filter( + (a) => a.lastActiveAgeMs != null && a.lastActiveAgeMs <= aliveThresholdMs, + ).length; + + const overviewRows = [ + { Item: "Version", Value: VERSION }, + { Item: "OS", Value: osSummary.label }, + { Item: "Node", Value: process.versions.node }, + { + Item: "Config", + Value: snap?.path?.trim() ? snap.path.trim() : "(unknown config path)", + }, + dashboard + ? { Item: "Dashboard", Value: dashboard } + : { Item: "Dashboard", Value: "disabled" }, + { Item: "Update", Value: updateLine }, + { + Item: "Gateway", + Value: `${gatewayMode}${remoteUrlMissing ? " (remote.url missing)" : ""} · ${gatewayTarget} (${connection.urlSource}) · ${gatewayStatus}`, + }, + gatewaySelfLine + ? { Item: "Gateway self", Value: gatewaySelfLine } + : { Item: "Gateway self", Value: "unknown" }, + daemon + ? { + Item: "Daemon", + Value: `${daemon.label} ${daemon.loadedText}${daemon.runtime?.status ? ` · ${daemon.runtime.status}` : ""}${daemon.runtime?.pid ? ` (pid ${daemon.runtime.pid})` : ""}`, + } + : { Item: "Daemon", Value: "unknown" }, + { + Item: "Agents", + Value: `${agentStatus.agents.length} total · ${agentStatus.bootstrapPendingCount} bootstrapping · ${aliveAgents} active · ${agentStatus.totalSessions} sessions`, + }, + ]; + + const rich = isRich(); + const heading = (text: string) => (rich ? theme.heading(text) : text); + const ok = (text: string) => (rich ? theme.success(text) : text); + const warn = (text: string) => (rich ? theme.warn(text) : text); + const fail = (text: string) => (rich ? theme.error(text) : text); + const muted = (text: string) => (rich ? theme.muted(text) : text); + + const tableWidth = process.stdout.columns ?? 120; + + const overview = renderTable({ + width: tableWidth, + columns: [ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Value", header: "Value", flex: true, minWidth: 24 }, + ], + rows: overviewRows, + }); + + const providerRows = providers.rows.map((row) => ({ + Provider: row.provider, + Enabled: row.enabled ? ok("ON") : muted("OFF"), + Configured: row.configured + ? ok("OK") + : row.enabled + ? warn("WARN") + : muted("OFF"), + Detail: row.detail, + })); + + const providersTable = renderTable({ + width: tableWidth, + columns: [ + { key: "Provider", header: "Provider", minWidth: 10 }, + { key: "Enabled", header: "Enabled", minWidth: 7 }, + { key: "Configured", header: "Configured", minWidth: 10 }, + { key: "Detail", header: "Detail", flex: true, minWidth: 28 }, + ], + rows: providerRows, + }); + + const agentRows = agentStatus.agents.map((a) => ({ + Agent: a.name?.trim() ? `${a.id} (${a.name.trim()})` : a.id, + Bootstrap: + a.bootstrapPending === true + ? warn("PENDING") + : a.bootstrapPending === false + ? ok("OK") + : "unknown", + Sessions: String(a.sessionsCount), + Active: + a.lastActiveAgeMs != null ? formatAge(a.lastActiveAgeMs) : "unknown", + Store: a.sessionsPath, + })); + + const agentsTable = renderTable({ + width: tableWidth, + columns: [ + { key: "Agent", header: "Agent", minWidth: 12 }, + { key: "Bootstrap", header: "Bootstrap", minWidth: 10 }, + { key: "Sessions", header: "Sessions", align: "right", minWidth: 8 }, + { key: "Active", header: "Active", minWidth: 10 }, + { key: "Store", header: "Store", flex: true, minWidth: 34 }, + ], + rows: agentRows, + }); + + const lines: string[] = []; + lines.push(heading("Clawdbot status --all")); + lines.push(""); + lines.push(heading("Overview")); + lines.push(overview.trimEnd()); + lines.push(""); + lines.push(heading("Providers")); + lines.push(providersTable.trimEnd()); + for (const detail of providers.details) { + lines.push(""); + lines.push(heading(detail.title)); + lines.push( + renderTable({ + width: tableWidth, + columns: detail.columns.map((c) => ({ + key: c, + header: c, + flex: c === "Notes", + minWidth: c === "Notes" ? 28 : 10, + })), + rows: detail.rows.map((r) => ({ + ...r, + ...(r.Status === "OK" + ? { Status: ok("OK") } + : r.Status === "WARN" + ? { Status: warn("WARN") } + : {}), + })), + }).trimEnd(), + ); + } + lines.push(""); + lines.push(heading("Agents")); + lines.push(agentsTable.trimEnd()); + lines.push(""); + lines.push(heading("Diagnosis (read-only)")); + + const emitCheck = (label: string, status: "ok" | "warn" | "fail") => { + const icon = + status === "ok" ? ok("✓") : status === "warn" ? warn("!") : fail("✗"); + const colored = + status === "ok" + ? ok(label) + : status === "warn" + ? warn(label) + : fail(label); + lines.push(`${icon} ${colored}`); + }; + + lines.push(""); + lines.push(`${muted("Gateway connection details:")}`); + for (const line of redactSecrets(connectionDetailsForReport) + .split("\n") + .map((l) => l.trimEnd())) { + lines.push(` ${muted(line)}`); + } + + lines.push(""); + if (snap) { + const status = !snap.exists ? "fail" : snap.valid ? "ok" : "warn"; + emitCheck(`Config: ${snap.path ?? "(unknown)"}`, status); + const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])]; + const uniqueIssues = issues.filter( + (issue, index) => + issues.findIndex( + (x) => x.path === issue.path && x.message === issue.message, + ) === index, + ); + for (const issue of uniqueIssues.slice(0, 12)) { + lines.push(` - ${issue.path}: ${issue.message}`); + } + if (uniqueIssues.length > 12) { + lines.push(` ${muted(`… +${uniqueIssues.length - 12} more`)}`); + } + } else { + emitCheck("Config: read failed", "warn"); + } + + if (remoteUrlMissing) { + lines.push(""); + emitCheck( + "Gateway remote mode misconfigured (gateway.remote.url missing)", + "warn", + ); + lines.push( + ` ${muted("Fix: set gateway.remote.url, or set gateway.mode=local.")}`, + ); + } + + if (sentinel?.payload) { + emitCheck("Restart sentinel present", "warn"); + lines.push( + ` ${muted(`${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`)}`, + ); + } else { + emitCheck("Restart sentinel: none", "ok"); + } + + const lastErrClean = lastErr?.trim() ?? ""; + const isTrivialLastErr = + lastErrClean.length < 8 || lastErrClean === "}" || lastErrClean === "{"; + if (lastErrClean && !isTrivialLastErr) { + lines.push(""); + lines.push(`${muted("Gateway last log line:")}`); + lines.push(` ${muted(redactSecrets(lastErrClean))}`); + } + + if (portUsage) { + const portOk = portUsage.listeners.length === 0; + emitCheck(`Port ${port}`, portOk ? "ok" : "warn"); + if (!portOk) { + for (const line of formatPortDiagnostics(portUsage)) { + lines.push(` ${muted(line)}`); + } + } + } + + if (skillStatus) { + const eligible = skillStatus.skills.filter((s) => s.eligible).length; + const missing = skillStatus.skills.filter( + (s) => s.eligible && Object.values(s.missing).some((arr) => arr.length), + ).length; + emitCheck( + `Skills (${eligible} eligible · ${missing} missing requirements)`, + missing === 0 ? "ok" : "warn", + ); + lines.push(` ${muted(skillStatus.workspaceDir)}`); + } + + const logPaths = (() => { + try { + return resolveGatewayLogPaths(process.env); + } catch { + return null; + } + })(); + if (logPaths) { + const [stderrTail, stdoutTail] = await Promise.all([ + readFileTailLines(logPaths.stderrPath, 40).catch(() => []), + readFileTailLines(logPaths.stdoutPath, 40).catch(() => []), + ]); + if (stderrTail.length > 0 || stdoutTail.length > 0) { + lines.push(""); + lines.push(`${muted(`Gateway logs (tail): ${logPaths.logDir}`)}`); + lines.push(` ${muted(`# stderr: ${logPaths.stderrPath}`)}`); + for (const line of stderrTail.map(redactSecrets)) { + lines.push(` ${muted(line)}`); + } + lines.push(` ${muted(`# stdout: ${logPaths.stdoutPath}`)}`); + for (const line of stdoutTail.map(redactSecrets)) { + lines.push(` ${muted(line)}`); + } + } + } + + if (providersStatus) { + emitCheck( + `Provider issues (${providerIssues.length || "none"})`, + providerIssues.length === 0 ? "ok" : "warn", + ); + for (const issue of providerIssues.slice(0, 12)) { + const fixText = issue.fix ? ` · fix: ${issue.fix}` : ""; + lines.push( + ` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}${fixText}`, + ); + } + if (providerIssues.length > 12) { + lines.push(` ${muted(`… +${providerIssues.length - 12} more`)}`); + } + } else { + emitCheck( + `Provider issues skipped (gateway ${gatewayReachable ? "query failed" : "unreachable"})`, + "warn", + ); + } + + const healthErr = (() => { + if (!health || typeof health !== "object") return ""; + const record = health as Record; + if (!("error" in record)) return ""; + const value = record.error; + if (!value) return ""; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return "[unserializable error]"; + } + })(); + if (healthErr) { + lines.push(""); + lines.push(`${muted("Gateway health:")}`); + lines.push(` ${muted(redactSecrets(healthErr))}`); + } + + lines.push(""); + lines.push(muted("Pasteable debug report. Auth tokens redacted.")); + lines.push(""); + + runtime.log(lines.join("\n")); +} diff --git a/src/commands/status-all/agents.ts b/src/commands/status-all/agents.ts new file mode 100644 index 000000000..b87075d66 --- /dev/null +++ b/src/commands/status-all/agents.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { loadSessionStore, resolveStorePath } from "../../config/sessions.js"; +import { listAgentsForGateway } from "../../gateway/session-utils.js"; + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +export async function getAgentLocalStatuses(cfg: ClawdbotConfig) { + const agentList = listAgentsForGateway(cfg); + const now = Date.now(); + + const agents = await Promise.all( + agentList.agents.map(async (agent) => { + const workspaceDir = (() => { + try { + return resolveAgentWorkspaceDir(cfg, agent.id); + } catch { + return null; + } + })(); + const bootstrapPending = + workspaceDir != null + ? await fileExists(path.join(workspaceDir, "BOOTSTRAP.md")) + : null; + const sessionsPath = resolveStorePath(cfg.session?.store, { + agentId: agent.id, + }); + const store = (() => { + try { + return loadSessionStore(sessionsPath); + } catch { + return {}; + } + })(); + const updatedAt = Object.values(store).reduce( + (max, entry) => Math.max(max, entry?.updatedAt ?? 0), + 0, + ); + const lastUpdatedAt = updatedAt > 0 ? updatedAt : null; + const lastActiveAgeMs = lastUpdatedAt ? now - lastUpdatedAt : null; + const sessionsCount = Object.keys(store).filter( + (k) => k !== "global" && k !== "unknown", + ).length; + return { + id: agent.id, + name: agent.name, + workspaceDir, + bootstrapPending, + sessionsPath, + sessionsCount, + lastUpdatedAt, + lastActiveAgeMs, + }; + }), + ); + + const totalSessions = agents.reduce((sum, a) => sum + a.sessionsCount, 0); + const bootstrapPendingCount = agents.reduce( + (sum, a) => sum + (a.bootstrapPending ? 1 : 0), + 0, + ); + return { + defaultId: agentList.defaultId, + agents, + totalSessions, + bootstrapPendingCount, + }; +} diff --git a/src/commands/status-all/format.ts b/src/commands/status-all/format.ts new file mode 100644 index 000000000..0380d487d --- /dev/null +++ b/src/commands/status-all/format.ts @@ -0,0 +1,28 @@ +export const formatAge = (ms: number | null | undefined) => { + if (!ms || ms < 0) return "unknown"; + const minutes = Math.round(ms / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 48) return `${hours}h ago`; + const days = Math.round(hours / 24); + return `${days}d ago`; +}; + +export const formatDuration = (ms: number | null | undefined) => { + if (ms == null || !Number.isFinite(ms)) return "unknown"; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +}; + +export function redactSecrets(text: string): string { + if (!text) return text; + let out = text; + out = out.replace( + /(\b(?:access[_-]?token|refresh[_-]?token|token|password|secret|api[_-]?key)\b\s*[:=]\s*)("?)([^"\\s]+)("?)/gi, + "$1$2***$4", + ); + out = out.replace(/\bBearer\s+[A-Za-z0-9._-]+\b/g, "Bearer ***"); + out = out.replace(/\bsk-[A-Za-z0-9]{10,}\b/g, "sk-***"); + return out; +} diff --git a/src/commands/status-all/gateway.ts b/src/commands/status-all/gateway.ts new file mode 100644 index 000000000..eed40597a --- /dev/null +++ b/src/commands/status-all/gateway.ts @@ -0,0 +1,33 @@ +import fs from "node:fs/promises"; + +export async function readFileTailLines( + filePath: string, + maxLines: number, +): Promise { + const raw = await fs.readFile(filePath, "utf8").catch(() => ""); + if (!raw.trim()) return []; + const lines = raw.replace(/\r/g, "").split("\n"); + const out = lines.slice(Math.max(0, lines.length - maxLines)); + return out + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); +} + +export function pickGatewaySelfPresence(presence: unknown): { + host?: string; + ip?: string; + version?: string; + platform?: string; +} | null { + if (!Array.isArray(presence)) return null; + const entries = presence as Array>; + const self = + entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null; + if (!self) return null; + return { + host: typeof self.host === "string" ? self.host : undefined, + ip: typeof self.ip === "string" ? self.ip : undefined, + version: typeof self.version === "string" ? self.version : undefined, + platform: typeof self.platform === "string" ? self.platform : undefined, + }; +} diff --git a/src/commands/status-all/providers.ts b/src/commands/status-all/providers.ts new file mode 100644 index 000000000..7b2b52cd4 --- /dev/null +++ b/src/commands/status-all/providers.ts @@ -0,0 +1,211 @@ +import type { ClawdbotConfig } from "../../config/config.js"; +import { + listDiscordAccountIds, + resolveDiscordAccount, +} from "../../discord/accounts.js"; +import { + listIMessageAccountIds, + resolveIMessageAccount, +} from "../../imessage/accounts.js"; +import { resolveMSTeamsCredentials } from "../../msteams/token.js"; +import { + listSignalAccountIds, + resolveSignalAccount, +} from "../../signal/accounts.js"; +import { + listSlackAccountIds, + resolveSlackAccount, +} from "../../slack/accounts.js"; +import { + listTelegramAccountIds, + resolveTelegramAccount, +} from "../../telegram/accounts.js"; +import { normalizeE164 } from "../../utils.js"; +import { + listWhatsAppAccountIds, + resolveWhatsAppAccount, +} from "../../web/accounts.js"; +import { + getWebAuthAgeMs, + readWebSelfId, + webAuthExists, +} from "../../web/session.js"; +import { formatAge } from "./format.js"; + +export type ProviderRow = { + provider: string; + enabled: boolean; + configured: boolean; + detail: string; +}; + +export async function buildProvidersTable(cfg: ClawdbotConfig): Promise<{ + rows: ProviderRow[]; + details: Array<{ + title: string; + columns: string[]; + rows: Array>; + }>; +}> { + const rows: ProviderRow[] = []; + const details: Array<{ + title: string; + columns: string[]; + rows: Array>; + }> = []; + + // WhatsApp + const waEnabled = cfg.web?.enabled !== false; + const waLinked = waEnabled ? await webAuthExists().catch(() => false) : false; + const waAuthAgeMs = waLinked ? getWebAuthAgeMs() : null; + const waSelf = waLinked ? readWebSelfId().e164 : undefined; + const waAccounts = waLinked + ? listWhatsAppAccountIds(cfg).map((accountId) => + resolveWhatsAppAccount({ cfg, accountId }), + ) + : []; + rows.push({ + provider: "WhatsApp", + enabled: waEnabled, + configured: waLinked, + detail: waEnabled + ? waLinked + ? `linked${waSelf ? ` ${waSelf}` : ""}${waAuthAgeMs ? ` · auth ${formatAge(waAuthAgeMs)}` : ""} · accounts ${waAccounts.length || 1}` + : "not linked" + : "disabled", + }); + if (waLinked) { + const waRows = + waAccounts.length > 0 ? waAccounts : [resolveWhatsAppAccount({ cfg })]; + details.push({ + title: "WhatsApp accounts", + columns: ["Account", "Status", "Notes"], + rows: waRows.map((account) => { + const allowFrom = (account.allowFrom ?? cfg.whatsapp?.allowFrom ?? []) + .map(normalizeE164) + .filter(Boolean) + .slice(0, 3); + const dmPolicy = + account.dmPolicy ?? cfg.whatsapp?.dmPolicy ?? "pairing"; + const notes: string[] = []; + if (!account.enabled) notes.push("disabled"); + if (account.selfChatMode) notes.push("self-chat"); + notes.push(`dm:${dmPolicy}`); + if (allowFrom.length) notes.push(`allow:${allowFrom.join(",")}`); + return { + Account: account.name?.trim() + ? `${account.accountId} (${account.name.trim()})` + : account.accountId, + Status: account.enabled ? "OK" : "WARN", + Notes: notes.join(" · "), + }; + }), + }); + } + + // Telegram + const tgEnabled = cfg.telegram?.enabled !== false; + const tgAccounts = listTelegramAccountIds(cfg).map((accountId) => + resolveTelegramAccount({ cfg, accountId }), + ); + const tgConfigured = tgAccounts.some((a) => Boolean(a.token?.trim())); + rows.push({ + provider: "Telegram", + enabled: tgEnabled, + configured: tgEnabled && tgConfigured, + detail: tgEnabled + ? tgConfigured + ? `accounts ${tgAccounts.filter((a) => a.token?.trim()).length}` + : "not configured" + : "disabled", + }); + + // Discord + const dcEnabled = cfg.discord?.enabled !== false; + const dcAccounts = listDiscordAccountIds(cfg).map((accountId) => + resolveDiscordAccount({ cfg, accountId }), + ); + const dcConfigured = dcAccounts.some((a) => Boolean(a.token?.trim())); + rows.push({ + provider: "Discord", + enabled: dcEnabled, + configured: dcEnabled && dcConfigured, + detail: dcEnabled + ? dcConfigured + ? `accounts ${dcAccounts.filter((a) => a.token?.trim()).length}` + : "not configured" + : "disabled", + }); + + // Slack + const slEnabled = cfg.slack?.enabled !== false; + const slAccounts = listSlackAccountIds(cfg).map((accountId) => + resolveSlackAccount({ cfg, accountId }), + ); + const slConfigured = slAccounts.some( + (a) => Boolean(a.botToken?.trim()) && Boolean(a.appToken?.trim()), + ); + rows.push({ + provider: "Slack", + enabled: slEnabled, + configured: slEnabled && slConfigured, + detail: slEnabled + ? slConfigured + ? `accounts ${slAccounts.filter((a) => a.botToken?.trim() && a.appToken?.trim()).length}` + : "not configured" + : "disabled", + }); + + // Signal + const siEnabled = cfg.signal?.enabled !== false; + const siAccounts = listSignalAccountIds(cfg).map((accountId) => + resolveSignalAccount({ cfg, accountId }), + ); + const siConfigured = siAccounts.some((a) => a.configured); + rows.push({ + provider: "Signal", + enabled: siEnabled, + configured: siEnabled && siConfigured, + detail: siEnabled + ? siConfigured + ? `accounts ${siAccounts.filter((a) => a.configured).length}` + : "not configured" + : "disabled", + }); + + // iMessage + const imEnabled = cfg.imessage?.enabled !== false; + const imAccounts = listIMessageAccountIds(cfg).map((accountId) => + resolveIMessageAccount({ cfg, accountId }), + ); + const imConfigured = imAccounts.some((a) => a.configured); + rows.push({ + provider: "iMessage", + enabled: imEnabled, + configured: imEnabled && imConfigured, + detail: imEnabled + ? imConfigured + ? `accounts ${imAccounts.length}` + : "not configured" + : "disabled", + }); + + // MS Teams + const msEnabled = cfg.msteams?.enabled !== false; + const msConfigured = Boolean(resolveMSTeamsCredentials(cfg.msteams)); + rows.push({ + provider: "MS Teams", + enabled: msEnabled, + configured: msEnabled && msConfigured, + detail: msEnabled + ? msConfigured + ? "credentials present" + : "not configured" + : "disabled", + }); + + return { + rows, + details, + }; +} diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 977e085cd..e9acd167c 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -20,6 +20,17 @@ const mocks = vi.hoisted(() => ({ getWebAuthAgeMs: vi.fn().mockReturnValue(5000), readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }), logWebSelfId: vi.fn(), + probeGateway: vi.fn().mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }), })); vi.mock("../config/sessions.js", () => ({ @@ -33,6 +44,52 @@ vi.mock("../web/session.js", () => ({ readWebSelfId: mocks.readWebSelfId, logWebSelfId: mocks.logWebSelfId, })); +vi.mock("../gateway/probe.js", () => ({ + probeGateway: mocks.probeGateway, +})); +vi.mock("../gateway/session-utils.js", () => ({ + listAgentsForGateway: () => ({ + defaultId: "main", + mainKey: "agent:main:main", + scope: "per-sender", + agents: [{ id: "main", name: "Main" }], + }), +})); +vi.mock("../infra/clawdbot-root.js", () => ({ + resolveClawdbotPackageRoot: vi.fn().mockResolvedValue("/tmp/clawdbot"), +})); +vi.mock("../infra/os-summary.js", () => ({ + resolveOsSummary: () => ({ + platform: "darwin", + arch: "arm64", + release: "23.0.0", + label: "macos 14.0 (arm64)", + }), +})); +vi.mock("../infra/update-check.js", () => ({ + checkUpdateStatus: vi.fn().mockResolvedValue({ + root: "/tmp/clawdbot", + installKind: "git", + packageManager: "pnpm", + git: { + root: "/tmp/clawdbot", + branch: "main", + upstream: "origin/main", + dirty: false, + ahead: 0, + behind: 0, + fetchOk: true, + }, + deps: { + manager: "pnpm", + status: "ok", + lockfilePath: "/tmp/clawdbot/pnpm-lock.yaml", + markerPath: "/tmp/clawdbot/node_modules/.modules.yaml", + }, + registry: { latestVersion: "0.0.0" }, + }), + compareSemverStrings: vi.fn(() => 0), +})); vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { diff --git a/src/commands/status.ts b/src/commands/status.ts index 3aa94c451..d500b08de 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,3 +1,7 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, @@ -5,24 +9,47 @@ import { DEFAULT_PROVIDER, } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import { withProgress } from "../cli/progress.js"; -import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import { + loadConfig, + readConfigFileSnapshot, + resolveGatewayPort, +} from "../config/config.js"; import { loadSessionStore, resolveMainSessionKey, resolveStorePath, type SessionEntry, } from "../config/sessions.js"; +import { readLastGatewayErrorLine } from "../daemon/diagnostics.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; +import { probeGateway } from "../gateway/probe.js"; +import { listAgentsForGateway } from "../gateway/session-utils.js"; import { info } from "../globals.js"; +import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; +import { resolveOsSummary } from "../infra/os-summary.js"; +import { formatPortDiagnostics, inspectPortUsage } from "../infra/ports.js"; import { buildProviderSummary } from "../infra/provider-summary.js"; import { formatUsageReportLines, loadProviderUsageSummary, } from "../infra/provider-usage.js"; +import { collectProvidersStatusIssues } from "../infra/providers-status-issues.js"; +import { + readRestartSentinel, + summarizeRestartSentinel, +} from "../infra/restart-sentinel.js"; import { peekSystemEvents } from "../infra/system-events.js"; +import { + checkUpdateStatus, + compareSemverStrings, + type UpdateCheckResult, +} from "../infra/update-check.js"; import type { RuntimeEnv } from "../runtime.js"; +import { theme } from "../terminal/theme.js"; +import { VERSION } from "../version.js"; import { resolveWhatsAppAccount } from "../web/accounts.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { @@ -32,6 +59,7 @@ import { } from "../web/session.js"; import type { HealthSummary } from "./health.js"; import { resolveControlUiLinks } from "./onboard-helpers.js"; +import { statusAllCommand } from "./status-all.js"; export type SessionStatus = { key: string; @@ -172,6 +200,12 @@ const formatAge = (ms: number | null | undefined) => { return `${days}d ago`; }; +const formatDuration = (ms: number | null | undefined) => { + if (ms == null || !Number.isFinite(ms)) return "unknown"; + if (ms < 1000) return `${Math.round(ms)}ms`; + return `${(ms / 1000).toFixed(1)}s`; +}; + const formatContextUsage = ( total: number | null | undefined, contextTokens: number | null | undefined, @@ -236,6 +270,230 @@ async function getDaemonShortLine(): Promise { } } +type AgentLocalStatus = { + id: string; + name?: string; + workspaceDir: string | null; + bootstrapPending: boolean | null; + sessionsPath: string; + sessionsCount: number; + lastUpdatedAt: number | null; + lastActiveAgeMs: number | null; +}; + +async function fileExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function getAgentLocalStatuses(): Promise<{ + defaultId: string; + agents: AgentLocalStatus[]; + totalSessions: number; + bootstrapPendingCount: number; +}> { + const cfg = loadConfig(); + const agentList = listAgentsForGateway(cfg); + const now = Date.now(); + + const statuses: AgentLocalStatus[] = []; + for (const agent of agentList.agents) { + const agentId = agent.id; + const workspaceDir = (() => { + try { + return resolveAgentWorkspaceDir(cfg, agentId); + } catch { + return null; + } + })(); + + const bootstrapPath = + workspaceDir != null ? path.join(workspaceDir, "BOOTSTRAP.md") : null; + const bootstrapPending = + bootstrapPath != null ? await fileExists(bootstrapPath) : null; + + const sessionsPath = resolveStorePath(cfg.session?.store, { agentId }); + const store = (() => { + try { + return loadSessionStore(sessionsPath); + } catch { + return {}; + } + })(); + const sessions = Object.entries(store) + .filter(([key]) => key !== "global" && key !== "unknown") + .map(([, entry]) => entry); + const sessionsCount = sessions.length; + const lastUpdatedAt = sessions.reduce( + (max, e) => Math.max(max, e?.updatedAt ?? 0), + 0, + ); + const resolvedLastUpdatedAt = lastUpdatedAt > 0 ? lastUpdatedAt : null; + const lastActiveAgeMs = resolvedLastUpdatedAt + ? now - resolvedLastUpdatedAt + : null; + + statuses.push({ + id: agentId, + name: agent.name, + workspaceDir, + bootstrapPending, + sessionsPath, + sessionsCount, + lastUpdatedAt: resolvedLastUpdatedAt, + lastActiveAgeMs, + }); + } + + const totalSessions = statuses.reduce((sum, s) => sum + s.sessionsCount, 0); + const bootstrapPendingCount = statuses.reduce( + (sum, s) => sum + (s.bootstrapPending ? 1 : 0), + 0, + ); + return { + defaultId: agentList.defaultId, + agents: statuses, + totalSessions, + bootstrapPendingCount, + }; +} + +function resolveGatewayProbeAuth(cfg: ReturnType): { + token?: string; + password?: string; +} { + const isRemoteMode = cfg.gateway?.mode === "remote"; + const remote = isRemoteMode ? cfg.gateway?.remote : undefined; + const authToken = cfg.gateway?.auth?.token; + const authPassword = cfg.gateway?.auth?.password; + const token = isRemoteMode + ? typeof remote?.token === "string" && remote.token.trim().length > 0 + ? remote.token.trim() + : undefined + : process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + (typeof authToken === "string" && authToken.trim().length > 0 + ? authToken.trim() + : undefined); + const password = + process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + (isRemoteMode + ? typeof remote?.password === "string" && + remote.password.trim().length > 0 + ? remote.password.trim() + : undefined + : typeof authPassword === "string" && authPassword.trim().length > 0 + ? authPassword.trim() + : undefined); + return { token, password }; +} + +function pickGatewaySelfPresence(presence: unknown): { + host?: string; + ip?: string; + version?: string; + platform?: string; +} | null { + if (!Array.isArray(presence)) return null; + const entries = presence as Array>; + const self = + entries.find((e) => e.mode === "gateway" && e.reason === "self") ?? null; + if (!self) return null; + return { + host: typeof self.host === "string" ? self.host : undefined, + ip: typeof self.ip === "string" ? self.ip : undefined, + version: typeof self.version === "string" ? self.version : undefined, + platform: typeof self.platform === "string" ? self.platform : undefined, + }; +} + +async function getUpdateCheckResult(params: { + timeoutMs: number; + fetchGit: boolean; + includeRegistry: boolean; +}): Promise { + const root = await resolveClawdbotPackageRoot({ + moduleUrl: import.meta.url, + argv1: process.argv[1], + cwd: process.cwd(), + }); + return await checkUpdateStatus({ + root, + timeoutMs: params.timeoutMs, + fetchGit: params.fetchGit, + includeRegistry: params.includeRegistry, + }); +} + +function formatUpdateOneLiner(update: UpdateCheckResult): string { + const parts: string[] = []; + if (update.installKind === "git" && update.git) { + const branch = update.git.branch ? `git ${update.git.branch}` : "git"; + parts.push(branch); + if (update.git.upstream) parts.push(`↔ ${update.git.upstream}`); + if (update.git.dirty === true) parts.push("dirty"); + if (update.git.behind != null && update.git.ahead != null) { + if (update.git.behind === 0 && update.git.ahead === 0) { + parts.push("up to date"); + } else if (update.git.behind > 0 && update.git.ahead === 0) { + parts.push(`behind ${update.git.behind}`); + } else if (update.git.behind === 0 && update.git.ahead > 0) { + parts.push(`ahead ${update.git.ahead}`); + } else if (update.git.behind > 0 && update.git.ahead > 0) { + parts.push( + `diverged (ahead ${update.git.ahead}, behind ${update.git.behind})`, + ); + } + } + if (update.git.fetchOk === false) parts.push("fetch failed"); + } else { + parts.push( + update.packageManager !== "unknown" ? update.packageManager : "pkg", + ); + if (update.registry?.latestVersion) { + const cmp = compareSemverStrings(VERSION, update.registry.latestVersion); + if (cmp === 0) parts.push(`latest ${update.registry.latestVersion}`); + else if (cmp != null && cmp < 0) { + parts.push(`update available ${update.registry.latestVersion}`); + } else { + parts.push(`latest ${update.registry.latestVersion}`); + } + } else if (update.registry?.error) { + parts.push("latest unknown"); + } + } + + if (update.deps) { + if (update.deps.status === "ok") parts.push("deps ok"); + if (update.deps.status === "missing") parts.push("deps missing"); + if (update.deps.status === "stale") parts.push("deps stale"); + } + return `Update: ${parts.join(" · ")}`; +} + +function formatCheckLine(params: { + ok: boolean; + label: string; + detail?: string | null; + warn?: boolean; +}) { + const symbol = params.ok + ? theme.success("\u2713") + : params.warn + ? theme.warn("!") + : theme.error("\u2717"); + const label = params.ok + ? theme.success(params.label) + : params.warn + ? theme.warn(params.label) + : theme.error(params.label); + const detail = params.detail?.trim() ? ` ${theme.muted(params.detail)}` : ""; + return `${symbol} ${label}${detail}`; +} + const buildFlags = (entry: SessionEntry): string[] => { const flags: string[] = []; const think = entry?.thinkingLevel; @@ -265,11 +523,101 @@ export async function statusCommand( usage?: boolean; timeoutMs?: number; verbose?: boolean; + all?: boolean; }, runtime: RuntimeEnv, ) { - const cfg = loadConfig(); - const summary = await getStatusSummary(); + if (opts.all && !opts.json) { + await statusAllCommand(runtime, { timeoutMs: opts.timeoutMs }); + return; + } + + const scan = await withProgress( + { + label: "Scanning status…", + total: 6, + enabled: opts.json !== true, + }, + async (progress) => { + progress.setLabel("Loading config…"); + const cfg = loadConfig(); + const osSummary = resolveOsSummary(); + progress.tick(); + + progress.setLabel("Checking for updates…"); + const updateTimeoutMs = opts.all ? 6500 : 2500; + const update = await getUpdateCheckResult({ + timeoutMs: updateTimeoutMs, + fetchGit: true, + includeRegistry: true, + }); + progress.tick(); + + progress.setLabel("Resolving agents…"); + const agentStatus = await getAgentLocalStatuses(); + progress.tick(); + + progress.setLabel("Probing gateway…"); + const gatewayConnection = buildGatewayConnectionDetails(); + const isRemoteMode = cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof cfg.gateway?.remote?.url === "string" + ? cfg.gateway.remote.url + : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); + const gatewayMode = isRemoteMode ? "remote" : "local"; + const gatewayProbe = remoteUrlMissing + ? null + : await probeGateway({ + url: gatewayConnection.url, + auth: resolveGatewayProbeAuth(cfg), + timeoutMs: Math.min( + opts.all ? 5000 : 2500, + opts.timeoutMs ?? 10_000, + ), + }).catch(() => null); + const gatewayReachable = gatewayProbe?.ok === true; + const gatewaySelf = gatewayProbe?.presence + ? pickGatewaySelfPresence(gatewayProbe.presence) + : null; + progress.tick(); + + progress.setLabel("Reading sessions…"); + const summary = await getStatusSummary(); + progress.tick(); + + progress.setLabel("Rendering…"); + progress.tick(); + + return { + cfg, + osSummary, + update, + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbe, + gatewayReachable, + gatewaySelf, + agentStatus, + summary, + }; + }, + ); + + const { + cfg, + osSummary, + update, + gatewayConnection, + remoteUrlMissing, + gatewayMode, + gatewayProbe, + gatewayReachable, + gatewaySelf, + agentStatus, + summary, + } = scan; const usage = opts.usage ? await withProgress( { @@ -299,7 +647,23 @@ export async function statusCommand( if (opts.json) { runtime.log( JSON.stringify( - health || usage ? { ...summary, health, usage } : summary, + { + ...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, ), @@ -307,7 +671,7 @@ export async function statusCommand( return; } - if (opts.verbose) { + if (opts.verbose || opts.all) { const details = buildGatewayConnectionDetails(); runtime.log(info("Gateway connection:")); for (const line of details.message.split("\n")) { @@ -326,6 +690,50 @@ export async function statusCommand( }); runtime.log(info(`Dashboard: ${links.httpUrl}`)); } + + runtime.log(info(`OS: ${osSummary.label} · node ${process.versions.node}`)); + runtime.log(info(formatUpdateOneLiner(update))); + + const gatewayLine = (() => { + const target = remoteUrlMissing + ? "(missing gateway.remote.url)" + : gatewayConnection.url; + const reach = remoteUrlMissing + ? "misconfigured (missing gateway.remote.url)" + : gatewayReachable + ? `reachable (${formatDuration(gatewayProbe?.connectLatencyMs)})` + : gatewayProbe?.error + ? `unreachable (${gatewayProbe.error})` + : "unreachable"; + 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 `Gateway: ${gatewayMode} · ${target} · ${reach}${suffix}`; + })(); + runtime.log(info(gatewayLine)); + + const agentLine = (() => { + 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 `Agents: ${agentStatus.agents.length} · ${pending} · sessions ${agentStatus.totalSessions}${defSuffix}`; + })(); + runtime.log(info(agentLine)); + runtime.log( `Web session: ${summary.web.linked ? "linked" : "not linked"}${summary.web.linked ? ` (last refreshed ${formatAge(summary.web.authAgeMs)})` : ""}`, ); @@ -342,6 +750,217 @@ export async function statusCommand( if (daemonLine) { runtime.log(info(daemonLine)); } + + if (opts.all) { + runtime.log(""); + runtime.log(theme.heading("Diagnosis (read-only):")); + + const snap = await readConfigFileSnapshot().catch(() => null); + if (snap) { + runtime.log( + formatCheckLine({ + ok: Boolean(snap.exists && snap.valid), + warn: Boolean(snap.exists && !snap.valid), + label: `Config: ${snap.path ?? "(unknown)"}`, + detail: snap.exists + ? snap.valid + ? "valid" + : `invalid (${snap.issues.length} issues)` + : "missing", + }), + ); + const issues = [...(snap.legacyIssues ?? []), ...(snap.issues ?? [])]; + const uniqueIssues = issues.filter( + (issue, index) => + issues.findIndex( + (x) => x.path === issue.path && x.message === issue.message, + ) === index, + ); + for (const issue of uniqueIssues.slice(0, 12)) { + runtime.log(` - ${issue.path}: ${issue.message}`); + } + if (uniqueIssues.length > 12) { + runtime.log(theme.muted(` … +${uniqueIssues.length - 12} more`)); + } + } else { + runtime.log( + formatCheckLine({ + ok: false, + label: "Config: unknown", + detail: "read failed", + }), + ); + } + + const sentinel = await readRestartSentinel().catch(() => null); + if (sentinel?.payload) { + runtime.log( + formatCheckLine({ + ok: true, + label: "Restart sentinel", + detail: `${summarizeRestartSentinel(sentinel.payload)} · ${formatAge(Date.now() - sentinel.payload.ts)}`, + warn: true, + }), + ); + } else { + runtime.log( + formatCheckLine({ + ok: true, + label: "Restart sentinel", + detail: "none", + }), + ); + } + + const lastErr = await readLastGatewayErrorLine(process.env).catch( + () => null, + ); + if (lastErr) { + runtime.log( + formatCheckLine({ + ok: true, + warn: true, + label: "Gateway last log line", + detail: lastErr, + }), + ); + } else { + runtime.log( + formatCheckLine({ + ok: true, + label: "Gateway last log line", + detail: "none", + }), + ); + } + + const port = resolveGatewayPort(cfg); + const portUsage = await inspectPortUsage(port).catch(() => null); + if (portUsage) { + const ok = portUsage.listeners.length === 0; + runtime.log( + formatCheckLine({ + ok, + warn: !ok, + label: `Port ${port}`, + detail: ok ? "free" : "in use", + }), + ); + if (!ok) { + for (const line of formatPortDiagnostics(portUsage)) { + runtime.log(` ${line}`); + } + } + } + + const defaultWorkspace = + agentStatus.agents.find((a) => a.id === agentStatus.defaultId) + ?.workspaceDir ?? + agentStatus.agents[0]?.workspaceDir ?? + null; + const skillStatus = + defaultWorkspace != null + ? (() => { + try { + return buildWorkspaceSkillStatus(defaultWorkspace, { + config: cfg, + }); + } catch { + return null; + } + })() + : null; + if (skillStatus) { + const eligible = skillStatus.skills.filter((s) => s.eligible).length; + const missing = skillStatus.skills.filter( + (s) => s.eligible && Object.values(s.missing).some((arr) => arr.length), + ).length; + runtime.log( + formatCheckLine({ + ok: missing === 0, + warn: missing > 0, + label: "Skills", + detail: `${eligible} eligible · ${missing} missing requirements · ${skillStatus.workspaceDir}`, + }), + ); + } + + runtime.log(""); + runtime.log(theme.heading("Agents:")); + for (const agent of agentStatus.agents) { + const name = agent.name ? ` (${agent.name})` : ""; + const bootstrap = + agent.bootstrapPending === true + ? theme.warn("BOOTSTRAP.md pending") + : agent.bootstrapPending === false + ? theme.success("bootstrapped") + : theme.muted("bootstrap unknown"); + const active = + agent.lastActiveAgeMs != null + ? formatAge(agent.lastActiveAgeMs) + : "unknown"; + runtime.log( + `- ${theme.info(agent.id)}${name} · ${bootstrap} · sessions ${agent.sessionsCount} · active ${active}`, + ); + if (agent.workspaceDir) + runtime.log(theme.muted(` workspace: ${agent.workspaceDir}`)); + runtime.log(theme.muted(` sessions: ${agent.sessionsPath}`)); + } + + if (gatewayReachable) { + const providersStatus = await callGateway>({ + method: "providers.status", + params: { probe: false, timeoutMs: opts.timeoutMs ?? 10_000 }, + timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000), + }).catch(() => null); + if (providersStatus) { + const issues = collectProvidersStatusIssues(providersStatus); + runtime.log( + formatCheckLine({ + ok: issues.length === 0, + warn: issues.length > 0, + label: "Provider config/runtime issues", + detail: issues.length ? String(issues.length) : "none", + }), + ); + for (const issue of issues.slice(0, 8)) { + runtime.log( + ` - ${issue.provider}[${issue.accountId}] ${issue.kind}: ${issue.message}`, + ); + if (issue.fix) runtime.log(theme.muted(` fix: ${issue.fix}`)); + } + if (issues.length > 8) { + runtime.log(theme.muted(` … +${issues.length - 8} more`)); + } + } else { + runtime.log( + formatCheckLine({ + ok: false, + warn: true, + label: "Provider config/runtime issues", + detail: "skipped (gateway query failed)", + }), + ); + } + } else { + runtime.log( + formatCheckLine({ + ok: false, + warn: true, + label: "Provider config/runtime issues", + detail: "skipped (gateway unreachable)", + }), + ); + } + + runtime.log(""); + runtime.log( + theme.muted( + "Tip: This output is safe to paste for debugging (no tokens).", + ), + ); + } + runtime.log(""); if (health) { runtime.log(info("Gateway health: reachable")); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 9ae5c0fdb..60ab77c24 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -141,7 +141,9 @@ describe("buildGatewayConnectionDetails", () => { const details = buildGatewayConnectionDetails(); expect(details.url).toBe("ws://127.0.0.1:18789"); - expect(details.urlSource).toBe("local loopback"); + expect(details.urlSource).toBe( + "missing gateway.remote.url (fallback local)", + ); expect(details.bindDetail).toBe("Bind: loopback"); expect(details.remoteFallbackNote).toContain( "gateway.mode=remote but gateway.remote.url is missing", @@ -231,6 +233,18 @@ describe("callGateway error details", () => { expect(err?.message).toContain("Source: local loopback"); expect(err?.message).toContain("Bind: loopback"); }); + + it("fails fast when remote mode is missing remote url", async () => { + loadConfig.mockReturnValue({ + gateway: { mode: "remote", bind: "loopback", remote: {} }, + }); + await expect( + callGateway({ + method: "health", + timeoutMs: 10, + }), + ).rejects.toThrow("gateway remote mode misconfigured"); + }); }); describe("callGateway password resolution", () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index a381151a7..800fc4cb5 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -66,18 +66,20 @@ export function buildGatewayConnectionDetails( typeof remote?.url === "string" && remote.url.trim().length > 0 ? remote.url.trim() : undefined; + const remoteMisconfigured = isRemoteMode && !urlOverride && !remoteUrl; const url = urlOverride || remoteUrl || localUrl; const urlSource = urlOverride ? "cli --url" : remoteUrl ? "config gateway.remote.url" - : preferTailnet && tailnetIPv4 - ? `local tailnet ${tailnetIPv4}` - : "local loopback"; - const remoteFallbackNote = - isRemoteMode && !urlOverride && !remoteUrl - ? "Note: gateway.mode=remote but gateway.remote.url is missing; using local URL." - : undefined; + : remoteMisconfigured + ? "missing gateway.remote.url (fallback local)" + : preferTailnet && tailnetIPv4 + ? `local tailnet ${tailnetIPv4}` + : "local loopback"; + const remoteFallbackNote = remoteMisconfigured + ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." + : undefined; const bindDetail = !urlOverride && !remoteUrl ? `Bind: ${bindMode}` : undefined; const message = [ @@ -106,11 +108,31 @@ export async function callGateway( const config = loadConfig(); const isRemoteMode = config.gateway?.mode === "remote"; const remote = isRemoteMode ? config.gateway?.remote : undefined; + const urlOverride = + typeof opts.url === "string" && opts.url.trim().length > 0 + ? opts.url.trim() + : undefined; + const remoteUrl = + typeof remote?.url === "string" && remote.url.trim().length > 0 + ? remote.url.trim() + : undefined; + if (isRemoteMode && !urlOverride && !remoteUrl) { + const configPath = + opts.configPath ?? + resolveConfigPath(process.env, resolveStateDir(process.env)); + throw new Error( + [ + "gateway remote mode misconfigured: gateway.remote.url missing", + `Config: ${configPath}`, + "Fix: set gateway.remote.url, or set gateway.mode=local.", + ].join("\n"), + ); + } const authToken = config.gateway?.auth?.token; const authPassword = config.gateway?.auth?.password; const connectionDetails = buildGatewayConnectionDetails({ config, - url: opts.url, + url: urlOverride, ...(opts.configPath ? { configPath: opts.configPath } : {}), }); const url = connectionDetails.url; diff --git a/src/infra/os-summary.ts b/src/infra/os-summary.ts new file mode 100644 index 000000000..071384146 --- /dev/null +++ b/src/infra/os-summary.ts @@ -0,0 +1,31 @@ +import { spawnSync } from "node:child_process"; +import os from "node:os"; + +export type OsSummary = { + platform: NodeJS.Platform; + arch: string; + release: string; + label: string; +}; + +function safeTrim(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function macosVersion(): string { + const res = spawnSync("sw_vers", ["-productVersion"], { encoding: "utf-8" }); + const out = safeTrim(res.stdout); + return out || os.release(); +} + +export function resolveOsSummary(): OsSummary { + const platform = os.platform(); + const release = os.release(); + const arch = os.arch(); + const label = (() => { + if (platform === "darwin") return `macos ${macosVersion()} (${arch})`; + if (platform === "win32") return `windows ${release} (${arch})`; + return `${platform} ${release} (${arch})`; + })(); + return { platform, arch, release, label }; +} diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index cbb889643..a7ff71704 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -7,6 +7,7 @@ import { listIMessageAccountIds, resolveIMessageAccount, } from "../imessage/accounts.js"; +import { resolveMSTeamsCredentials } from "../msteams/token.js"; import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import { listSignalAccountIds, @@ -297,6 +298,27 @@ export async function buildProviderSummary( } } + const msEnabled = effective.msteams?.enabled !== false; + if (!msEnabled) { + lines.push(tint("MS Teams: disabled", theme.muted)); + } else { + const configured = Boolean(resolveMSTeamsCredentials(effective.msteams)); + lines.push( + configured + ? tint("MS Teams: configured", theme.success) + : tint("MS Teams: not configured", theme.muted), + ); + if (configured && resolved.includeAllowFrom) { + const allowFrom = (effective.msteams?.allowFrom ?? []) + .map((val) => val.trim()) + .filter(Boolean) + .slice(0, 2); + if (allowFrom.length > 0) { + lines.push(accountLine("default", [`allow:${allowFrom.join(",")}`])); + } + } + } + return lines; } diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts new file mode 100644 index 000000000..00fbd8f0e --- /dev/null +++ b/src/infra/update-check.ts @@ -0,0 +1,364 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { runCommandWithTimeout } from "../process/exec.js"; +import { parseSemver } from "./runtime-guard.js"; + +export type PackageManager = "pnpm" | "bun" | "npm" | "unknown"; + +export type GitUpdateStatus = { + root: string; + branch: string | null; + upstream: string | null; + dirty: boolean | null; + ahead: number | null; + behind: number | null; + fetchOk: boolean | null; + error?: string; +}; + +export type DepsStatus = { + manager: PackageManager; + status: "ok" | "missing" | "stale" | "unknown"; + lockfilePath: string | null; + markerPath: string | null; + reason?: string; +}; + +export type RegistryStatus = { + latestVersion: string | null; + error?: string; +}; + +export type UpdateCheckResult = { + root: string | null; + installKind: "git" | "package" | "unknown"; + packageManager: PackageManager; + git?: GitUpdateStatus; + deps?: DepsStatus; + registry?: RegistryStatus; +}; + +async function exists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } +} + +async function detectPackageManager(root: string): Promise { + try { + const raw = await fs.readFile(path.join(root, "package.json"), "utf-8"); + const parsed = JSON.parse(raw) as { packageManager?: string }; + const pm = parsed?.packageManager?.split("@")[0]?.trim(); + if (pm === "pnpm" || pm === "bun" || pm === "npm") return pm; + } catch { + // ignore + } + + const files = await fs.readdir(root).catch((): string[] => []); + if (files.includes("pnpm-lock.yaml")) return "pnpm"; + if (files.includes("bun.lockb")) return "bun"; + if (files.includes("package-lock.json")) return "npm"; + return "unknown"; +} + +async function detectGitRoot(root: string): Promise { + const res = await runCommandWithTimeout( + ["git", "-C", root, "rev-parse", "--show-toplevel"], + { timeoutMs: 4000 }, + ).catch(() => null); + if (!res || res.code !== 0) return null; + const top = res.stdout.trim(); + return top ? path.resolve(top) : null; +} + +export async function checkGitUpdateStatus(params: { + root: string; + timeoutMs?: number; + fetch?: boolean; +}): Promise { + const timeoutMs = params.timeoutMs ?? 6000; + const root = path.resolve(params.root); + + const base: GitUpdateStatus = { + root, + branch: null, + upstream: null, + dirty: null, + ahead: null, + behind: null, + fetchOk: null, + }; + + const branchRes = await runCommandWithTimeout( + ["git", "-C", root, "rev-parse", "--abbrev-ref", "HEAD"], + { timeoutMs }, + ).catch(() => null); + if (!branchRes || branchRes.code !== 0) { + return { ...base, error: branchRes?.stderr?.trim() || "git unavailable" }; + } + const branch = branchRes.stdout.trim() || null; + + const upstreamRes = await runCommandWithTimeout( + ["git", "-C", root, "rev-parse", "--abbrev-ref", "@{upstream}"], + { timeoutMs }, + ).catch(() => null); + const upstream = + upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null; + + const dirtyRes = await runCommandWithTimeout( + ["git", "-C", root, "status", "--porcelain"], + { timeoutMs }, + ).catch(() => null); + const dirty = + dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null; + + const fetchOk = params.fetch + ? await runCommandWithTimeout( + ["git", "-C", root, "fetch", "--quiet", "--prune"], + { timeoutMs }, + ) + .then((r) => r.code === 0) + .catch(() => false) + : null; + + const counts = + upstream && upstream.length > 0 + ? await runCommandWithTimeout( + [ + "git", + "-C", + root, + "rev-list", + "--left-right", + "--count", + `HEAD...${upstream}`, + ], + { timeoutMs }, + ).catch(() => null) + : null; + + const parseCounts = ( + raw: string, + ): { ahead: number; behind: number } | null => { + const parts = raw.trim().split(/\s+/); + if (parts.length < 2) return null; + const ahead = Number.parseInt(parts[0] ?? "", 10); + const behind = Number.parseInt(parts[1] ?? "", 10); + if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null; + return { ahead, behind }; + }; + const parsed = + counts && counts.code === 0 ? parseCounts(counts.stdout) : null; + + return { + root, + branch, + upstream, + dirty, + ahead: parsed?.ahead ?? null, + behind: parsed?.behind ?? null, + fetchOk, + }; +} + +async function statMtimeMs(p: string): Promise { + try { + const st = await fs.stat(p); + return st.mtimeMs; + } catch { + return null; + } +} + +function resolveDepsMarker(params: { root: string; manager: PackageManager }): { + lockfilePath: string | null; + markerPath: string | null; +} { + const root = params.root; + if (params.manager === "pnpm") { + return { + lockfilePath: path.join(root, "pnpm-lock.yaml"), + markerPath: path.join(root, "node_modules", ".modules.yaml"), + }; + } + if (params.manager === "bun") { + return { + lockfilePath: path.join(root, "bun.lockb"), + markerPath: path.join(root, "node_modules"), + }; + } + if (params.manager === "npm") { + return { + lockfilePath: path.join(root, "package-lock.json"), + markerPath: path.join(root, "node_modules"), + }; + } + return { lockfilePath: null, markerPath: null }; +} + +export async function checkDepsStatus(params: { + root: string; + manager: PackageManager; +}): Promise { + const root = path.resolve(params.root); + const { lockfilePath, markerPath } = resolveDepsMarker({ + root, + manager: params.manager, + }); + + if (!lockfilePath || !markerPath) { + return { + manager: params.manager, + status: "unknown", + lockfilePath, + markerPath, + reason: "unknown package manager", + }; + } + + const lockExists = await exists(lockfilePath); + const markerExists = await exists(markerPath); + if (!lockExists) { + return { + manager: params.manager, + status: "unknown", + lockfilePath, + markerPath, + reason: "lockfile missing", + }; + } + if (!markerExists) { + return { + manager: params.manager, + status: "missing", + lockfilePath, + markerPath, + reason: "node_modules marker missing", + }; + } + + const lockMtime = await statMtimeMs(lockfilePath); + const markerMtime = await statMtimeMs(markerPath); + if (!lockMtime || !markerMtime) { + return { + manager: params.manager, + status: "unknown", + lockfilePath, + markerPath, + }; + } + if (lockMtime > markerMtime + 1000) { + return { + manager: params.manager, + status: "stale", + lockfilePath, + markerPath, + reason: "lockfile newer than install marker", + }; + } + return { + manager: params.manager, + status: "ok", + lockfilePath, + markerPath, + }; +} + +async function fetchWithTimeout( + url: string, + timeoutMs: number, +): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), Math.max(250, timeoutMs)); + try { + return await fetch(url, { signal: ctrl.signal }); + } finally { + clearTimeout(t); + } +} + +export async function fetchNpmLatestVersion(params?: { + timeoutMs?: number; +}): Promise { + const timeoutMs = params?.timeoutMs ?? 3500; + try { + const res = await fetchWithTimeout( + "https://registry.npmjs.org/clawdbot/latest", + timeoutMs, + ); + if (!res.ok) { + return { latestVersion: null, error: `HTTP ${res.status}` }; + } + const json = (await res.json()) as { version?: unknown }; + const latestVersion = + typeof json?.version === "string" ? json.version : null; + return { latestVersion }; + } catch (err) { + return { latestVersion: null, error: String(err) }; + } +} + +export function compareSemverStrings( + a: string | null, + b: string | null, +): number | null { + const pa = parseSemver(a); + const pb = parseSemver(b); + if (!pa || !pb) return null; + if (pa.major !== pb.major) return pa.major < pb.major ? -1 : 1; + if (pa.minor !== pb.minor) return pa.minor < pb.minor ? -1 : 1; + if (pa.patch !== pb.patch) return pa.patch < pb.patch ? -1 : 1; + return 0; +} + +export async function checkUpdateStatus(params: { + root: string | null; + timeoutMs?: number; + fetchGit?: boolean; + includeRegistry?: boolean; +}): Promise { + const timeoutMs = params.timeoutMs ?? 6000; + const root = params.root ? path.resolve(params.root) : null; + if (!root) { + return { + root: null, + installKind: "unknown", + packageManager: "unknown", + registry: params.includeRegistry + ? await fetchNpmLatestVersion({ timeoutMs }) + : undefined, + }; + } + + const pm = await detectPackageManager(root); + const gitRoot = await detectGitRoot(root); + const isGit = gitRoot && path.resolve(gitRoot) === root; + + const installKind: UpdateCheckResult["installKind"] = isGit + ? "git" + : "package"; + const git = isGit + ? await checkGitUpdateStatus({ + root, + timeoutMs, + fetch: Boolean(params.fetchGit), + }) + : undefined; + const deps = await checkDepsStatus({ root, manager: pm }); + const registry = params.includeRegistry + ? await fetchNpmLatestVersion({ timeoutMs }) + : undefined; + + return { + root, + installKind, + packageManager: pm, + git, + deps, + registry, + }; +} diff --git a/src/logging.ts b/src/logging.ts index 3c8604430..75fead97a 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -402,7 +402,11 @@ function isRichConsoleEnv(): boolean { } function getColorForConsole(): ChalkInstance { - if (process.env.NO_COLOR) return new Chalk({ level: 0 }); + const hasForceColor = + typeof process.env.FORCE_COLOR === "string" && + process.env.FORCE_COLOR.trim().length > 0 && + process.env.FORCE_COLOR.trim() !== "0"; + if (process.env.NO_COLOR && !hasForceColor) return new Chalk({ level: 0 }); const hasTty = Boolean(process.stdout.isTTY || process.stderr.isTTY); return hasTty || isRichConsoleEnv() ? new Chalk({ level: 1 }) diff --git a/src/terminal/ansi.ts b/src/terminal/ansi.ts new file mode 100644 index 000000000..c3475d1eb --- /dev/null +++ b/src/terminal/ansi.ts @@ -0,0 +1,14 @@ +const ANSI_SGR_PATTERN = "\\x1b\\[[0-9;]*m"; +// OSC-8 hyperlinks: ESC ] 8 ; ; url ST ... ESC ] 8 ; ; ST +const OSC8_PATTERN = "\\x1b\\]8;;.*?\\x1b\\\\|\\x1b\\]8;;\\x1b\\\\"; + +const ANSI_REGEX = new RegExp(ANSI_SGR_PATTERN, "g"); +const OSC8_REGEX = new RegExp(OSC8_PATTERN, "g"); + +export function stripAnsi(input: string): string { + return input.replace(OSC8_REGEX, "").replace(ANSI_REGEX, ""); +} + +export function visibleWidth(input: string): number { + return Array.from(stripAnsi(input)).length; +} diff --git a/src/terminal/table.test.ts b/src/terminal/table.test.ts new file mode 100644 index 000000000..4de60503b --- /dev/null +++ b/src/terminal/table.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { renderTable } from "./table.js"; + +describe("renderTable", () => { + it("prefers shrinking flex columns to avoid wrapping non-flex labels", () => { + const out = renderTable({ + width: 40, + columns: [ + { key: "Item", header: "Item", minWidth: 10 }, + { key: "Value", header: "Value", flex: true, minWidth: 24 }, + ], + rows: [{ Item: "Dashboard", Value: "http://127.0.0.1:18789/" }], + }); + + expect(out).toContain("Dashboard"); + expect(out).toMatch(/│ Dashboard\s+│/); + }); +}); diff --git a/src/terminal/table.ts b/src/terminal/table.ts new file mode 100644 index 000000000..6c9907fef --- /dev/null +++ b/src/terminal/table.ts @@ -0,0 +1,265 @@ +import { visibleWidth } from "./ansi.js"; + +type Align = "left" | "right" | "center"; + +export type TableColumn = { + key: string; + header: string; + align?: Align; + minWidth?: number; + maxWidth?: number; + flex?: boolean; +}; + +export type RenderTableOptions = { + columns: TableColumn[]; + rows: Array>; + width?: number; + padding?: number; + border?: "unicode" | "ascii" | "none"; +}; + +function repeat(ch: string, n: number): string { + if (n <= 0) return ""; + return ch.repeat(n); +} + +function padCell(text: string, width: number, align: Align): string { + const w = visibleWidth(text); + if (w >= width) return text; + const pad = width - w; + if (align === "right") return `${repeat(" ", pad)}${text}`; + if (align === "center") { + const left = Math.floor(pad / 2); + const right = pad - left; + return `${repeat(" ", left)}${text}${repeat(" ", right)}`; + } + return `${text}${repeat(" ", pad)}`; +} + +function wrapLine(text: string, width: number): string[] { + if (width <= 0) return [text]; + const words = text.split(/(\s+)/).filter(Boolean); + const lines: string[] = []; + let current = ""; + let currentWidth = 0; + const push = (value: string) => lines.push(value.replace(/\s+$/, "")); + + const flush = () => { + if (current.trim().length === 0) return; + push(current); + current = ""; + currentWidth = 0; + }; + + const breakLong = (word: string) => { + const parts: string[] = []; + let buf = ""; + let lastBreakAt = 0; + const isBreakChar = (ch: string) => + ch === "/" || ch === "-" || ch === "_" || ch === "."; + for (const ch of Array.from(word)) { + const next = buf + ch; + if (visibleWidth(next) > width && buf) { + if (lastBreakAt > 0) { + parts.push(buf.slice(0, lastBreakAt)); + buf = `${buf.slice(lastBreakAt)}${ch}`; + lastBreakAt = 0; + for (let i = 0; i < buf.length; i += 1) { + const c = buf[i]; + if (c && isBreakChar(c)) lastBreakAt = i + 1; + } + } else { + parts.push(buf); + buf = ch; + } + } else { + buf = next; + if (isBreakChar(ch)) lastBreakAt = buf.length; + } + } + if (buf) parts.push(buf); + return parts; + }; + + for (const token of words) { + const tokenWidth = visibleWidth(token); + const isSpace = /^\s+$/.test(token); + + if (tokenWidth > width && !isSpace) { + flush(); + for (const part of breakLong(token.replace(/^\s+/, ""))) { + push(part); + } + continue; + } + + if ( + currentWidth + tokenWidth > width && + current.trim().length > 0 && + !isSpace + ) { + flush(); + } + + current += token; + currentWidth = visibleWidth(current); + } + + flush(); + return lines.length ? lines : [""]; +} + +function normalizeWidth(n: number | undefined): number | undefined { + if (n == null) return undefined; + if (!Number.isFinite(n) || n <= 0) return undefined; + return Math.floor(n); +} + +export function renderTable(opts: RenderTableOptions): string { + const border = opts.border ?? "unicode"; + if (border === "none") { + const columns = opts.columns; + const header = columns.map((c) => c.header).join(" | "); + const lines = [ + header, + ...opts.rows.map((r) => columns.map((c) => r[c.key] ?? "").join(" | ")), + ]; + return `${lines.join("\n")}\n`; + } + + const padding = Math.max(0, opts.padding ?? 1); + const columns = opts.columns; + + const metrics = columns.map((c) => { + const headerW = visibleWidth(c.header); + const cellW = Math.max( + 0, + ...opts.rows.map((r) => visibleWidth(r[c.key] ?? "")), + ); + return { headerW, cellW }; + }); + + const widths = columns.map((c, i) => { + const m = metrics[i]; + const base = Math.max(m?.headerW ?? 0, m?.cellW ?? 0) + padding * 2; + const capped = c.maxWidth ? Math.min(base, c.maxWidth) : base; + return Math.max(c.minWidth ?? 3, capped); + }); + + const maxWidth = normalizeWidth(opts.width); + const sepCount = columns.length + 1; + const total = widths.reduce((a, b) => a + b, 0) + sepCount; + + const preferredMinWidths = columns.map((c, i) => + Math.max(c.minWidth ?? 3, (metrics[i]?.headerW ?? 0) + padding * 2, 3), + ); + const absoluteMinWidths = columns.map((_c, i) => + Math.max((metrics[i]?.headerW ?? 0) + padding * 2, 3), + ); + + if (maxWidth && total > maxWidth) { + let over = total - maxWidth; + + const flexOrder = columns + .map((_c, i) => ({ i, w: widths[i] ?? 0 })) + .filter(({ i }) => Boolean(columns[i]?.flex)) + .sort((a, b) => b.w - a.w) + .map((x) => x.i); + + const nonFlexOrder = columns + .map((_c, i) => ({ i, w: widths[i] ?? 0 })) + .filter(({ i }) => !columns[i]?.flex) + .sort((a, b) => b.w - a.w) + .map((x) => x.i); + + const shrink = (order: number[], minWidths: number[]) => { + while (over > 0) { + let progressed = false; + for (const i of order) { + if ((widths[i] ?? 0) <= (minWidths[i] ?? 0)) continue; + widths[i] = (widths[i] ?? 0) - 1; + over -= 1; + progressed = true; + if (over <= 0) break; + } + if (!progressed) break; + } + }; + + // Prefer shrinking flex columns; only shrink non-flex if necessary. + // If required to fit, allow flex columns to shrink below user minWidth + // down to their absolute minimum (header + padding). + shrink(flexOrder, preferredMinWidths); + shrink(flexOrder, absoluteMinWidths); + shrink(nonFlexOrder, preferredMinWidths); + shrink(nonFlexOrder, absoluteMinWidths); + } + + const box = + border === "ascii" + ? { + tl: "+", + tr: "+", + bl: "+", + br: "+", + h: "-", + v: "|", + t: "+", + ml: "+", + m: "+", + mr: "+", + b: "+", + } + : { + tl: "┌", + tr: "┐", + bl: "└", + br: "┘", + h: "─", + v: "│", + t: "┬", + ml: "├", + m: "┼", + mr: "┤", + b: "┴", + }; + + const hLine = (left: string, mid: string, right: string) => + `${left}${widths.map((w) => repeat(box.h, w)).join(mid)}${right}`; + + const contentWidthFor = (i: number) => Math.max(1, widths[i] - padding * 2); + const padStr = repeat(" ", padding); + + const renderRow = (record: Record, isHeader = false) => { + const cells = columns.map((c) => + isHeader ? c.header : (record[c.key] ?? ""), + ); + const wrapped = cells.map((cell, i) => wrapLine(cell, contentWidthFor(i))); + const height = Math.max(...wrapped.map((w) => w.length)); + const out: string[] = []; + for (let li = 0; li < height; li += 1) { + const parts = wrapped.map((lines, i) => { + const raw = lines[li] ?? ""; + const aligned = padCell( + raw, + contentWidthFor(i), + columns[i]?.align ?? "left", + ); + return `${padStr}${aligned}${padStr}`; + }); + out.push(`${box.v}${parts.join(box.v)}${box.v}`); + } + return out; + }; + + const lines: string[] = []; + lines.push(hLine(box.tl, box.t, box.tr)); + lines.push(...renderRow({}, true)); + lines.push(hLine(box.ml, box.m, box.mr)); + for (const row of opts.rows) { + lines.push(...renderRow(row, false)); + } + lines.push(hLine(box.bl, box.b, box.br)); + return `${lines.join("\n")}\n`; +} diff --git a/src/terminal/theme.ts b/src/terminal/theme.ts index 06316623b..b6d8d812b 100644 --- a/src/terminal/theme.ts +++ b/src/terminal/theme.ts @@ -2,7 +2,13 @@ import chalk, { Chalk } from "chalk"; import { LOBSTER_PALETTE } from "./palette.js"; -const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk; +const hasForceColor = + typeof process.env.FORCE_COLOR === "string" && + process.env.FORCE_COLOR.trim().length > 0 && + process.env.FORCE_COLOR.trim() !== "0"; + +const baseChalk = + process.env.NO_COLOR && !hasForceColor ? new Chalk({ level: 0 }) : chalk; const hex = (value: string) => baseChalk.hex(value); @@ -20,8 +26,7 @@ export const theme = { option: hex(LOBSTER_PALETTE.warn), } as const; -export const isRich = () => - Boolean(process.stdout.isTTY && baseChalk.level > 0); +export const isRich = () => Boolean(baseChalk.level > 0); export const colorize = ( rich: boolean,