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