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",
+ );
+ }
}
}
}