From 8dd2286e3e6727b0eddcbce127c49bac5bd10310 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 09:14:31 +0100 Subject: [PATCH 1/8] docs: add message examples to cli index --- docs/cli/index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/cli/index.md b/docs/cli/index.md index c754280d7..fa6ddee98 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -301,6 +301,10 @@ Subcommands: - `message voice status` - `message event ` +Examples: +- `clawdbot message send --to +15555550123 --message "Hi"` +- `clawdbot message poll --provider discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi` + ### `agent` Run one agent turn via the Gateway (or `--local` embedded). From 89132fdd251e8698417640b77aaa5483a0337c49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 09:27:18 +0100 Subject: [PATCH 2/8] chore(lint): biome import order --- src/commands/configure.ts | 4 ++-- src/commands/doctor-gateway-services.ts | 2 +- src/commands/doctor-prompter.ts | 5 ++++- src/commands/doctor-security.ts | 2 +- src/commands/doctor-state-integrity.ts | 2 +- src/commands/doctor.ts | 6 +++++- src/commands/models/scan.ts | 6 ++++-- src/wizard/clack-prompter.ts | 2 +- 8 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/commands/configure.ts b/src/commands/configure.ts index e15ba0aef..39c10d084 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -7,8 +7,8 @@ import { note as clackNote, outro as clackOutro, select as clackSelect, - spinner, text as clackText, + spinner, } from "@clack/prompts"; import { loginOpenAICodex, @@ -38,12 +38,12 @@ import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; -import { theme } from "../terminal/theme.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle, } from "../terminal/prompt-style.js"; +import { theme } from "../terminal/theme.js"; import { resolveUserPath, sleep } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index d0ec0940a..a12e1151c 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -25,13 +25,13 @@ import { } from "../daemon/service-audit.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import type { RuntimeEnv } from "../runtime.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; -import { stylePromptTitle } from "../terminal/prompt-style.js"; const note = (message: string, title?: string) => clackNote(message, stylePromptTitle(title)); diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 52cf0adfc..99f7d5a4f 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -1,7 +1,10 @@ import { confirm, select } from "@clack/prompts"; import type { RuntimeEnv } from "../runtime.js"; -import { stylePromptHint, stylePromptMessage } from "../terminal/prompt-style.js"; +import { + stylePromptHint, + stylePromptMessage, +} from "../terminal/prompt-style.js"; import { guardCancel } from "./onboard-helpers.js"; export type DoctorOptions = { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index bc16fcc29..7d529e5d9 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -1,10 +1,10 @@ import { note as clackNote } from "@clack/prompts"; import type { ClawdbotConfig } from "../config/config.js"; -import { stylePromptTitle } from "../terminal/prompt-style.js"; import { readProviderAllowFromStore } from "../pairing/pairing-store.js"; import { readTelegramAllowFromStore } from "../telegram/pairing-store.js"; import { resolveTelegramToken } from "../telegram/token.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; import { normalizeE164 } from "../utils.js"; const note = (message: string, title?: string) => diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index 9eb492ee3..49d849ffa 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -13,8 +13,8 @@ import { resolveSessionTranscriptsDirForAgent, resolveStorePath, } from "../config/sessions.js"; -import { stylePromptTitle } from "../terminal/prompt-style.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; +import { stylePromptTitle } from "../terminal/prompt-style.js"; const note = (message: string, title?: string) => clackNote(message, stylePromptTitle(title)); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 650ad08f9..7e670cc57 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -1,5 +1,9 @@ import path from "node:path"; -import { intro as clackIntro, note as clackNote, outro as clackOutro } from "@clack/prompts"; +import { + intro as clackIntro, + note as clackNote, + outro as clackOutro, +} from "@clack/prompts"; import { buildWorkspaceSkillStatus } from "../agents/skills-status.js"; import type { ClawdbotConfig } from "../config/config.js"; import { diff --git a/src/commands/models/scan.ts b/src/commands/models/scan.ts index 01b5a03c6..586f7f009 100644 --- a/src/commands/models/scan.ts +++ b/src/commands/models/scan.ts @@ -1,7 +1,7 @@ import { cancel, - isCancel, multiselect as clackMultiselect, + isCancel, } from "@clack/prompts"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { @@ -288,7 +288,9 @@ export async function modelsScanCommand( }); if (isCancel(selection)) { - cancel(stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled."); + cancel( + stylePromptTitle("Model scan cancelled.") ?? "Model scan cancelled.", + ); runtime.exit(0); } diff --git a/src/wizard/clack-prompter.ts b/src/wizard/clack-prompter.ts index eeae829ff..923abc13b 100644 --- a/src/wizard/clack-prompter.ts +++ b/src/wizard/clack-prompter.ts @@ -12,12 +12,12 @@ import { text, } from "@clack/prompts"; import { createCliProgress } from "../cli/progress.js"; -import { theme } from "../terminal/theme.js"; import { stylePromptHint, stylePromptMessage, stylePromptTitle, } from "../terminal/prompt-style.js"; +import { theme } from "../terminal/theme.js"; import type { WizardProgress, WizardPrompter } from "./prompts.js"; import { WizardCancelledError } from "./prompts.js"; From 5b50c97939f8c97e6a0c3b0088e7087816200b9f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 09:27:27 +0100 Subject: [PATCH 3/8] feat(cli): improve gateway status output --- CHANGELOG.md | 2 + docs/cli/gateway.md | 25 +- docs/cli/index.md | 3 +- src/cli/gateway-cli.coverage.test.ts | 22 +- src/cli/gateway-cli.ts | 97 ++++- src/cli/program.ts | 2 + src/commands/gateway-status.test.ts | 131 +++++++ src/commands/gateway-status.ts | 523 +++++++++++++++++++++++++++ src/entry.ts | 5 + src/gateway/client.ts | 4 + src/gateway/probe.ts | 123 +++++++ src/terminal/theme.ts | 11 +- 12 files changed, 917 insertions(+), 31 deletions(-) create mode 100644 src/commands/gateway-status.test.ts create mode 100644 src/commands/gateway-status.ts create mode 100644 src/gateway/probe.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 102b3a992..0479c0108 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,8 @@ - Agent: bypass Anthropic OAuth tool-name blocks by capitalizing built-ins and keeping pruning tool matching case-insensitive. (#553) — thanks @andrewting19 - Commands/Tools: disable /restart and gateway restart tool by default (enable with commands.restart=true). - Gateway/CLI: add `clawdbot gateway discover` (Bonjour scan on `local.` + `clawdbot.internal.`) with `--timeout` and `--json`. — thanks @steipete +- Gateway/CLI: make `clawdbot gateway status` human-readable by default, add `--json`, and probe localhost + configured remote (warn on multiple gateways). — thanks @steipete +- CLI: add global `--no-color` (and respect `NO_COLOR=1`) to disable ANSI output. — thanks @steipete - CLI: centralize lobster palette + apply it to onboarding/config prompts. — thanks @steipete ## 2026.1.8 diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 960bbd2c1..0e80a7c73 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -51,11 +51,16 @@ Notes: All query commands use WebSocket RPC. -Shared options: -- `--url `: Gateway WebSocket URL (defaults to `gateway.remote.url` when configured). -- `--token `: Gateway token (if required). -- `--password `: Gateway password (password auth). -- `--timeout `: timeout (default `10000`). +Output modes: +- Default: human-readable (colored in TTY). +- `--json`: machine-readable JSON (no styling/spinner). +- `--no-color` (or `NO_COLOR=1`): disable ANSI while keeping human layout. + +Shared options (where supported): +- `--url `: Gateway WebSocket URL. +- `--token `: Gateway token. +- `--password `: Gateway password. +- `--timeout `: timeout/budget (varies per command). - `--expect-final`: wait for a “final” response (agent calls). ### `gateway health` @@ -66,8 +71,15 @@ clawdbot gateway health --url ws://127.0.0.1:18789 ### `gateway status` +`gateway status` is the “debug everything” command. It always probes: +- your configured remote gateway (if set), and +- localhost (loopback) **even if remote is configured**. + +If multiple gateways are reachable, it prints all of them and warns this is an unconventional setup (usually you want only one gateway). + ```bash -clawdbot gateway status --url ws://127.0.0.1:18789 +clawdbot gateway status +clawdbot gateway status --json ``` ### `gateway call ` @@ -104,4 +116,3 @@ Examples: clawdbot gateway discover --timeout 4000 clawdbot gateway discover --json | jq '.beacons[].wsUrl' ``` - diff --git a/docs/cli/index.md b/docs/cli/index.md index fa6ddee98..99e505a95 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -13,6 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do - `--dev`: isolate state under `~/.clawdbot-dev` and shift default ports. - `--profile `: isolate state under `~/.clawdbot-`. +- `--no-color`: disable ANSI colors. - `-V`, `--version`, `-v`: print version and exit. ## Output styling @@ -20,7 +21,7 @@ This page describes the current CLI behavior. If commands change, update this do - ANSI colors and progress indicators only render in TTY sessions. - OSC-8 hyperlinks render as clickable links in supported terminals; otherwise we fall back to plain URLs. - `--json` (and `--plain` where supported) disables styling for clean output. -- `--no-color` disables ANSI styling where supported; `NO_COLOR=1` is also respected. +- `--no-color` disables ANSI styling; `NO_COLOR=1` is also respected. - Long-running commands show a progress indicator (OSC 9;4 when supported). ## Color palette diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 1cf7f01a6..6e25780b7 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -13,6 +13,7 @@ const forceFreePortAndWait = vi.fn(async () => ({ })); const serviceIsLoaded = vi.fn().mockResolvedValue(true); const discoverGatewayBeacons = vi.fn(async () => []); +const gatewayStatusCommand = vi.fn(async () => {}); const runtimeLogs: string[] = []; const runtimeErrors: string[] = []; @@ -95,8 +96,12 @@ vi.mock("../infra/bonjour-discovery.js", () => ({ discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts), })); +vi.mock("../commands/gateway-status.js", () => ({ + gatewayStatusCommand: (opts: unknown) => gatewayStatusCommand(opts), +})); + describe("gateway-cli coverage", () => { - it("registers call/health/status commands and routes to callGateway", async () => { + it("registers call/health commands and routes to callGateway", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; callGateway.mockClear(); @@ -115,6 +120,21 @@ describe("gateway-cli coverage", () => { expect(runtimeLogs.join("\n")).toContain('"ok": true'); }); + it("registers gateway status and routes to gatewayStatusCommand", async () => { + runtimeLogs.length = 0; + runtimeErrors.length = 0; + gatewayStatusCommand.mockClear(); + + const { registerGatewayCli } = await import("./gateway-cli.js"); + const program = new Command(); + program.exitOverride(); + registerGatewayCli(program); + + await program.parseAsync(["gateway", "status", "--json"], { from: "user" }); + + expect(gatewayStatusCommand).toHaveBeenCalledTimes(1); + }); + it("registers gateway discover and prints JSON", async () => { runtimeLogs.length = 0; runtimeErrors.length = 0; diff --git a/src/cli/gateway-cli.ts b/src/cli/gateway-cli.ts index c040b8feb..82541fa60 100644 --- a/src/cli/gateway-cli.ts +++ b/src/cli/gateway-cli.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import type { Command } from "commander"; +import { gatewayStatusCommand } from "../commands/gateway-status.js"; import { CONFIG_PATH_CLAWDBOT, type GatewayAuthMode, @@ -42,6 +43,7 @@ type GatewayRpcOpts = { password?: string; timeout?: string; expectFinal?: boolean; + json?: boolean; }; type GatewayRunOpts = { @@ -369,7 +371,8 @@ const gatewayCallOpts = (cmd: Command) => .option("--token ", "Gateway token (if required)") .option("--password ", "Gateway password (password auth)") .option("--timeout ", "Timeout in ms", "10000") - .option("--expect-final", "Wait for final response (agent)", false); + .option("--expect-final", "Wait for final response (agent)", false) + .option("--json", "Output JSON", false); const callGatewayCli = async ( method: string, @@ -380,7 +383,7 @@ const callGatewayCli = async ( { label: `Gateway ${method}`, indeterminate: true, - enabled: true, + enabled: opts.json !== true, }, async () => await callGateway({ @@ -729,7 +732,7 @@ export function registerGatewayCli(program: Command) { gatewayCallOpts( gateway .command("call") - .description("Call a Gateway method and print JSON") + .description("Call a Gateway method") .argument( "", "Method name (health/status/system-presence/cron.*)", @@ -739,6 +742,18 @@ export function registerGatewayCli(program: Command) { try { const params = JSON.parse(String(opts.params ?? "{}")); const result = await callGatewayCli(method, opts, params); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const rich = isRich(); + defaultRuntime.log( + `${colorize(rich, theme.heading, "Gateway call")}: ${colorize( + rich, + theme.muted, + String(method), + )}`, + ); defaultRuntime.log(JSON.stringify(result, null, 2)); } catch (err) { defaultRuntime.error(`Gateway call failed: ${String(err)}`); @@ -754,7 +769,46 @@ export function registerGatewayCli(program: Command) { .action(async (opts) => { try { const result = await callGatewayCli("health", opts); - defaultRuntime.log(JSON.stringify(result, null, 2)); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + const rich = isRich(); + const obj = + result && typeof result === "object" + ? (result as Record) + : {}; + const durationMs = + typeof obj.durationMs === "number" ? obj.durationMs : null; + defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health")); + defaultRuntime.log( + `${colorize(rich, theme.success, "OK")}${ + durationMs != null ? ` (${durationMs}ms)` : "" + }`, + ); + if (obj.web && typeof obj.web === "object") { + const web = obj.web as Record; + const linked = web.linked === true; + defaultRuntime.log( + `Web: ${linked ? "linked" : "not linked"}${ + typeof web.authAgeMs === "number" && linked + ? ` (${Math.round(web.authAgeMs / 60_000)}m)` + : "" + }`, + ); + } + if (obj.telegram && typeof obj.telegram === "object") { + const tg = obj.telegram as Record; + defaultRuntime.log( + `Telegram: ${tg.configured === true ? "configured" : "not configured"}`, + ); + } + if (obj.discord && typeof obj.discord === "object") { + const dc = obj.discord as Record; + defaultRuntime.log( + `Discord: ${dc.configured === true ? "configured" : "not configured"}`, + ); + } } catch (err) { defaultRuntime.error(String(err)); defaultRuntime.exit(1); @@ -762,20 +816,27 @@ export function registerGatewayCli(program: Command) { }), ); - gatewayCallOpts( - gateway - .command("status") - .description("Fetch Gateway status") - .action(async (opts) => { - try { - const result = await callGatewayCli("status", opts); - defaultRuntime.log(JSON.stringify(result, null, 2)); - } catch (err) { - defaultRuntime.error(String(err)); - defaultRuntime.exit(1); - } - }), - ); + gateway + .command("status") + .description( + "Show gateway reachability + discovery + health + status summary (local + remote)", + ) + .option( + "--url ", + "Explicit Gateway WebSocket URL (still probes localhost)", + ) + .option("--token ", "Gateway token (applies to all probes)") + .option("--password ", "Gateway password (applies to all probes)") + .option("--timeout ", "Overall probe budget in ms", "3000") + .option("--json", "Output JSON", false) + .action(async (opts) => { + try { + await gatewayStatusCommand(opts, defaultRuntime); + } catch (err) { + defaultRuntime.error(String(err)); + defaultRuntime.exit(1); + } + }); gateway .command("discover") diff --git a/src/cli/program.ts b/src/cli/program.ts index 23eac4ccb..fb24c10d1 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -72,6 +72,8 @@ export function buildProgram() { "Use a named profile (isolates CLAWDBOT_STATE_DIR/CLAWDBOT_CONFIG_PATH under ~/.clawdbot-)", ); + program.option("--no-color", "Disable ANSI colors", false); + program.configureHelp({ optionTerm: (option) => theme.option(option.flags), subcommandTerm: (cmd) => theme.command(cmd.name()), diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts new file mode 100644 index 000000000..64d536f98 --- /dev/null +++ b/src/commands/gateway-status.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from "vitest"; + +const loadConfig = vi.fn(() => ({ + gateway: { + mode: "remote", + remote: { url: "ws://remote.example:18789", token: "rtok" }, + auth: { token: "ltok" }, + }, +})); +const resolveGatewayPort = vi.fn(() => 18789); +const discoverGatewayBeacons = vi.fn(async () => []); +const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10"); +const probeGateway = vi.fn(async ({ url }: { url: string }) => { + if (url.includes("127.0.0.1")) { + return { + ok: true, + url, + connectLatencyMs: 12, + error: null, + close: null, + health: { ok: true }, + status: { web: { linked: false }, sessions: { count: 0 } }, + presence: [ + { mode: "gateway", reason: "self", host: "local", ip: "127.0.0.1" }, + ], + configSnapshot: { + path: "/tmp/cfg.json", + exists: true, + valid: true, + config: { + gateway: { mode: "local" }, + bridge: { enabled: true, port: 18790 }, + }, + issues: [], + legacyIssues: [], + }, + }; + } + return { + ok: true, + url, + connectLatencyMs: 34, + error: null, + close: null, + health: { ok: true }, + status: { web: { linked: true }, sessions: { count: 2 } }, + presence: [ + { mode: "gateway", reason: "self", host: "remote", ip: "100.64.0.2" }, + ], + configSnapshot: { + path: "/tmp/remote.json", + exists: true, + valid: true, + config: { gateway: { mode: "remote" }, bridge: { enabled: false } }, + issues: [], + legacyIssues: [], + }, + }; +}); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), + resolveGatewayPort: (cfg: unknown) => resolveGatewayPort(cfg), +})); + +vi.mock("../infra/bonjour-discovery.js", () => ({ + discoverGatewayBeacons: (opts: unknown) => discoverGatewayBeacons(opts), +})); + +vi.mock("../infra/tailnet.js", () => ({ + pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(), +})); + +vi.mock("../gateway/probe.js", () => ({ + probeGateway: (opts: unknown) => probeGateway(opts), +})); + +describe("gateway-status command", () => { + it("prints human output by default", async () => { + const runtimeLogs: string[] = []; + const runtimeErrors: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000" }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + expect(runtimeErrors).toHaveLength(0); + expect(runtimeLogs.join("\n")).toContain("Gateway Status"); + expect(runtimeLogs.join("\n")).toContain("Discovery (this machine)"); + expect(runtimeLogs.join("\n")).toContain("Targets"); + }); + + it("prints a structured JSON envelope when --json is set", async () => { + const runtimeLogs: string[] = []; + const runtimeErrors: string[] = []; + const runtime = { + log: (msg: string) => runtimeLogs.push(msg), + error: (msg: string) => runtimeErrors.push(msg), + exit: (code: number) => { + throw new Error(`__exit__:${code}`); + }, + }; + + const { gatewayStatusCommand } = await import("./gateway-status.js"); + await gatewayStatusCommand( + { timeout: "1000", json: true }, + runtime as unknown as import("../runtime.js").RuntimeEnv, + ); + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as Record< + string, + unknown + >; + expect(parsed.ok).toBe(true); + expect(parsed.targets).toBeTruthy(); + const targets = parsed.targets as Array>; + expect(targets.length).toBeGreaterThanOrEqual(2); + expect(targets[0]?.health).toBeTruthy(); + expect(targets[0]?.summary).toBeTruthy(); + }); +}); diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts new file mode 100644 index 000000000..daa41ca39 --- /dev/null +++ b/src/commands/gateway-status.ts @@ -0,0 +1,523 @@ +import { withProgress } from "../cli/progress.js"; +import { loadConfig, resolveGatewayPort } from "../config/config.js"; +import type { ClawdbotConfig, ConfigFileSnapshot } from "../config/types.js"; +import { type GatewayProbeResult, probeGateway } from "../gateway/probe.js"; +import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js"; +import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { colorize, isRich, theme } from "../terminal/theme.js"; + +type TargetKind = "explicit" | "configRemote" | "localLoopback"; + +type GatewayStatusTarget = { + id: string; + kind: TargetKind; + url: string; + active: boolean; +}; + +type GatewayConfigSummary = { + path: string | null; + exists: boolean; + valid: boolean; + issues: Array<{ path: string; message: string }>; + legacyIssues: Array<{ path: string; message: string }>; + gateway: { + mode: string | null; + bind: string | null; + port: number | null; + controlUiEnabled: boolean | null; + controlUiBasePath: string | null; + authMode: string | null; + authTokenConfigured: boolean; + authPasswordConfigured: boolean; + remoteUrl: string | null; + remoteTokenConfigured: boolean; + remotePasswordConfigured: boolean; + tailscaleMode: string | null; + }; + bridge: { + enabled: boolean | null; + bind: string | null; + port: number | null; + }; + discovery: { + wideAreaEnabled: boolean | null; + }; +}; + +function parseIntOrNull(value: unknown): number | null { + const s = + typeof value === "string" + ? value.trim() + : typeof value === "number" || typeof value === "bigint" + ? String(value) + : ""; + if (!s) return null; + const n = Number.parseInt(s, 10); + return Number.isFinite(n) ? n : null; +} + +function parseTimeoutMs(raw: unknown, fallbackMs: number): number { + const value = + typeof raw === "string" + ? raw.trim() + : typeof raw === "number" || typeof raw === "bigint" + ? String(raw) + : ""; + if (!value) return fallbackMs; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`invalid --timeout: ${value}`); + } + return parsed; +} + +function normalizeWsUrl(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!trimmed.startsWith("ws://") && !trimmed.startsWith("wss://")) + return null; + return trimmed; +} + +function resolveTargets( + cfg: ClawdbotConfig, + explicitUrl?: string, +): GatewayStatusTarget[] { + const targets: GatewayStatusTarget[] = []; + const add = (t: GatewayStatusTarget) => { + if (!targets.some((x) => x.url === t.url)) targets.push(t); + }; + + const explicit = + typeof explicitUrl === "string" ? normalizeWsUrl(explicitUrl) : null; + if (explicit) + add({ id: "explicit", kind: "explicit", url: explicit, active: true }); + + const remoteUrl = + typeof cfg.gateway?.remote?.url === "string" + ? normalizeWsUrl(cfg.gateway.remote.url) + : null; + if (remoteUrl) { + add({ + id: "configRemote", + kind: "configRemote", + url: remoteUrl, + active: cfg.gateway?.mode === "remote", + }); + } + + const port = resolveGatewayPort(cfg); + add({ + id: "localLoopback", + kind: "localLoopback", + url: `ws://127.0.0.1:${port}`, + active: cfg.gateway?.mode !== "remote", + }); + + return targets; +} + +function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { + if (kind === "localLoopback") return Math.min(800, overallMs); + return Math.min(1500, overallMs); +} + +function resolveAuthForTarget( + cfg: ClawdbotConfig, + target: GatewayStatusTarget, + overrides: { token?: string; password?: string }, +): { token?: string; password?: string } { + const tokenOverride = overrides.token?.trim() + ? overrides.token.trim() + : undefined; + const passwordOverride = overrides.password?.trim() + ? overrides.password.trim() + : undefined; + if (tokenOverride || passwordOverride) { + return { token: tokenOverride, password: passwordOverride }; + } + + if (target.kind === "configRemote") { + const token = + typeof cfg.gateway?.remote?.token === "string" + ? cfg.gateway.remote.token.trim() + : ""; + const remotePassword = ( + cfg.gateway?.remote as { password?: unknown } | undefined + )?.password; + const password = + typeof remotePassword === "string" ? remotePassword.trim() : ""; + return { + token: token.length > 0 ? token : undefined, + password: password.length > 0 ? password : undefined, + }; + } + + const envToken = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || ""; + const envPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || ""; + const cfgToken = + typeof cfg.gateway?.auth?.token === "string" + ? cfg.gateway.auth.token.trim() + : ""; + const cfgPassword = + typeof cfg.gateway?.auth?.password === "string" + ? cfg.gateway.auth.password.trim() + : ""; + + return { + token: envToken || cfgToken || undefined, + password: envPassword || cfgPassword || undefined, + }; +} + +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") ?? + entries.find( + (e) => + typeof e.text === "string" && String(e.text).startsWith("Gateway:"), + ) ?? + 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, + }; +} + +function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSummary { + const snap = snapshotUnknown as Partial | null; + const path = typeof snap?.path === "string" ? snap.path : null; + const exists = Boolean(snap?.exists); + const valid = Boolean(snap?.valid); + const issuesRaw = Array.isArray(snap?.issues) ? snap.issues : []; + const legacyRaw = Array.isArray(snap?.legacyIssues) ? snap.legacyIssues : []; + + const cfg = (snap?.config ?? {}) as Record; + const gateway = (cfg.gateway ?? {}) as Record; + const bridge = (cfg.bridge ?? {}) as Record; + const discovery = (cfg.discovery ?? {}) as Record; + const wideArea = (discovery.wideArea ?? {}) as Record; + + const remote = (gateway.remote ?? {}) as Record; + const auth = (gateway.auth ?? {}) as Record; + const controlUi = (gateway.controlUi ?? {}) as Record; + const tailscale = (gateway.tailscale ?? {}) as Record; + + const authMode = typeof auth.mode === "string" ? auth.mode : null; + const authTokenConfigured = + typeof auth.token === "string" ? auth.token.trim().length > 0 : false; + const authPasswordConfigured = + typeof auth.password === "string" ? auth.password.trim().length > 0 : false; + + const remoteUrl = + typeof remote.url === "string" ? normalizeWsUrl(remote.url) : null; + const remoteTokenConfigured = + typeof remote.token === "string" ? remote.token.trim().length > 0 : false; + const remotePasswordConfigured = + typeof remote.password === "string" + ? String(remote.password).trim().length > 0 + : false; + + const bridgeEnabled = + typeof bridge.enabled === "boolean" ? bridge.enabled : null; + const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null; + const bridgePort = parseIntOrNull(bridge.port); + + const wideAreaEnabled = + typeof wideArea.enabled === "boolean" ? wideArea.enabled : null; + + return { + path, + exists, + valid, + issues: issuesRaw + .filter((i): i is { path: string; message: string } => + Boolean( + i && typeof i.path === "string" && typeof i.message === "string", + ), + ) + .map((i) => ({ path: i.path, message: i.message })), + legacyIssues: legacyRaw + .filter((i): i is { path: string; message: string } => + Boolean( + i && typeof i.path === "string" && typeof i.message === "string", + ), + ) + .map((i) => ({ path: i.path, message: i.message })), + gateway: { + mode: typeof gateway.mode === "string" ? gateway.mode : null, + bind: typeof gateway.bind === "string" ? gateway.bind : null, + port: parseIntOrNull(gateway.port), + controlUiEnabled: + typeof controlUi.enabled === "boolean" ? controlUi.enabled : null, + controlUiBasePath: + typeof controlUi.basePath === "string" ? controlUi.basePath : null, + authMode, + authTokenConfigured, + authPasswordConfigured, + remoteUrl, + remoteTokenConfigured, + remotePasswordConfigured, + tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null, + }, + bridge: { + enabled: bridgeEnabled, + bind: bridgeBind, + port: bridgePort, + }, + discovery: { wideAreaEnabled }, + }; +} + +function buildNetworkHints(cfg: ClawdbotConfig) { + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const port = resolveGatewayPort(cfg); + return { + localLoopbackUrl: `ws://127.0.0.1:${port}`, + localTailnetUrl: tailnetIPv4 ? `ws://${tailnetIPv4}:${port}` : null, + tailnetIPv4: tailnetIPv4 ?? null, + }; +} + +function renderTargetHeader(target: GatewayStatusTarget, rich: boolean) { + const kindLabel = + target.kind === "localLoopback" + ? "Local loopback" + : target.kind === "configRemote" + ? target.active + ? "Remote (configured)" + : "Remote (configured, inactive)" + : "URL (explicit)"; + return `${colorize(rich, theme.heading, kindLabel)} ${colorize(rich, theme.muted, target.url)}`; +} + +function renderProbeSummaryLine(probe: GatewayProbeResult, rich: boolean) { + if (probe.ok) { + const latency = + typeof probe.connectLatencyMs === "number" + ? `${probe.connectLatencyMs}ms` + : "unknown"; + return `${colorize(rich, theme.success, "Connect: ok")} (${latency})`; + } + const detail = probe.error ? ` - ${probe.error}` : ""; + return `${colorize(rich, theme.error, "Connect: failed")}${detail}`; +} + +export async function gatewayStatusCommand( + opts: { + url?: string; + token?: string; + password?: string; + timeout?: unknown; + json?: boolean; + }, + runtime: RuntimeEnv, +) { + const startedAt = Date.now(); + const cfg = loadConfig(); + const rich = isRich() && opts.json !== true; + const overallTimeoutMs = parseTimeoutMs(opts.timeout, 3000); + + const targets = resolveTargets(cfg, opts.url); + const network = buildNetworkHints(cfg); + + const discoveryTimeoutMs = Math.min(1200, overallTimeoutMs); + const discoveryPromise = discoverGatewayBeacons({ + timeoutMs: discoveryTimeoutMs, + }); + + const probePromises = targets.map(async (target) => { + const auth = resolveAuthForTarget(cfg, target, { + token: typeof opts.token === "string" ? opts.token : undefined, + password: typeof opts.password === "string" ? opts.password : undefined, + }); + const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); + const probe = await probeGateway({ url: target.url, auth, timeoutMs }); + const configSummary = probe.configSnapshot + ? extractConfigSummary(probe.configSnapshot) + : null; + const self = pickGatewaySelfPresence(probe.presence); + return { target, probe, configSummary, self }; + }); + + const { discovery, probed } = await withProgress( + { + label: "Inspecting gateways…", + indeterminate: true, + enabled: opts.json !== true, + }, + async () => { + const [discoveryRes, probesRes] = await Promise.allSettled([ + discoveryPromise, + Promise.all(probePromises), + ]); + return { + discovery: + discoveryRes.status === "fulfilled" ? discoveryRes.value : [], + probed: probesRes.status === "fulfilled" ? probesRes.value : [], + }; + }, + ); + + const reachable = probed.filter((p) => p.probe.ok); + const ok = reachable.length > 0; + const multipleGateways = reachable.length > 1; + const primary = + reachable.find((p) => p.target.kind === "explicit") ?? + reachable.find((p) => p.target.kind === "configRemote") ?? + reachable.find((p) => p.target.kind === "localLoopback") ?? + null; + + const warnings: Array<{ + code: string; + message: string; + targetIds?: string[]; + }> = []; + if (multipleGateways) { + warnings.push({ + code: "multiple_gateways", + message: + "Unconventional setup: multiple reachable gateways detected. Usually only one gateway should exist on a network.", + targetIds: reachable.map((p) => p.target.id), + }); + } + + if (opts.json) { + runtime.log( + JSON.stringify( + { + ok, + ts: Date.now(), + durationMs: Date.now() - startedAt, + timeoutMs: overallTimeoutMs, + primaryTargetId: primary?.target.id ?? null, + warnings, + network, + discovery: { + timeoutMs: discoveryTimeoutMs, + count: discovery.length, + beacons: discovery.map((b) => ({ + instanceName: b.instanceName, + displayName: b.displayName ?? null, + domain: b.domain ?? null, + host: b.host ?? null, + lanHost: b.lanHost ?? null, + tailnetDns: b.tailnetDns ?? null, + bridgePort: b.bridgePort ?? null, + gatewayPort: b.gatewayPort ?? null, + sshPort: b.sshPort ?? null, + wsUrl: (() => { + const host = b.tailnetDns || b.lanHost || b.host; + const port = b.gatewayPort ?? 18789; + return host ? `ws://${host}:${port}` : null; + })(), + })), + }, + targets: probed.map((p) => ({ + id: p.target.id, + kind: p.target.kind, + url: p.target.url, + active: p.target.active, + connect: { + ok: p.probe.ok, + latencyMs: p.probe.connectLatencyMs, + error: p.probe.error, + close: p.probe.close, + }, + self: p.self, + config: p.configSummary, + health: p.probe.health, + summary: p.probe.status, + presence: p.probe.presence, + })), + }, + null, + 2, + ), + ); + if (!ok) runtime.exit(1); + return; + } + + runtime.log(colorize(rich, theme.heading, "Gateway Status")); + runtime.log( + ok + ? `${colorize(rich, theme.success, "Reachable")}: yes` + : `${colorize(rich, theme.error, "Reachable")}: no`, + ); + runtime.log( + colorize(rich, theme.muted, `Probe budget: ${overallTimeoutMs}ms`), + ); + + if (warnings.length > 0) { + runtime.log(""); + runtime.log(colorize(rich, theme.warn, "Warning:")); + for (const w of warnings) runtime.log(`- ${w.message}`); + } + + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Discovery (this machine)")); + runtime.log( + discovery.length > 0 + ? `Found ${discovery.length} gateway(s) via Bonjour (local. + clawdbot.internal.)` + : "Found 0 gateways via Bonjour (local. + clawdbot.internal.)", + ); + if (discovery.length === 0) { + runtime.log( + colorize( + rich, + theme.muted, + "Tip: if the gateway is remote, mDNS won’t cross networks; use Wide-Area Bonjour (split DNS) or SSH tunnels.", + ), + ); + } + + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Targets")); + for (const p of probed) { + runtime.log(renderTargetHeader(p.target, rich)); + runtime.log(` ${renderProbeSummaryLine(p.probe, rich)}`); + if (p.probe.ok && p.self) { + const host = p.self.host ?? "unknown"; + const ip = p.self.ip ? ` (${p.self.ip})` : ""; + const platform = p.self.platform ? ` · ${p.self.platform}` : ""; + const version = p.self.version ? ` · app ${p.self.version}` : ""; + runtime.log( + ` ${colorize(rich, theme.info, "Gateway")}: ${host}${ip}${platform}${version}`, + ); + } + if (p.configSummary) { + const c = p.configSummary; + const bridge = + c.bridge.enabled === false + ? "disabled" + : c.bridge.enabled === true + ? "enabled" + : "unknown"; + const wideArea = + c.discovery.wideAreaEnabled === true + ? "enabled" + : c.discovery.wideAreaEnabled === false + ? "disabled" + : "unknown"; + runtime.log( + ` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`, + ); + runtime.log( + ` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`, + ); + } + runtime.log(""); + } + + if (!ok) runtime.exit(1); +} diff --git a/src/entry.ts b/src/entry.ts index 6aa98c7f5..fd44bd5bd 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -3,6 +3,11 @@ import process from "node:process"; import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js"; +if (process.argv.includes("--no-color")) { + process.env.NO_COLOR = "1"; + process.env.FORCE_COLOR = "0"; +} + const parsed = parseCliProfileArgs(process.argv); if (!parsed.ok) { // Keep it simple; Commander will handle rich help/errors after we strip flags. diff --git a/src/gateway/client.ts b/src/gateway/client.ts index b0c06b293..33b82dd38 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -32,6 +32,7 @@ export type GatewayClientOptions = { maxProtocol?: number; onEvent?: (evt: EventFrame) => void; onHelloOk?: (hello: HelloOk) => void; + onConnectError?: (err: Error) => void; onClose?: (code: number, reason: string) => void; onGap?: (info: { expected: number; received: number }) => void; }; @@ -130,6 +131,9 @@ export class GatewayClient { this.opts.onHelloOk?.(helloOk); }) .catch((err) => { + this.opts.onConnectError?.( + err instanceof Error ? err : new Error(String(err)), + ); const msg = `gateway connect failed: ${String(err)}`; if (this.opts.mode === "probe") logDebug(msg); else logError(msg); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts new file mode 100644 index 000000000..236199935 --- /dev/null +++ b/src/gateway/probe.ts @@ -0,0 +1,123 @@ +import { randomUUID } from "node:crypto"; + +import type { SystemPresence } from "../infra/system-presence.js"; +import { GatewayClient } from "./client.js"; + +export type GatewayProbeAuth = { + token?: string; + password?: string; +}; + +export type GatewayProbeClose = { + code: number; + reason: string; + hint?: string; +}; + +export type GatewayProbeResult = { + ok: boolean; + url: string; + connectLatencyMs: number | null; + error: string | null; + close: GatewayProbeClose | null; + health: unknown; + status: unknown; + presence: SystemPresence[] | null; + configSnapshot: unknown; +}; + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} + +export async function probeGateway(opts: { + url: string; + auth?: GatewayProbeAuth; + timeoutMs: number; +}): Promise { + const startedAt = Date.now(); + const instanceId = randomUUID(); + let connectLatencyMs: number | null = null; + let connectError: string | null = null; + let close: GatewayProbeClose | null = null; + + return await new Promise((resolve) => { + let settled = false; + const settle = (result: Omit) => { + if (settled) return; + settled = true; + clearTimeout(timer); + client.stop(); + resolve({ url: opts.url, ...result }); + }; + + const client = new GatewayClient({ + url: opts.url, + token: opts.auth?.token, + password: opts.auth?.password, + clientName: "cli", + clientVersion: "dev", + mode: "probe", + instanceId, + onConnectError: (err) => { + connectError = formatError(err); + }, + onClose: (code, reason) => { + close = { code, reason }; + }, + onHelloOk: async () => { + connectLatencyMs = Date.now() - startedAt; + try { + const [health, status, presence, configSnapshot] = await Promise.all([ + client.request("health"), + client.request("status"), + client.request("system-presence"), + client.request("config.get", {}), + ]); + settle({ + ok: true, + connectLatencyMs, + error: null, + close, + health, + status, + presence: Array.isArray(presence) + ? (presence as SystemPresence[]) + : null, + configSnapshot, + }); + } catch (err) { + settle({ + ok: false, + connectLatencyMs, + error: formatError(err), + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + } + }, + }); + + const timer = setTimeout( + () => { + settle({ + ok: false, + connectLatencyMs, + error: connectError ? `connect failed: ${connectError}` : "timeout", + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + }, + Math.max(250, opts.timeoutMs), + ); + + client.start(); + }); +} diff --git a/src/terminal/theme.ts b/src/terminal/theme.ts index 8eccb3f2c..06316623b 100644 --- a/src/terminal/theme.ts +++ b/src/terminal/theme.ts @@ -1,8 +1,10 @@ -import chalk from "chalk"; +import chalk, { Chalk } from "chalk"; import { LOBSTER_PALETTE } from "./palette.js"; -const hex = (value: string) => chalk.hex(value); +const baseChalk = process.env.NO_COLOR ? new Chalk({ level: 0 }) : chalk; + +const hex = (value: string) => baseChalk.hex(value); export const theme = { accent: hex(LOBSTER_PALETTE.accent), @@ -13,12 +15,13 @@ export const theme = { warn: hex(LOBSTER_PALETTE.warn), error: hex(LOBSTER_PALETTE.error), muted: hex(LOBSTER_PALETTE.muted), - heading: chalk.bold.hex(LOBSTER_PALETTE.accent), + heading: baseChalk.bold.hex(LOBSTER_PALETTE.accent), command: hex(LOBSTER_PALETTE.accentBright), option: hex(LOBSTER_PALETTE.warn), } as const; -export const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); +export const isRich = () => + Boolean(process.stdout.isTTY && baseChalk.level > 0); export const colorize = ( rich: boolean, From 17ccf53eb1eb7350e2fb02d5782bd1db5a52a7c4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 08:29:58 +0000 Subject: [PATCH 4/8] fix: normalize reasoning blocks --- CHANGELOG.md | 2 + src/agents/pi-embedded-subscribe.test.ts | 111 +++++++++++++++++++++++ src/agents/pi-embedded-subscribe.ts | 98 ++++++++++++++++++++ ui/src/ui/controllers/sessions.ts | 8 +- ui/src/ui/views/chat.ts | 75 ++++++++++++++- ui/src/ui/views/sessions.ts | 22 ++++- 6 files changed, 310 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0479c0108..ce0ed5284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,12 +32,14 @@ - Status: show provider prefix in /status model display. (#506) — thanks @mcinteerj - Status: compact /status with session token usage + estimated cost, add `/cost` per-response usage lines (tokens-only for OAuth). - Status: show active auth profile and key snippet in /status. +- Agent: promote ``/`` tag reasoning into structured thinking blocks so `/reasoning` works consistently for OpenAI-compat providers. - macOS: package ClawdbotKit resources and Swift 6.2 compatibility dylib to avoid launch/tool crashes. (#473) — thanks @gupsammy - WhatsApp: group `/model list` output by provider for scannability. (#456) - thanks @mcinteerj - Hooks: allow per-hook model overrides for webhook/Gmail runs (e.g. GPT 5 Mini). - Control UI: logs tab opens at the newest entries (bottom). - Control UI: add Docs link, remove chat composer divider, and add New session button. - Control UI: link sessions list to chat view. (#471) — thanks @HazAT +- Control UI: show/patch per-session reasoning level and render extracted reasoning in chat. - Control UI: queue outgoing chat messages, add Enter-to-send, and show queued items. (#527) — thanks @YuriNachos - Control UI: drop explicit `ui:install` step; `ui:build` now auto-installs UI deps (docs + update flow). - Telegram: retry long-polling conflicts with backoff to avoid fatal exits. diff --git a/src/agents/pi-embedded-subscribe.test.ts b/src/agents/pi-embedded-subscribe.test.ts index 6c6a8de51..a49311f17 100644 --- a/src/agents/pi-embedded-subscribe.test.ts +++ b/src/agents/pi-embedded-subscribe.test.ts @@ -167,6 +167,117 @@ describe("subscribeEmbeddedPiSession", () => { ); }); + it("promotes tags to thinking blocks at write-time", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onBlockReply = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run", + onBlockReply, + blockReplyBreak: "message_end", + reasoningMode: "on", + }); + + const assistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: "\nBecause it helps\n\n\nFinal answer", + }, + ], + } as AssistantMessage; + + handler?.({ type: "message_end", message: assistantMessage }); + + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply.mock.calls[0][0].text).toBe( + "_Reasoning:_\n_Because it helps_\n\nFinal answer", + ); + + expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "Because it helps" }, + { type: "text", text: "Final answer" }, + ]); + }); + + it("streams reasoning via onReasoningStream without leaking into final text", () => { + let handler: ((evt: unknown) => void) | undefined; + const session: StubSession = { + subscribe: (fn) => { + handler = fn; + return () => {}; + }, + }; + + const onReasoningStream = vi.fn(); + const onBlockReply = vi.fn(); + + subscribeEmbeddedPiSession({ + session: session as unknown as Parameters< + typeof subscribeEmbeddedPiSession + >[0]["session"], + runId: "run", + onReasoningStream, + onBlockReply, + blockReplyBreak: "message_end", + reasoningMode: "stream", + }); + + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: "\nBecause", + }, + }); + + handler?.({ + type: "message_update", + message: { role: "assistant" }, + assistantMessageEvent: { + type: "text_delta", + delta: " it helps\n\n\nFinal answer", + }, + }); + + const assistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: "\nBecause it helps\n\n\nFinal answer", + }, + ], + } as AssistantMessage; + + handler?.({ type: "message_end", message: assistantMessage }); + + expect(onBlockReply).toHaveBeenCalledTimes(1); + expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer"); + + const streamTexts = onReasoningStream.mock.calls + .map((call) => call[0]?.text) + .filter((value): value is string => typeof value === "string"); + expect(streamTexts.at(-1)).toBe("Reasoning:\nBecause it helps"); + + expect(assistantMessage.content).toEqual([ + { type: "thinking", thinking: "Because it helps" }, + { type: "text", text: "Final answer" }, + ]); + }); + it("emits block replies on text_end and does not duplicate on message_end", () => { let handler: ((evt: unknown) => void) | undefined; const session: StubSession = { diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index c3f703e40..bed69a237 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -24,6 +24,7 @@ const THINKING_OPEN_RE = /<\s*think(?:ing)?\s*>/i; const THINKING_CLOSE_RE = /<\s*\/\s*think(?:ing)?\s*>/i; const THINKING_OPEN_GLOBAL_RE = /<\s*think(?:ing)?\s*>/gi; const THINKING_CLOSE_GLOBAL_RE = /<\s*\/\s*think(?:ing)?\s*>/gi; +const THINKING_TAG_SCAN_RE = /<\s*(\/?)\s*think(?:ing)?\s*>/gi; const TOOL_RESULT_MAX_CHARS = 8000; const log = createSubsystemLogger("agent/embedded"); const RAW_STREAM_ENABLED = process.env.CLAWDBOT_RAW_STREAM === "1"; @@ -121,6 +122,102 @@ function stripUnpairedThinkingTags(text: string): string { return text; } +type ThinkTaggedSplitBlock = + | { type: "thinking"; thinking: string } + | { type: "text"; text: string }; + +function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null { + const trimmedStart = text.trimStart(); + // Avoid false positives: only treat it as structured thinking when it begins + // with a think tag (common for local/OpenAI-compat providers that emulate + // reasoning blocks via tags). + if (!trimmedStart.startsWith("<")) return null; + if (!THINKING_OPEN_RE.test(trimmedStart)) return null; + if (!THINKING_CLOSE_RE.test(text)) return null; + + THINKING_TAG_SCAN_RE.lastIndex = 0; + let inThinking = false; + let cursor = 0; + let thinkingStart = 0; + const blocks: ThinkTaggedSplitBlock[] = []; + + const pushText = (value: string) => { + if (!value) return; + blocks.push({ type: "text", text: value }); + }; + const pushThinking = (value: string) => { + const cleaned = value.trim(); + if (!cleaned) return; + blocks.push({ type: "thinking", thinking: cleaned }); + }; + + for (const match of text.matchAll(THINKING_TAG_SCAN_RE)) { + const index = match.index ?? 0; + const isClose = Boolean(match[1]?.includes("/")); + + if (!inThinking && !isClose) { + pushText(text.slice(cursor, index)); + thinkingStart = index + match[0].length; + inThinking = true; + continue; + } + + if (inThinking && isClose) { + pushThinking(text.slice(thinkingStart, index)); + cursor = index + match[0].length; + inThinking = false; + } + } + + if (inThinking) return null; + pushText(text.slice(cursor)); + + const hasThinking = blocks.some((b) => b.type === "thinking"); + if (!hasThinking) return null; + return blocks; +} + +function promoteThinkingTagsToBlocks(message: AssistantMessage): void { + if (!Array.isArray(message.content)) return; + const hasThinkingBlock = message.content.some((block) => { + if (!block || typeof block !== "object") return false; + return (block as Record).type === "thinking"; + }); + if (hasThinkingBlock) return; + + const next: Array> = []; + let changed = false; + + for (const block of message.content) { + if (!block || typeof block !== "object") { + next.push(block as Record); + continue; + } + const record = block as Record; + if (record.type !== "text" || typeof record.text !== "string") { + next.push(record); + continue; + } + const split = splitThinkingTaggedText(record.text); + if (!split) { + next.push(record); + continue; + } + changed = true; + for (const part of split) { + if (part.type === "thinking") { + next.push({ type: "thinking", thinking: part.thinking }); + } else if (part.type === "text") { + const cleaned = part.text.trimStart(); + if (cleaned) next.push({ type: "text", text: cleaned }); + } + } + } + + if (!changed) return; + (message as unknown as { content: unknown }).content = next; +} + function normalizeSlackTarget(raw: string): string | undefined { const trimmed = raw.trim(); if (!trimmed) return undefined; @@ -792,6 +889,7 @@ export function subscribeEmbeddedPiSession(params: { const msg = (evt as AgentEvent & { message: AgentMessage }).message; if (msg?.role === "assistant") { const assistantMessage = msg as AssistantMessage; + promoteThinkingTagsToBlocks(assistantMessage); const rawText = extractAssistantText(assistantMessage); appendRawStream({ ts: Date.now(), diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 685660742..faf61e5a9 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -42,12 +42,17 @@ export async function loadSessions(state: SessionsState) { export async function patchSession( state: SessionsState, key: string, - patch: { thinkingLevel?: string | null; verboseLevel?: string | null }, + patch: { + thinkingLevel?: string | null; + verboseLevel?: string | null; + reasoningLevel?: string | null; + }, ) { if (!state.client || !state.connected) return; const params: Record = { key }; if ("thinkingLevel" in patch) params.thinkingLevel = patch.thinkingLevel; if ("verboseLevel" in patch) params.verboseLevel = patch.verboseLevel; + if ("reasoningLevel" in patch) params.reasoningLevel = patch.reasoningLevel; try { await state.client.request("sessions.patch", params); await loadSessions(state); @@ -55,4 +60,3 @@ export async function patchSession( state.sessionsError = String(err); } } - diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 61ee55fa9..fa5b26987 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -38,6 +38,11 @@ export function renderChat(props: ChatProps) { const canCompose = props.connected; const isBusy = props.sending || Boolean(props.stream); const sessionOptions = resolveSessionOptions(props.sessionKey, props.sessions); + const activeSession = props.sessions?.sessions?.find( + (row) => row.key === props.sessionKey, + ); + const reasoningLevel = activeSession?.reasoningLevel ?? "off"; + const showReasoning = reasoningLevel !== "off"; const composePlaceholder = props.connected ? "Message (↩ to send, Shift+↩ for line breaks)" : "Connect to the gateway to start chatting…"; @@ -72,6 +77,7 @@ export function renderChat(props: ChatProps) {
Thinking: ${props.thinkingLevel ?? "inherit"}
+
Reasoning: ${reasoningLevel}
@@ -107,7 +113,7 @@ export function renderChat(props: ChatProps) { { streaming: true } ); } - return renderMessage(item.message, props); + return renderMessage(item.message, props, { showReasoning }); } )} @@ -326,7 +332,7 @@ function renderReadingIndicator() { function renderMessage( message: unknown, props?: Pick, - opts?: { streaming?: boolean } + opts?: { streaming?: boolean; showReasoning?: boolean } ) { const m = message as Record; const role = typeof m.role === "string" ? m.role : "unknown"; @@ -334,6 +340,10 @@ function renderMessage( const hasToolCards = toolCards.length > 0; const isToolResult = isToolResultMessage(message); const extractedText = extractText(message); + const extractedThinking = + opts?.showReasoning && role === "assistant" + ? extractThinking(message) + : null; const contentText = typeof m.content === "string" ? m.content : null; const fallback = hasToolCards ? null : JSON.stringify(message, null, 2); @@ -345,10 +355,15 @@ function renderMessage( : !isToolResult && fallback ? { kind: "json" as const, value: fallback } : null; - const markdown = + const markdownBase = display?.kind === "json" ? ["```json", display.value, "```"].join("\n") : (display?.value ?? null); + const markdown = extractedThinking + ? [formatReasoningMarkdown(extractedThinking), markdownBase] + .filter(Boolean) + .join("\n\n") + : markdownBase; const timestamp = typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; @@ -413,6 +428,60 @@ function extractText(message: unknown): string | null { return null; } +function extractThinking(message: unknown): string | null { + const m = message as Record; + const content = m.content; + const parts: string[] = []; + if (Array.isArray(content)) { + for (const p of content) { + const item = p as Record; + if (item.type === "thinking" && typeof item.thinking === "string") { + const cleaned = item.thinking.trim(); + if (cleaned) parts.push(cleaned); + } + } + } + if (parts.length > 0) return parts.join("\n"); + + // Back-compat: older logs may still have tags inside text blocks. + const rawText = extractRawText(message); + if (!rawText) return null; + const matches = [...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi)]; + const extracted = matches + .map((m) => (m[1] ?? "").trim()) + .filter(Boolean); + return extracted.length > 0 ? extracted.join("\n") : null; +} + +function extractRawText(message: unknown): string | null { + const m = message as Record; + const content = m.content; + if (typeof content === "string") return content; + if (Array.isArray(content)) { + const parts = content + .map((p) => { + const item = p as Record; + if (item.type === "text" && typeof item.text === "string") return item.text; + return null; + }) + .filter((v): v is string => typeof v === "string"); + if (parts.length > 0) return parts.join("\n"); + } + if (typeof m.text === "string") return m.text; + return null; +} + +function formatReasoningMarkdown(text: string): string { + const trimmed = text.trim(); + if (!trimmed) return ""; + const lines = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => `_${line}_`); + return lines.length ? ["_Reasoning:_", ...lines].join("\n") : ""; +} + type ToolCard = { kind: "call" | "result"; name: string; diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 47e910750..008285ab9 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -23,12 +23,17 @@ export type SessionsProps = { onRefresh: () => void; onPatch: ( key: string, - patch: { thinkingLevel?: string | null; verboseLevel?: string | null }, + patch: { + thinkingLevel?: string | null; + verboseLevel?: string | null; + reasoningLevel?: string | null; + }, ) => void; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high"] as const; const VERBOSE_LEVELS = ["", "off", "on"] as const; +const REASONING_LEVELS = ["", "off", "on", "stream"] as const; export function renderSessions(props: SessionsProps) { const rows = props.result?.sessions ?? []; @@ -117,6 +122,7 @@ export function renderSessions(props: SessionsProps) {
Tokens
Thinking
Verbose
+
Reasoning
${rows.length === 0 ? html`
No sessions found.
` @@ -130,6 +136,7 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr const updated = row.updatedAt ? formatAgo(row.updatedAt) : "n/a"; const thinking = row.thinkingLevel ?? ""; const verbose = row.verboseLevel ?? ""; + const reasoning = row.reasoningLevel ?? ""; const displayName = row.displayName ?? row.key; const canLink = row.kind !== "global"; const chatUrl = canLink @@ -170,6 +177,19 @@ function renderRow(row: GatewaySessionRow, basePath: string, onPatch: SessionsPr )} +
+ +
`; } From 3db52c972d6056682f3673fe885cf83c7e45b2d4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 08:37:38 +0000 Subject: [PATCH 5/8] fix: repair typing for thinking promotion --- src/agents/pi-embedded-subscribe.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index bed69a237..16643d6fc 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -179,28 +179,22 @@ function splitThinkingTaggedText(text: string): ThinkTaggedSplitBlock[] | null { function promoteThinkingTagsToBlocks(message: AssistantMessage): void { if (!Array.isArray(message.content)) return; - const hasThinkingBlock = message.content.some((block) => { - if (!block || typeof block !== "object") return false; - return (block as Record).type === "thinking"; - }); + const hasThinkingBlock = message.content.some( + (block) => block.type === "thinking", + ); if (hasThinkingBlock) return; - const next: Array> = []; + const next: AssistantMessage["content"] = []; let changed = false; for (const block of message.content) { - if (!block || typeof block !== "object") { - next.push(block as Record); + if (block.type !== "text") { + next.push(block); continue; } - const record = block as Record; - if (record.type !== "text" || typeof record.text !== "string") { - next.push(record); - continue; - } - const split = splitThinkingTaggedText(record.text); + const split = splitThinkingTaggedText(block.text); if (!split) { - next.push(record); + next.push(block); continue; } changed = true; @@ -215,7 +209,7 @@ function promoteThinkingTagsToBlocks(message: AssistantMessage): void { } if (!changed) return; - (message as unknown as { content: unknown }).content = next; + message.content = next; } function normalizeSlackTarget(raw: string): string | undefined { From 827e68eadd3b054244864d89654ae6799e649f80 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 09:59:58 +0100 Subject: [PATCH 6/8] feat: improve auth setup flows --- CHANGELOG.md | 2 + docs/cli/index.md | 3 +- docs/start/wizard.md | 11 +- src/cli/program.ts | 5 +- src/commands/auth-choice-options.ts | 1 + src/commands/auth-choice.ts | 101 +++++----- src/commands/configure.ts | 234 ++++++++++++------------ src/commands/onboard-non-interactive.ts | 15 ++ src/commands/onboard-types.ts | 2 + src/infra/env-file.ts | 55 ++++++ src/wizard/onboarding.ts | 107 +++++------ 11 files changed, 310 insertions(+), 226 deletions(-) create mode 100644 src/infra/env-file.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ce0ed5284..58654420d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,8 @@ - Onboarding: QuickStart auto-installs the Gateway daemon with Node (no runtime picker). - Daemon runtime: remove Bun from selection options. - CLI: restore hidden `gateway-daemon` alias for legacy launchd configs. +- Onboarding/Configure: add OpenAI API key flow that stores in shared `~/.clawdbot/.env` for launchd; simplify Anthropic token prompt order. +- Configure/Onboarding: show Control UI docs with gateway reachability status and only offer to open when a gateway is detected; default model prompt now prefers Opus 4.5 for Anthropic auth. - Control UI: show skill install progress + per-skill results, hide install once binaries present. (#445) — thanks @pkrmf - Providers/Doctor: surface Discord privileged intent (Message Content) misconfiguration with actionable warnings. - Providers/Doctor: warn when Telegram config expects unmentioned group messages but Bot API privacy mode is likely enabled; surface WhatsApp login/disconnect hints. diff --git a/docs/cli/index.md b/docs/cli/index.md index 99e505a95..26e8dae8a 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -169,8 +169,9 @@ Options: - `--workspace ` - `--non-interactive` - `--mode ` -- `--auth-choice ` +- `--auth-choice ` - `--anthropic-api-key ` +- `--openai-api-key ` - `--gemini-api-key ` - `--gateway-port ` - `--gateway-bind ` diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 00072c9f1..1efd2df7a 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -71,11 +71,12 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( 2) **Model/Auth** - **Anthropic OAuth (Claude CLI)**: on macOS the wizard checks Keychain item "Claude Code-credentials" (choose "Always Allow" so launchd starts don't block); on Linux/Windows it reuses `~/.claude/.credentials.json` if present. - - **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). - - **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. - - **OpenAI Codex OAuth**: browser flow; paste the `code#state`. - - Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. - - **API key**: stores the key for you. +- **Anthropic token (paste setup-token)**: run `claude setup-token` in your terminal, then paste the token (you can name it; blank = default). +- **OpenAI Codex OAuth (Codex CLI)**: if `~/.codex/auth.json` exists, the wizard can reuse it. +- **OpenAI Codex OAuth**: browser flow; paste the `code#state`. + - Sets `agent.model` to `openai-codex/gpt-5.2` when model is unset or `openai/*`. +- **OpenAI API key**: uses `OPENAI_API_KEY` if present or prompts for a key, then saves it to `~/.clawdbot/.env` so launchd can read it. +- **API key**: stores the key for you. - **Minimax M2.1 (LM Studio)**: config is auto‑written for the LM Studio endpoint. - **Skip**: no auth configured yet. - Wizard runs a model check and warns if the configured model is unknown or missing auth. diff --git a/src/cli/program.ts b/src/cli/program.ts index fb24c10d1..c5c8f6bca 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -239,9 +239,10 @@ export function buildProgram() { .option("--mode ", "Wizard mode: local|remote") .option( "--auth-choice ", - "Auth: oauth|claude-cli|token|openai-codex|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip", + "Auth: oauth|claude-cli|token|openai-codex|openai-api-key|codex-cli|antigravity|gemini-api-key|apiKey|minimax|skip", ) .option("--anthropic-api-key ", "Anthropic API key") + .option("--openai-api-key ", "OpenAI API key") .option("--gemini-api-key ", "Gemini API key") .option("--gateway-port ", "Gateway port") .option("--gateway-bind ", "Gateway bind: loopback|lan|tailnet|auto") @@ -270,6 +271,7 @@ export function buildProgram() { | "claude-cli" | "token" | "openai-codex" + | "openai-api-key" | "codex-cli" | "antigravity" | "gemini-api-key" @@ -278,6 +280,7 @@ export function buildProgram() { | "skip" | undefined, anthropicApiKey: opts.anthropicApiKey as string | undefined, + openaiApiKey: opts.openaiApiKey as string | undefined, geminiApiKey: opts.geminiApiKey as string | undefined, gatewayPort: typeof opts.gatewayPort === "string" diff --git a/src/commands/auth-choice-options.ts b/src/commands/auth-choice-options.ts index 51c1f2c86..160f64911 100644 --- a/src/commands/auth-choice-options.ts +++ b/src/commands/auth-choice-options.ts @@ -85,6 +85,7 @@ export function buildAuthChoiceOptions(params: { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)", }); + options.push({ value: "openai-api-key", label: "OpenAI API key" }); options.push({ value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", diff --git a/src/commands/auth-choice.ts b/src/commands/auth-choice.ts index ad48c1b36..6505f8bab 100644 --- a/src/commands/auth-choice.ts +++ b/src/commands/auth-choice.ts @@ -18,8 +18,8 @@ import { } from "../agents/model-auth.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import type { ClawdbotConfig } from "../config/config.js"; +import { upsertSharedEnvVar } from "../infra/env-file.js"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { @@ -210,38 +210,10 @@ export async function applyAuthChoice(params: { mode: "token", }); } else if (params.authChoice === "token" || params.authChoice === "oauth") { - const profileNameRaw = await params.prompter.text({ - message: "Token name (blank = default)", - placeholder: "default", - }); const provider = (await params.prompter.select({ message: "Token provider", options: [{ value: "anthropic", label: "Anthropic (only supported)" }], })) as "anthropic"; - const profileId = buildTokenProfileId({ - provider, - name: String(profileNameRaw ?? ""), - }); - - const store = ensureAuthProfileStore(params.agentDir, { - allowKeychainPrompt: false, - }); - const existing = store.profiles[profileId]; - if (existing?.type === "token") { - const useExisting = await params.prompter.confirm({ - message: `Use existing token "${profileId}"?`, - initialValue: true, - }); - if (useExisting) { - nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, - provider, - mode: "token", - }); - return { config: nextConfig, agentModelOverride }; - } - } - await params.prompter.note( [ "Run `claude setup-token` in your terminal.", @@ -256,46 +228,67 @@ export async function applyAuthChoice(params: { }); const token = String(tokenRaw).trim(); - const wantsExpiry = await params.prompter.confirm({ - message: "Does this token expire?", - initialValue: false, + const profileNameRaw = await params.prompter.text({ + message: "Token name (blank = default)", + placeholder: "default", + }); + const namedProfileId = buildTokenProfileId({ + provider, + name: String(profileNameRaw ?? ""), }); - const expiresInRaw = wantsExpiry - ? await params.prompter.text({ - message: "Expires in (duration)", - initialValue: "365d", - validate: (value) => { - try { - parseDurationMs(String(value ?? ""), { defaultUnit: "d" }); - return undefined; - } catch { - return "Invalid duration (e.g. 365d, 12h, 30m)"; - } - }, - }) - : ""; - - const expiresIn = String(expiresInRaw).trim(); - const expires = expiresIn - ? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" }) - : undefined; upsertAuthProfile({ - profileId, + profileId: namedProfileId, agentDir: params.agentDir, credential: { type: "token", provider, token, - ...(expires ? { expires } : {}), }, }); nextConfig = applyAuthProfileConfig(nextConfig, { - profileId, + profileId: namedProfileId, provider, mode: "token", }); + } else if (params.authChoice === "openai-api-key") { + const envKey = resolveEnvApiKey("openai"); + if (envKey) { + const useExisting = await params.prompter.confirm({ + message: `Use existing OPENAI_API_KEY (${envKey.source})?`, + initialValue: true, + }); + if (useExisting) { + const result = upsertSharedEnvVar({ + key: "OPENAI_API_KEY", + value: envKey.apiKey, + }); + if (!process.env.OPENAI_API_KEY) { + process.env.OPENAI_API_KEY = envKey.apiKey; + } + await params.prompter.note( + `Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`, + "OpenAI API key", + ); + return { config: nextConfig, agentModelOverride }; + } + } + + const key = await params.prompter.text({ + message: "Enter OpenAI API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const trimmed = String(key).trim(); + const result = upsertSharedEnvVar({ + key: "OPENAI_API_KEY", + value: trimmed, + }); + process.env.OPENAI_API_KEY = trimmed; + await params.prompter.note( + `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, + "OpenAI API key", + ); } else if (params.authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); await params.prompter.note( diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 39c10d084..3b80b0fca 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -21,7 +21,6 @@ import { ensureAuthProfileStore, upsertAuthProfile, } from "../agents/auth-profiles.js"; -import { parseDurationMs } from "../cli/parse-duration.js"; import { createCliProgress } from "../cli/progress.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -65,6 +64,8 @@ import { GOOGLE_GEMINI_DEFAULT_MODEL, } from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; +import { resolveEnvApiKey } from "../agents/model-auth.js"; +import { upsertSharedEnvVar } from "../infra/env-file.js"; import { applyAuthProfileConfig, applyMinimaxConfig, @@ -351,6 +352,7 @@ async function promptAuthConfig( | "claude-cli" | "token" | "openai-codex" + | "openai-api-key" | "codex-cli" | "antigravity" | "gemini-api-key" @@ -398,14 +400,6 @@ async function promptAuthConfig( mode: "token", }); } else if (authChoice === "token" || authChoice === "oauth") { - const profileNameRaw = guardCancel( - await text({ - message: "Token name (blank = default)", - placeholder: "default", - }), - runtime, - ); - const provider = guardCancel( await select({ message: "Token provider", @@ -419,32 +413,6 @@ async function promptAuthConfig( runtime, ) as "anthropic"; - const profileId = buildTokenProfileId({ - provider, - name: String(profileNameRaw ?? ""), - }); - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - const existing = store.profiles[profileId]; - if (existing?.type === "token") { - const useExisting = guardCancel( - await confirm({ - message: `Use existing token "${profileId}"?`, - initialValue: true, - }), - runtime, - ); - if (useExisting) { - next = applyAuthProfileConfig(next, { - profileId, - provider, - mode: "token", - }); - return next; - } - } - note( [ "Run `claude setup-token` in your terminal.", @@ -462,34 +430,17 @@ async function promptAuthConfig( ); const token = String(tokenRaw).trim(); - const wantsExpiry = guardCancel( - await confirm({ - message: "Does this token expire?", - initialValue: false, + const profileNameRaw = guardCancel( + await text({ + message: "Token name (blank = default)", + placeholder: "default", }), runtime, ); - const expiresInRaw = wantsExpiry - ? guardCancel( - await text({ - message: "Expires in (duration)", - initialValue: "365d", - validate: (value) => { - try { - parseDurationMs(String(value ?? ""), { defaultUnit: "d" }); - return undefined; - } catch { - return "Invalid duration (e.g. 365d, 12h, 30m)"; - } - }, - }), - runtime, - ) - : ""; - const expiresIn = String(expiresInRaw).trim(); - const expires = expiresIn - ? Date.now() + parseDurationMs(expiresIn, { defaultUnit: "d" }) - : undefined; + const profileId = buildTokenProfileId({ + provider, + name: String(profileNameRaw ?? ""), + }); upsertAuthProfile({ profileId, @@ -497,11 +448,52 @@ async function promptAuthConfig( type: "token", provider, token, - ...(expires ? { expires } : {}), }, }); next = applyAuthProfileConfig(next, { profileId, provider, mode: "token" }); + } else if (authChoice === "openai-api-key") { + const envKey = resolveEnvApiKey("openai"); + if (envKey) { + const useExisting = guardCancel( + await confirm({ + message: `Use existing OPENAI_API_KEY (${envKey.source})?`, + initialValue: true, + }), + runtime, + ); + if (useExisting) { + const result = upsertSharedEnvVar({ + key: "OPENAI_API_KEY", + value: envKey.apiKey, + }); + if (!process.env.OPENAI_API_KEY) { + process.env.OPENAI_API_KEY = envKey.apiKey; + } + note( + `Copied OPENAI_API_KEY to ${result.path} for launchd compatibility.`, + "OpenAI API key", + ); + } + } + + const key = guardCancel( + await text({ + message: "Enter OpenAI API key", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + runtime, + ); + const trimmed = String(key).trim(); + const result = upsertSharedEnvVar({ + key: "OPENAI_API_KEY", + value: trimmed, + }); + process.env.OPENAI_API_KEY = trimmed; + note( + `Saved OPENAI_API_KEY to ${result.path} for launchd compatibility.`, + "OpenAI API key", + ); } else if (authChoice === "openai-codex") { const isRemote = isRemoteEnvironment(); note( @@ -703,13 +695,24 @@ async function promptAuthConfig( next = applyMinimaxConfig(next); } + const currentModel = + typeof next.agent?.model === "string" + ? next.agent?.model + : (next.agent?.model?.primary ?? ""); + const preferAnthropic = + authChoice === "claude-cli" || + authChoice === "token" || + authChoice === "oauth" || + authChoice === "apiKey"; + const modelInitialValue = + preferAnthropic && !currentModel.startsWith("anthropic/") + ? "anthropic/claude-opus-4-5" + : currentModel; + const modelInput = guardCancel( await text({ message: "Default model (blank to keep)", - initialValue: - typeof next.agent?.model === "string" - ? next.agent?.model - : (next.agent?.model?.primary ?? ""), + initialValue: modelInitialValue, }), runtime, ); @@ -1078,58 +1081,65 @@ export async function runConfigureWizard( runtime.error(controlUiAssets.message); } + const bind = nextConfig.gateway?.bind ?? "loopback"; + const links = resolveControlUiLinks({ + bind, + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + }); + const gatewayProbe = await probeGatewayReachable({ + url: links.wsUrl, + token: + nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + password: + nextConfig.gateway?.auth?.password ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD, + }); + const gatewayStatusLine = gatewayProbe.ok + ? "Gateway: reachable" + : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; + note( - (() => { - const bind = nextConfig.gateway?.bind ?? "loopback"; - const links = resolveControlUiLinks({ - bind, - port: gatewayPort, - basePath: nextConfig.gateway?.controlUi?.basePath, - }); - return [ - `Web UI: ${links.httpUrl}`, - `Gateway WS: ${links.wsUrl}`, - "Docs: https://docs.clawd.bot/web/control-ui", - ].join("\n"); - })(), + [ + `Web UI: ${links.httpUrl}`, + `Gateway WS: ${links.wsUrl}`, + gatewayStatusLine, + "Docs: https://docs.clawd.bot/web/control-ui", + ].join("\n"), "Control UI", ); const browserSupport = await detectBrowserOpenSupport(); - if (!browserSupport.ok) { - note( - formatControlUiSshHint({ - port: gatewayPort, - basePath: nextConfig.gateway?.controlUi?.basePath, - token: gatewayToken, - }), - "Open Control UI", - ); - } else { - const wantsOpen = guardCancel( - await confirm({ - message: "Open Control UI now?", - initialValue: false, - }), - runtime, - ); - if (wantsOpen) { - const bind = nextConfig.gateway?.bind ?? "loopback"; - const links = resolveControlUiLinks({ - bind, - port: gatewayPort, - basePath: nextConfig.gateway?.controlUi?.basePath, - }); - const opened = await openUrl(links.httpUrl); - if (!opened) { - note( - formatControlUiSshHint({ - port: gatewayPort, - basePath: nextConfig.gateway?.controlUi?.basePath, - token: gatewayToken, - }), - "Open Control UI", - ); + if (gatewayProbe.ok) { + if (!browserSupport.ok) { + note( + formatControlUiSshHint({ + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + token: gatewayToken, + }), + "Open Control UI", + ); + } else { + const wantsOpen = guardCancel( + await confirm({ + message: "Open Control UI now?", + initialValue: false, + }), + runtime, + ); + if (wantsOpen) { + const opened = await openUrl(links.httpUrl); + if (!opened) { + note( + formatControlUiSshHint({ + port: gatewayPort, + basePath: nextConfig.gateway?.controlUi?.basePath, + token: gatewayToken, + }), + "Open Control UI", + ); + } } } } diff --git a/src/commands/onboard-non-interactive.ts b/src/commands/onboard-non-interactive.ts index 73c8fc888..1563e090d 100644 --- a/src/commands/onboard-non-interactive.ts +++ b/src/commands/onboard-non-interactive.ts @@ -4,6 +4,7 @@ import { CODEX_CLI_PROFILE_ID, ensureAuthProfileStore, } from "../agents/auth-profiles.js"; +import { resolveEnvApiKey } from "../agents/model-auth.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, @@ -16,6 +17,7 @@ import { resolveGatewayProgramArguments } from "../daemon/program-args.js"; import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; +import { upsertSharedEnvVar } from "../infra/env-file.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { resolveUserPath, sleep } from "../utils.js"; @@ -135,6 +137,19 @@ export async function runNonInteractiveOnboarding( mode: "api_key", }); nextConfig = applyGoogleGeminiModelDefault(nextConfig).next; + } else if (authChoice === "openai-api-key") { + const key = opts.openaiApiKey?.trim() || resolveEnvApiKey("openai")?.apiKey; + if (!key) { + runtime.error("Missing --openai-api-key (or OPENAI_API_KEY in env)."); + runtime.exit(1); + return; + } + const result = upsertSharedEnvVar({ + key: "OPENAI_API_KEY", + value: key, + }); + process.env.OPENAI_API_KEY = key; + runtime.log(`Saved OPENAI_API_KEY to ${result.path}`); } else if (authChoice === "claude-cli") { const store = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 159cd11e6..3f84dfaf4 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -7,6 +7,7 @@ export type AuthChoice = | "claude-cli" | "token" | "openai-codex" + | "openai-api-key" | "codex-cli" | "antigravity" | "apiKey" @@ -26,6 +27,7 @@ export type OnboardOptions = { nonInteractive?: boolean; authChoice?: AuthChoice; anthropicApiKey?: string; + openaiApiKey?: string; geminiApiKey?: string; gatewayPort?: number; gatewayBind?: GatewayBind; diff --git a/src/infra/env-file.ts b/src/infra/env-file.ts new file mode 100644 index 000000000..de7a27f2d --- /dev/null +++ b/src/infra/env-file.ts @@ -0,0 +1,55 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { resolveConfigDir } from "../utils.js"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function upsertSharedEnvVar(params: { + key: string; + value: string; + env?: NodeJS.ProcessEnv; +}): { path: string; updated: boolean; created: boolean } { + const env = params.env ?? process.env; + const dir = resolveConfigDir(env); + const filepath = path.join(dir, ".env"); + const key = params.key.trim(); + const value = params.value; + + let raw = ""; + if (fs.existsSync(filepath)) { + raw = fs.readFileSync(filepath, "utf8"); + } + + const lines = raw.length ? raw.split(/\r?\n/) : []; + const matcher = new RegExp(`^(\\s*(?:export\\s+)?)${escapeRegExp(key)}\\s*=`); + let updated = false; + let replaced = false; + + const nextLines = lines.map((line) => { + const match = line.match(matcher); + if (!match) return line; + replaced = true; + const prefix = match[1] ?? ""; + const next = `${prefix}${key}=${value}`; + if (next !== line) updated = true; + return next; + }); + + if (!replaced) { + nextLines.push(`${key}=${value}`); + updated = true; + } + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + + const output = `${nextLines.join("\n")}\n`; + fs.writeFileSync(filepath, output, "utf8"); + fs.chmodSync(filepath, 0o600); + + return { path: filepath, updated, created: !raw }; +} diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index c79d2d1ef..81d2fde98 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -548,65 +548,66 @@ export async function runOnboardingWizard( "Optional apps", ); + const links = resolveControlUiLinks({ + bind, + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + }); + const tokenParam = + authMode === "token" && gatewayToken + ? `?token=${encodeURIComponent(gatewayToken)}` + : ""; + const authedUrl = `${links.httpUrl}${tokenParam}`; + const gatewayProbe = await probeGatewayReachable({ + url: links.wsUrl, + token: authMode === "token" ? gatewayToken : undefined, + password: authMode === "password" ? baseConfig.gateway?.auth?.password : "", + }); + const gatewayStatusLine = gatewayProbe.ok + ? "Gateway: reachable" + : `Gateway: not detected${gatewayProbe.detail ? ` (${gatewayProbe.detail})` : ""}`; + await prompter.note( - (() => { - const links = resolveControlUiLinks({ - bind, - port, - basePath: baseConfig.gateway?.controlUi?.basePath, - }); - const tokenParam = - authMode === "token" && gatewayToken - ? `?token=${encodeURIComponent(gatewayToken)}` - : ""; - const authedUrl = `${links.httpUrl}${tokenParam}`; - return [ - `Web UI: ${links.httpUrl}`, - tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, - `Gateway WS: ${links.wsUrl}`, - "Docs: https://docs.clawd.bot/web/control-ui", - ] - .filter(Boolean) - .join("\n"); - })(), + [ + `Web UI: ${links.httpUrl}`, + tokenParam ? `Web UI (with token): ${authedUrl}` : undefined, + `Gateway WS: ${links.wsUrl}`, + gatewayStatusLine, + "Docs: https://docs.clawd.bot/web/control-ui", + ] + .filter(Boolean) + .join("\n"), "Control UI", ); const browserSupport = await detectBrowserOpenSupport(); - if (!browserSupport.ok) { - await prompter.note( - formatControlUiSshHint({ - port, - basePath: baseConfig.gateway?.controlUi?.basePath, - token: authMode === "token" ? gatewayToken : undefined, - }), - "Open Control UI", - ); - } else { - const wantsOpen = await prompter.confirm({ - message: "Open Control UI now?", - initialValue: true, - }); - if (wantsOpen) { - const links = resolveControlUiLinks({ - bind, - port, - basePath: baseConfig.gateway?.controlUi?.basePath, + if (gatewayProbe.ok) { + if (!browserSupport.ok) { + await prompter.note( + formatControlUiSshHint({ + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + token: authMode === "token" ? gatewayToken : undefined, + }), + "Open Control UI", + ); + } else { + const wantsOpen = await prompter.confirm({ + message: "Open Control UI now?", + initialValue: true, }); - const tokenParam = - authMode === "token" && gatewayToken - ? `?token=${encodeURIComponent(gatewayToken)}` - : ""; - const opened = await openUrl(`${links.httpUrl}${tokenParam}`); - if (!opened) { - await prompter.note( - formatControlUiSshHint({ - port, - basePath: baseConfig.gateway?.controlUi?.basePath, - token: authMode === "token" ? gatewayToken : undefined, - }), - "Open Control UI", - ); + if (wantsOpen) { + const opened = await openUrl(`${links.httpUrl}${tokenParam}`); + if (!opened) { + await prompter.note( + formatControlUiSshHint({ + port, + basePath: baseConfig.gateway?.controlUi?.basePath, + token: authMode === "token" ? gatewayToken : undefined, + }), + "Open Control UI", + ); + } } } } From e0c310d056e84767550e4bc360a3af4e3c0e6f53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 10:02:15 +0100 Subject: [PATCH 7/8] chore: bump versions to 2026.1.9 --- apps/android/app/build.gradle.kts | 4 ++-- apps/ios/Sources/Info.plist | 4 ++-- apps/ios/Tests/Info.plist | 4 ++-- apps/macos/Sources/Clawdbot/Resources/Info.plist | 4 ++-- docs/install/updating.md | 2 +- docs/platforms/mac/release.md | 14 +++++++------- package.json | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts index e3bc3785b..14c051e73 100644 --- a/apps/android/app/build.gradle.kts +++ b/apps/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "com.clawdbot.android" minSdk = 31 targetSdk = 36 - versionCode = 20260108 - versionName = "2026.1.8" + versionCode = 20260109 + versionName = "2026.1.9" } buildTypes { diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist index 99870f1a5..63f2cc5c7 100644 --- a/apps/ios/Sources/Info.plist +++ b/apps/ios/Sources/Info.plist @@ -19,9 +19,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.8 + 2026.1.9 CFBundleVersion - 20260108 + 20260109 NSAppTransportSecurity NSAllowsArbitraryLoadsInWebContent diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist index b8b3936a4..d577244b7 100644 --- a/apps/ios/Tests/Info.plist +++ b/apps/ios/Tests/Info.plist @@ -17,8 +17,8 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 2026.1.8 + 2026.1.9 CFBundleVersion - 20260108 + 20260109 diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist index b0ab948f2..2e69df3de 100644 --- a/apps/macos/Sources/Clawdbot/Resources/Info.plist +++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 2026.1.8 + 2026.1.9 CFBundleVersion - 20260108 + 20260109 CFBundleIconFile Clawdbot CFBundleURLTypes diff --git a/docs/install/updating.md b/docs/install/updating.md index 061a39874..46e5632cf 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -108,7 +108,7 @@ Runbook + exact service labels: [Gateway runbook](/gateway) Install a known-good version: ```bash -npm i -g clawdbot@2026.1.8 +npm i -g clawdbot@2026.1.9 ``` Then restart + re-run doctor: diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md index fd31c6cfc..7413a3f9e 100644 --- a/docs/platforms/mac/release.md +++ b/docs/platforms/mac/release.md @@ -29,17 +29,17 @@ Notes: # From repo root; set release IDs so Sparkle feed is enabled. # APP_BUILD must be numeric + monotonic for Sparkle compare. BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=0.1.0 \ +APP_VERSION=2026.1.9 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \ scripts/package-mac-app.sh # Zip for distribution (includes resource forks for Sparkle delta support) -ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-0.1.0.zip +ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.9.zip # Optional: also build a styled DMG for humans (drag to /Applications) -scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-0.1.0.dmg +scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.9.dmg # Recommended: build + notarize/staple zip + DMG # First, create a keychain profile once: @@ -47,26 +47,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-0.1.0.dmg # --apple-id "" --team-id "" --password "" NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \ BUNDLE_ID=com.clawdbot.mac \ -APP_VERSION=0.1.0 \ +APP_VERSION=2026.1.9 \ APP_BUILD="$(git rev-list --count HEAD)" \ BUILD_CONFIG=release \ SIGN_IDENTITY="Developer ID Application: Peter Steinberger (Y5PE65HELJ)" \ scripts/package-mac-dist.sh # Optional: ship dSYM alongside the release -ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-0.1.0.dSYM.zip +ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.9.dSYM.zip ``` ## Appcast entry Use the release note generator so Sparkle renders formatted HTML notes: ```bash -SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-0.1.0.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml +SPARKLE_PRIVATE_KEY_FILE=/Users/steipete/Library/CloudStorage/Dropbox/Backup/Sparkle/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.9.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml ``` Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry. Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing. ## Publish & verify -- Upload `Clawdbot-0.1.0.zip` (and `Clawdbot-0.1.0.dSYM.zip`) to the GitHub release for tag `v0.1.0`. +- Upload `Clawdbot-2026.1.9.zip` (and `Clawdbot-2026.1.9.dSYM.zip`) to the GitHub release for tag `v2026.1.9`. - Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`. - Sanity checks: - `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200. diff --git a/package.json b/package.json index 2da28c18e..ef1b0ca1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "clawdbot", - "version": "2026.1.8-2", + "version": "2026.1.9", "description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent", "type": "module", "main": "dist/index.js", From 0c167e85af1ad5b4346f52269424afdfca26da9a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 9 Jan 2026 10:03:52 +0100 Subject: [PATCH 8/8] style: sort configure imports --- src/commands/configure.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 3b80b0fca..14551cac5 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -21,6 +21,7 @@ import { ensureAuthProfileStore, upsertAuthProfile, } from "../agents/auth-profiles.js"; +import { resolveEnvApiKey } from "../agents/model-auth.js"; import { createCliProgress } from "../cli/progress.js"; import type { ClawdbotConfig } from "../config/config.js"; import { @@ -35,6 +36,7 @@ import { resolvePreferredNodePath } from "../daemon/runtime-paths.js"; import { resolveGatewayService } from "../daemon/service.js"; import { buildServiceEnvironment } from "../daemon/service-env.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; +import { upsertSharedEnvVar } from "../infra/env-file.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { @@ -64,8 +66,6 @@ import { GOOGLE_GEMINI_DEFAULT_MODEL, } from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { upsertSharedEnvVar } from "../infra/env-file.js"; import { applyAuthProfileConfig, applyMinimaxConfig,