From 53a0c966a5a025d93382b84e1b4abfa485090221 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 10 Jan 2026 15:51:29 +0100 Subject: [PATCH] refactor: unify configure auth choice --- patches/@mariozechner__pi-ai@0.42.1.patch | 57 -- pnpm-workspace.yaml | 2 +- src/commands/configure.ts | 1127 ++++++--------------- src/commands/onboard-auth.test.ts | 8 +- src/commands/onboard-auth.ts | 20 +- 5 files changed, 332 insertions(+), 882 deletions(-) delete mode 100644 patches/@mariozechner__pi-ai@0.42.1.patch diff --git a/patches/@mariozechner__pi-ai@0.42.1.patch b/patches/@mariozechner__pi-ai@0.42.1.patch deleted file mode 100644 index e4a1be4c1..000000000 --- a/patches/@mariozechner__pi-ai@0.42.1.patch +++ /dev/null @@ -1,57 +0,0 @@ -diff --git a/dist/providers/google-gemini-cli.js b/dist/providers/google-gemini-cli.js -index 93aa26c395e9bd0df64376408a13d15ee9e7cce7..41a439e5fc370038a5febef9e8f021ee279cf8aa 100644 ---- a/dist/providers/google-gemini-cli.js -+++ b/dist/providers/google-gemini-cli.js -@@ -248,6 +248,11 @@ export const streamGoogleGeminiCli = (model, context, options) => { - break; // Success, exit retry loop - } - const errorText = await response.text(); -+ // Fail immediately on 429 for Antigravity to let callers rotate accounts. -+ // Antigravity rate limits can have very long retry delays (10+ minutes). -+ if (isAntigravity && response.status === 429) { -+ throw new Error(`Cloud Code Assist API error (${response.status}): ${errorText}`); -+ } - // Check if retryable - if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) { - // Use server-provided delay or exponential backoff -diff --git a/dist/providers/openai-codex-responses.js b/dist/providers/openai-codex-responses.js -index 188a8294f26fe1bfe3fb298a7f58e4d8eaf2a529..3fd8027edafdad4ca364af53f0a1811139705b21 100644 ---- a/dist/providers/openai-codex-responses.js -+++ b/dist/providers/openai-codex-responses.js -@@ -433,9 +433,15 @@ function convertMessages(model, context) { - } - else if (msg.role === "assistant") { - const output = []; -+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`. -+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but -+ // their stored reasoning items must not be replayed as standalone `reasoning` input. -+ const hasTextBlock = msg.content.some((b) => b.type === "text"); - for (const block of msg.content) { - if (block.type === "thinking" && msg.stopReason !== "error") { - if (block.thinkingSignature) { -+ if (!hasTextBlock) -+ continue; - const reasoningItem = JSON.parse(block.thinkingSignature); - output.push(reasoningItem); - } -diff --git a/dist/providers/openai-responses.js b/dist/providers/openai-responses.js -index 20fb0a22aaa28f7ff7c2f44a8b628fa1d9d7d936..0bf46bfb4a6fac5a0304652e42566b2c991bab48 100644 ---- a/dist/providers/openai-responses.js -+++ b/dist/providers/openai-responses.js -@@ -396,10 +396,16 @@ function convertMessages(model, context) { - } - else if (msg.role === "assistant") { - const output = []; -+ // OpenAI Responses rejects `reasoning` items that are not followed by a `message`. -+ // Tool-call-only turns (thinking + function_call) are valid assistant turns, but -+ // their stored reasoning items must not be replayed as standalone `reasoning` input. -+ const hasTextBlock = msg.content.some((b) => b.type === "text"); - for (const block of msg.content) { - // Do not submit thinking blocks if the completion had an error (i.e. abort) - if (block.type === "thinking" && msg.stopReason !== "error") { - if (block.thinkingSignature) { -+ if (!hasTextBlock) -+ continue; - const reasoningItem = JSON.parse(block.thinkingSignature); - output.push(reasoningItem); - } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9cd3d93b7..266963933 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,4 +10,4 @@ onlyBuiltDependencies: - sharp patchedDependencies: - '@mariozechner/pi-ai@0.42.1': patches/@mariozechner__pi-ai@0.42.1.patch + '@mariozechner/pi-ai@0.42.2': patches/@mariozechner__pi-ai@0.42.2.patch diff --git a/src/commands/configure.ts b/src/commands/configure.ts index 0c4780e80..55fa1c6a2 100644 --- a/src/commands/configure.ts +++ b/src/commands/configure.ts @@ -8,21 +8,8 @@ import { outro as clackOutro, select as clackSelect, text as clackText, - spinner, } from "@clack/prompts"; -import { - loginOpenAICodex, - type OAuthCredentials, - type OAuthProvider, -} from "@mariozechner/pi-ai"; -import { - CLAUDE_CLI_PROFILE_ID, - CODEX_CLI_PROFILE_ID, - ensureAuthProfileStore, - upsertAuthProfile, -} from "../agents/auth-profiles.js"; -import { resolveEnvApiKey } from "../agents/model-auth.js"; -import { createCliProgress } from "../cli/progress.js"; +import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, @@ -36,7 +23,6 @@ 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 { listChatProviders } from "../providers/registry.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -45,47 +31,26 @@ import { 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 { - isRemoteEnvironment, - loginAntigravityVpsAware, -} from "./antigravity-oauth.js"; + WizardCancelledError, + type WizardPrompter, +} from "../wizard/prompts.js"; +import { applyAuthChoice } from "./auth-choice.js"; import { buildAuthChoiceOptions } from "./auth-choice-options.js"; -import { - buildTokenProfileId, - validateAnthropicSetupToken, -} from "./auth-token.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME, GATEWAY_DAEMON_RUNTIME_OPTIONS, type GatewayDaemonRuntime, } from "./daemon-runtime.js"; -import { - applyGoogleGeminiModelDefault, - GOOGLE_GEMINI_DEFAULT_MODEL, -} from "./google-gemini-model-default.js"; import { healthCommand } from "./health.js"; import { formatHealthCheckFailure } from "./health-format.js"; -import { - applyAuthProfileConfig, - applyMinimaxApiConfig, - applyMinimaxConfig, - applyMinimaxHostedConfig, - applyOpencodeZenConfig, - setAnthropicApiKey, - setGeminiApiKey, - setMinimaxApiKey, - setOpencodeZenApiKey, - writeOAuthCredentials, -} from "./onboard-auth.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, - openUrl, printWizardHeader, probeGatewayReachable, randomToken, @@ -95,11 +60,7 @@ import { import { setupProviders } from "./onboard-providers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; -import { - applyOpenAICodexModelDefault, - OPENAI_CODEX_DEFAULT_MODEL, -} from "./openai-codex-model-default.js"; -import { OPENCODE_ZEN_DEFAULT_MODEL } from "./opencode-zen-model-default.js"; +import type { AuthChoice } from "./onboard-types.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; export const CONFIGURE_WIZARD_SECTIONS = [ @@ -158,27 +119,6 @@ const multiselect = (params: Parameters>[0]) => ), }); -const startOscSpinner = (label: string) => { - const spin = spinner(); - spin.start(theme.accent(label)); - const osc = createCliProgress({ - label, - indeterminate: true, - enabled: true, - fallback: "none", - }); - return { - update: (message: string) => { - spin.message(theme.accent(message)); - osc.setLabel(message); - }, - stop: (message: string) => { - osc.done(); - spin.stop(message); - }, - }; -}; - async function promptGatewayConfig( cfg: ClawdbotConfig, runtime: RuntimeEnv, @@ -345,492 +285,29 @@ async function promptGatewayConfig( async function promptAuthConfig( cfg: ClawdbotConfig, runtime: RuntimeEnv, + prompter: WizardPrompter, ): Promise { - const authChoice = guardCancel( - await select({ - message: "Model/auth choice", - options: buildAuthChoiceOptions({ - store: ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }), - includeSkip: true, - includeClaudeCliIfMissing: true, + const authChoice: AuthChoice = await prompter.select({ + message: "Model/auth choice", + options: buildAuthChoiceOptions({ + store: ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, }), + includeSkip: true, + includeClaudeCliIfMissing: true, }), - runtime, - ) as - | "oauth" - | "setup-token" - | "claude-cli" - | "token" - | "openai-codex" - | "openai-api-key" - | "codex-cli" - | "antigravity" - | "gemini-api-key" - | "apiKey" - | "minimax-cloud" - | "minimax-api" - | "minimax" - | "opencode-zen" - | "skip"; + }); let next = cfg; - - if (authChoice === "claude-cli") { - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID] && process.stdin.isTTY) { - note( - [ - "No Claude CLI credentials found yet.", - "If you have a Claude Pro/Max subscription, run `claude setup-token`.", - ].join("\n"), - "Claude CLI", - ); - const runNow = guardCancel( - await confirm({ - message: "Run `claude setup-token` now?", - initialValue: true, - }), - runtime, - ); - if (runNow) { - const res = await (async () => { - const { spawnSync } = await import("node:child_process"); - return spawnSync("claude", ["setup-token"], { stdio: "inherit" }); - })(); - if (res.error) { - note( - `Failed to run claude: ${String(res.error)}`, - "Claude setup-token", - ); - } - } - } - next = applyAuthProfileConfig(next, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if (authChoice === "setup-token" || authChoice === "oauth") { - note( - [ - "This will run `claude setup-token` to create a long-lived Anthropic token.", - "Requires an interactive TTY and a Claude Pro/Max subscription.", - ].join("\n"), - "Anthropic setup-token", - ); - - if (!process.stdin.isTTY) { - note( - "`claude setup-token` requires an interactive TTY.", - "Anthropic setup-token", - ); - return next; - } - - const runNow = guardCancel( - await confirm({ - message: "Run `claude setup-token` now?", - initialValue: true, - }), + if (authChoice !== "skip") { + const applied = await applyAuthChoice({ + authChoice, + config: next, + prompter, runtime, - ); - if (!runNow) return next; - - const res = await (async () => { - const { spawnSync } = await import("node:child_process"); - return spawnSync("claude", ["setup-token"], { stdio: "inherit" }); - })(); - if (res.error) { - note( - `Failed to run claude: ${String(res.error)}`, - "Anthropic setup-token", - ); - return next; - } - if (typeof res.status === "number" && res.status !== 0) { - note( - `claude setup-token failed (exit ${res.status})`, - "Anthropic setup-token", - ); - return next; - } - - const store = ensureAuthProfileStore(undefined, { - allowKeychainPrompt: true, + setDefaultModel: true, }); - if (!store.profiles[CLAUDE_CLI_PROFILE_ID]) { - note( - `No Claude CLI credentials found after setup-token. Expected ${CLAUDE_CLI_PROFILE_ID}.`, - "Anthropic setup-token", - ); - return next; - } - - next = applyAuthProfileConfig(next, { - profileId: CLAUDE_CLI_PROFILE_ID, - provider: "anthropic", - mode: "token", - }); - } else if (authChoice === "token") { - const provider = guardCancel( - await select({ - message: "Token provider", - options: [ - { - value: "anthropic", - label: "Anthropic (only supported)", - }, - ], - }), - runtime, - ) as "anthropic"; - - note( - [ - "Run `claude setup-token` in your terminal.", - "Then paste the generated token below.", - ].join("\n"), - "Anthropic token", - ); - - const tokenRaw = guardCancel( - await text({ - message: "Paste Anthropic setup-token", - validate: (value) => validateAnthropicSetupToken(String(value ?? "")), - }), - runtime, - ); - const token = String(tokenRaw).trim(); - - const profileNameRaw = guardCancel( - await text({ - message: "Token name (blank = default)", - placeholder: "default", - }), - runtime, - ); - const profileId = buildTokenProfileId({ - provider, - name: String(profileNameRaw ?? ""), - }); - - upsertAuthProfile({ - profileId, - credential: { - type: "token", - provider, - token, - }, - }); - - 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( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, paste the redirect URL back here.", - ].join("\n") - : [ - "Browser will open for OpenAI authentication.", - "If the callback doesn't auto-complete, paste the redirect URL.", - "OpenAI OAuth uses localhost:1455 for the callback.", - ].join("\n"), - "OpenAI Codex OAuth", - ); - const spin = startOscSpinner("Starting OAuth flow…"); - let manualCodePromise: Promise | undefined; - try { - const creds = await loginOpenAICodex({ - onAuth: async ({ url }) => { - if (isRemote) { - spin.update("OAuth URL ready (see below)…"); - runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - manualCodePromise = text({ - message: "Paste the redirect URL (or authorization code)", - validate: (value) => (value?.trim() ? undefined : "Required"), - }).then((value) => String(guardCancel(value, runtime))); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); - } - }, - onPrompt: async (prompt) => { - if (manualCodePromise) return manualCodePromise; - const code = guardCancel( - await text({ - message: prompt.message, - placeholder: prompt.placeholder, - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - return String(code); - }, - onProgress: (msg) => spin.update(msg), - }); - spin.stop("OpenAI OAuth complete"); - if (creds) { - await writeOAuthCredentials( - "openai-codex" as unknown as OAuthProvider, - creds, - ); - next = applyAuthProfileConfig(next, { - profileId: "openai-codex:default", - provider: "openai-codex", - mode: "oauth", - }); - const applied = applyOpenAICodexModelDefault(next); - next = applied.next; - if (applied.changed) { - note( - `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", - ); - } - } - } catch (err) { - spin.stop("OpenAI OAuth failed"); - runtime.error(String(err)); - note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth"); - } - } else if (authChoice === "codex-cli") { - next = applyAuthProfileConfig(next, { - profileId: CODEX_CLI_PROFILE_ID, - provider: "openai-codex", - mode: "oauth", - }); - const applied = applyOpenAICodexModelDefault(next); - next = applied.next; - if (applied.changed) { - note( - `Default model set to ${OPENAI_CODEX_DEFAULT_MODEL}`, - "Model configured", - ); - } - } else if (authChoice === "antigravity") { - const isRemote = isRemoteEnvironment(); - note( - isRemote - ? [ - "You are running in a remote/VPS environment.", - "A URL will be shown for you to open in your LOCAL browser.", - "After signing in, copy the redirect URL and paste it back here.", - ].join("\n") - : [ - "Browser will open for Google authentication.", - "Sign in with your Google account that has Antigravity access.", - "The callback will be captured automatically on localhost:51121.", - ].join("\n"), - "Google Antigravity OAuth", - ); - const spin = startOscSpinner("Starting OAuth flow…"); - let oauthCreds: OAuthCredentials | null = null; - try { - oauthCreds = await loginAntigravityVpsAware( - async (url) => { - if (isRemote) { - spin.stop("OAuth URL ready"); - runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); - } else { - spin.update("Complete sign-in in browser…"); - await openUrl(url); - runtime.log(`Open: ${url}`); - } - }, - (msg) => spin.update(msg), - ); - spin.stop("Antigravity OAuth complete"); - if (oauthCreds) { - await writeOAuthCredentials("google-antigravity", oauthCreds); - next = applyAuthProfileConfig(next, { - profileId: `google-antigravity:${oauthCreds.email ?? "default"}`, - provider: "google-antigravity", - mode: "oauth", - }); - // Set default model to Claude Opus 4.5 via Antigravity - const existingDefaults = next.agents?.defaults; - const existingModel = existingDefaults?.model; - const existingModels = existingDefaults?.models; - next = { - ...next, - agents: { - ...next.agents, - defaults: { - ...existingDefaults, - model: { - ...(existingModel && - "fallbacks" in (existingModel as Record) - ? { - fallbacks: (existingModel as { fallbacks?: string[] }) - .fallbacks, - } - : undefined), - primary: "google-antigravity/claude-opus-4-5-thinking", - }, - models: { - ...existingModels, - "google-antigravity/claude-opus-4-5-thinking": - existingModels?.[ - "google-antigravity/claude-opus-4-5-thinking" - ] ?? {}, - }, - }, - }, - }; - note( - "Default model set to google-antigravity/claude-opus-4-5-thinking", - "Model configured", - ); - } - } catch (err) { - spin.stop("Antigravity OAuth failed"); - runtime.error(String(err)); - note("Trouble with OAuth? See https://docs.clawd.bot/start/faq", "OAuth"); - } - } else if (authChoice === "gemini-api-key") { - const key = guardCancel( - await text({ - message: "Enter Gemini API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - await setGeminiApiKey(String(key).trim()); - next = applyAuthProfileConfig(next, { - profileId: "google:default", - provider: "google", - mode: "api_key", - }); - const applied = applyGoogleGeminiModelDefault(next); - next = applied.next; - if (applied.changed) { - note( - `Default model set to ${GOOGLE_GEMINI_DEFAULT_MODEL}`, - "Model configured", - ); - } - } else if (authChoice === "apiKey") { - const key = guardCancel( - await text({ - message: "Enter Anthropic API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - await setAnthropicApiKey(String(key).trim()); - next = applyAuthProfileConfig(next, { - profileId: "anthropic:default", - provider: "anthropic", - mode: "api_key", - }); - } else if (authChoice === "minimax-cloud") { - const key = guardCancel( - await text({ - message: "Enter MiniMax API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - await setMinimaxApiKey(String(key).trim()); - next = applyAuthProfileConfig(next, { - profileId: "minimax:default", - provider: "minimax", - mode: "api_key", - }); - next = applyMinimaxHostedConfig(next); - } else if (authChoice === "minimax") { - next = applyMinimaxConfig(next); - } else if (authChoice === "minimax-api") { - const key = guardCancel( - await text({ - message: "Enter MiniMax API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - await setMinimaxApiKey(String(key).trim()); - next = applyAuthProfileConfig(next, { - profileId: "minimax:default", - provider: "minimax", - mode: "api_key", - }); - next = applyMinimaxApiConfig(next); - } else if (authChoice === "opencode-zen") { - note( - [ - "OpenCode Zen provides access to Claude, GPT, Gemini, and more models.", - "Get your API key at: https://opencode.ai/auth", - ].join("\n"), - "OpenCode Zen", - ); - const key = guardCancel( - await text({ - message: "Enter OpenCode Zen API key", - validate: (value) => (value?.trim() ? undefined : "Required"), - }), - runtime, - ); - await setOpencodeZenApiKey(String(key).trim()); - next = applyAuthProfileConfig(next, { - profileId: "opencode-zen:default", - provider: "opencode-zen", - mode: "api_key", - }); - next = applyOpencodeZenConfig(next); - note( - `Default model set to ${OPENCODE_ZEN_DEFAULT_MODEL}`, - "Model configured", - ); + next = applied.config; } const currentModel = @@ -1051,305 +528,315 @@ export async function runConfigureWizard( opts: ConfigureWizardParams, runtime: RuntimeEnv = defaultRuntime, ) { - printWizardHeader(runtime); - intro( - opts.command === "update" ? "Clawdbot update wizard" : "Clawdbot configure", - ); - const prompter = createClackPrompter(); + try { + printWizardHeader(runtime); + intro( + opts.command === "update" + ? "Clawdbot update wizard" + : "Clawdbot configure", + ); + const prompter = createClackPrompter(); - const snapshot = await readConfigFileSnapshot(); - let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; + const snapshot = await readConfigFileSnapshot(); + let baseConfig: ClawdbotConfig = snapshot.valid ? snapshot.config : {}; - if (snapshot.exists) { - const title = snapshot.valid - ? "Existing config detected" - : "Invalid config"; - note(summarizeExistingConfig(baseConfig), title); - if (!snapshot.valid && snapshot.issues.length > 0) { - note( - [ - ...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`), - "", - "Docs: https://docs.clawd.bot/gateway/configuration", - ].join("\n"), - "Config issues", - ); + if (snapshot.exists) { + const title = snapshot.valid + ? "Existing config detected" + : "Invalid config"; + note(summarizeExistingConfig(baseConfig), title); + if (!snapshot.valid && snapshot.issues.length > 0) { + note( + [ + ...snapshot.issues.map((iss) => `- ${iss.path}: ${iss.message}`), + "", + "Docs: https://docs.clawd.bot/gateway/configuration", + ].join("\n"), + "Config issues", + ); + } + if (!snapshot.valid) { + const reset = guardCancel( + await confirm({ + message: "Config invalid. Start fresh?", + initialValue: true, + }), + runtime, + ); + if (reset) baseConfig = {}; + } } - if (!snapshot.valid) { - const reset = guardCancel( - await confirm({ - message: "Config invalid. Start fresh?", - initialValue: true, + + const localUrl = "ws://127.0.0.1:18789"; + const localProbe = await probeGatewayReachable({ + url: localUrl, + token: + baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, + password: + baseConfig.gateway?.auth?.password ?? + process.env.CLAWDBOT_GATEWAY_PASSWORD, + }); + const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; + const remoteProbe = remoteUrl + ? await probeGatewayReachable({ + url: remoteUrl, + token: baseConfig.gateway?.remote?.token, + }) + : null; + + const mode = guardCancel( + await select({ + message: "Where will the Gateway run?", + options: [ + { + value: "local", + label: "Local (this machine)", + hint: localProbe.ok + ? `Gateway reachable (${localUrl})` + : `No gateway detected (${localUrl})`, + }, + { + value: "remote", + label: "Remote (info-only)", + hint: !remoteUrl + ? "No remote URL configured yet" + : remoteProbe?.ok + ? `Gateway reachable (${remoteUrl})` + : `Configured but unreachable (${remoteUrl})`, + }, + ], + }), + runtime, + ) as "local" | "remote"; + + if (mode === "remote") { + let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter); + remoteConfig = applyWizardMetadata(remoteConfig, { + command: opts.command, + mode, + }); + await writeConfigFile(remoteConfig); + runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); + outro("Remote gateway configured."); + return; + } + + const selected = opts.sections + ? opts.sections + : (guardCancel( + await multiselect({ + message: "Select sections to configure", + options: [ + { + value: "workspace", + label: "Workspace", + hint: "Set default workspace + ensure sessions", + }, + { + value: "model", + label: "Model/auth", + hint: "Pick model + auth profile sources", + }, + { + value: "gateway", + label: "Gateway config", + hint: "Port/bind/auth/control UI settings", + }, + { + value: "daemon", + label: "Gateway daemon", + hint: "Install/manage the background service", + }, + { + value: "providers", + label: "Providers", + hint: "Link WhatsApp/Telegram/etc and defaults", + }, + { + value: "skills", + label: "Skills", + hint: "Install/enable workspace skills", + }, + { + value: "health", + label: "Health check", + hint: "Run gateway + provider checks", + }, + ], + }), + runtime, + ) as WizardSection[]); + + if (!selected || selected.length === 0) { + outro("No changes selected."); + return; + } + + let nextConfig = { ...baseConfig }; + let workspaceDir = + nextConfig.agents?.defaults?.workspace ?? + baseConfig.agents?.defaults?.workspace ?? + DEFAULT_WORKSPACE; + let gatewayPort = resolveGatewayPort(baseConfig); + let gatewayToken: string | undefined; + + if (selected.includes("workspace")) { + const workspaceInput = guardCancel( + await text({ + message: "Workspace directory", + initialValue: workspaceDir, }), runtime, ); - if (reset) baseConfig = {}; + workspaceDir = resolveUserPath( + String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE, + ); + nextConfig = { + ...nextConfig, + agents: { + ...nextConfig.agents, + defaults: { + ...nextConfig.agents?.defaults, + workspace: workspaceDir, + }, + }, + }; + await ensureWorkspaceAndSessions(workspaceDir, runtime); } - } - const localUrl = "ws://127.0.0.1:18789"; - const localProbe = await probeGatewayReachable({ - url: localUrl, - token: - baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, - password: - baseConfig.gateway?.auth?.password ?? - process.env.CLAWDBOT_GATEWAY_PASSWORD, - }); - const remoteUrl = baseConfig.gateway?.remote?.url?.trim() ?? ""; - const remoteProbe = remoteUrl - ? await probeGatewayReachable({ - url: remoteUrl, - token: baseConfig.gateway?.remote?.token, - }) - : null; + if (selected.includes("model")) { + nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); + } - const mode = guardCancel( - await select({ - message: "Where will the Gateway run?", - options: [ - { - value: "local", - label: "Local (this machine)", - hint: localProbe.ok - ? `Gateway reachable (${localUrl})` - : `No gateway detected (${localUrl})`, - }, - { - value: "remote", - label: "Remote (info-only)", - hint: !remoteUrl - ? "No remote URL configured yet" - : remoteProbe?.ok - ? `Gateway reachable (${remoteUrl})` - : `Configured but unreachable (${remoteUrl})`, - }, - ], - }), - runtime, - ) as "local" | "remote"; + if (selected.includes("gateway")) { + const gateway = await promptGatewayConfig(nextConfig, runtime); + nextConfig = gateway.config; + gatewayPort = gateway.port; + gatewayToken = gateway.token; + } - if (mode === "remote") { - let remoteConfig = await promptRemoteGatewayConfig(baseConfig, prompter); - remoteConfig = applyWizardMetadata(remoteConfig, { + if (selected.includes("providers")) { + const providerMode = guardCancel( + await select({ + message: "Providers", + options: [ + { + value: "configure", + label: "Configure/link", + hint: "Add/update providers; disable unselected accounts", + }, + { + value: "remove", + label: "Remove provider config", + hint: "Delete provider tokens/settings from clawdbot.json", + }, + ], + initialValue: "configure", + }), + runtime, + ) as ProvidersWizardMode; + + if (providerMode === "configure") { + nextConfig = await setupProviders(nextConfig, runtime, prompter, { + allowDisable: true, + allowSignalInstall: true, + }); + } else { + nextConfig = await removeProviderConfigWizard(nextConfig, runtime); + } + } + + if (selected.includes("skills")) { + const wsDir = resolveUserPath(workspaceDir); + nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); + } + + nextConfig = applyWizardMetadata(nextConfig, { command: opts.command, mode, }); - await writeConfigFile(remoteConfig); + await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - outro("Remote gateway configured."); - return; - } - const selected = opts.sections - ? opts.sections - : (guardCancel( - await multiselect({ - message: "Select sections to configure", - options: [ - { - value: "workspace", - label: "Workspace", - hint: "Set default workspace + ensure sessions", - }, - { - value: "model", - label: "Model/auth", - hint: "Pick model + auth profile sources", - }, - { - value: "gateway", - label: "Gateway config", - hint: "Port/bind/auth/control UI settings", - }, - { - value: "daemon", - label: "Gateway daemon", - hint: "Install/manage the background service", - }, - { - value: "providers", - label: "Providers", - hint: "Link WhatsApp/Telegram/etc and defaults", - }, - { - value: "skills", - label: "Skills", - hint: "Install/enable workspace skills", - }, - { - value: "health", - label: "Health check", - hint: "Run gateway + provider checks", - }, - ], - }), + if (selected.includes("daemon")) { + if (!selected.includes("gateway")) { + const portInput = guardCancel( + await text({ + message: "Gateway port for daemon install", + initialValue: String(gatewayPort), + validate: (value) => + Number.isFinite(Number(value)) ? undefined : "Invalid port", + }), + runtime, + ); + gatewayPort = Number.parseInt(String(portInput), 10); + } + + await maybeInstallDaemon({ runtime, - ) as WizardSection[]); - - if (!selected || selected.length === 0) { - outro("No changes selected."); - return; - } - - let nextConfig = { ...baseConfig }; - let workspaceDir = - nextConfig.agents?.defaults?.workspace ?? - baseConfig.agents?.defaults?.workspace ?? - DEFAULT_WORKSPACE; - let gatewayPort = resolveGatewayPort(baseConfig); - let gatewayToken: string | undefined; - - if (selected.includes("workspace")) { - const workspaceInput = guardCancel( - await text({ - message: "Workspace directory", - initialValue: workspaceDir, - }), - runtime, - ); - workspaceDir = resolveUserPath( - String(workspaceInput ?? "").trim() || DEFAULT_WORKSPACE, - ); - nextConfig = { - ...nextConfig, - agents: { - ...nextConfig.agents, - defaults: { - ...nextConfig.agents?.defaults, - workspace: workspaceDir, - }, - }, - }; - await ensureWorkspaceAndSessions(workspaceDir, runtime); - } - - if (selected.includes("model")) { - nextConfig = await promptAuthConfig(nextConfig, runtime); - } - - if (selected.includes("gateway")) { - const gateway = await promptGatewayConfig(nextConfig, runtime); - nextConfig = gateway.config; - gatewayPort = gateway.port; - gatewayToken = gateway.token; - } - - if (selected.includes("providers")) { - const providerMode = guardCancel( - await select({ - message: "Providers", - options: [ - { - value: "configure", - label: "Configure/link", - hint: "Add/update providers; disable unselected accounts", - }, - { - value: "remove", - label: "Remove provider config", - hint: "Delete provider tokens/settings from clawdbot.json", - }, - ], - initialValue: "configure", - }), - runtime, - ) as ProvidersWizardMode; - - if (providerMode === "configure") { - nextConfig = await setupProviders(nextConfig, runtime, prompter, { - allowDisable: true, - allowSignalInstall: true, + port: gatewayPort, + gatewayToken, }); - } else { - nextConfig = await removeProviderConfigWizard(nextConfig, runtime); - } - } - - if (selected.includes("skills")) { - const wsDir = resolveUserPath(workspaceDir); - nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); - } - - nextConfig = applyWizardMetadata(nextConfig, { - command: opts.command, - mode, - }); - await writeConfigFile(nextConfig); - runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); - - if (selected.includes("daemon")) { - if (!selected.includes("gateway")) { - const portInput = guardCancel( - await text({ - message: "Gateway port for daemon install", - initialValue: String(gatewayPort), - validate: (value) => - Number.isFinite(Number(value)) ? undefined : "Invalid port", - }), - runtime, - ); - gatewayPort = Number.parseInt(String(portInput), 10); } - await maybeInstallDaemon({ - runtime, + if (selected.includes("health")) { + await sleep(1000); + try { + await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); + } catch (err) { + runtime.error(formatHealthCheckFailure(err)); + note( + [ + "Docs:", + "https://docs.clawd.bot/gateway/health", + "https://docs.clawd.bot/gateway/troubleshooting", + ].join("\n"), + "Health check help", + ); + } + } + + const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); + if (!controlUiAssets.ok && controlUiAssets.message) { + runtime.error(controlUiAssets.message); + } + + const bind = nextConfig.gateway?.bind ?? "loopback"; + const links = resolveControlUiLinks({ + bind, port: gatewayPort, - gatewayToken, + 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})` : ""}`; - if (selected.includes("health")) { - await sleep(1000); - try { - await healthCommand({ json: false, timeoutMs: 10_000 }, runtime); - } catch (err) { - runtime.error(formatHealthCheckFailure(err)); - note( - [ - "Docs:", - "https://docs.clawd.bot/gateway/health", - "https://docs.clawd.bot/gateway/troubleshooting", - ].join("\n"), - "Health check help", - ); + note( + [ + `Web UI: ${links.httpUrl}`, + `Gateway WS: ${links.wsUrl}`, + gatewayStatusLine, + "Docs: https://docs.clawd.bot/web/control-ui", + ].join("\n"), + "Control UI", + ); + + outro("Configure complete."); + } catch (err) { + if (err instanceof WizardCancelledError) { + runtime.exit(0); + return; } + throw err; } - - const controlUiAssets = await ensureControlUiAssetsBuilt(runtime); - if (!controlUiAssets.ok && controlUiAssets.message) { - 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( - [ - `Web UI: ${links.httpUrl}`, - `Gateway WS: ${links.wsUrl}`, - gatewayStatusLine, - "Docs: https://docs.clawd.bot/web/control-ui", - ].join("\n"), - "Control UI", - ); - - outro("Configure complete."); } export async function configureCommand(runtime: RuntimeEnv = defaultRuntime) { diff --git a/src/commands/onboard-auth.test.ts b/src/commands/onboard-auth.test.ts index d210eedbd..495ae029d 100644 --- a/src/commands/onboard-auth.test.ts +++ b/src/commands/onboard-auth.test.ts @@ -177,7 +177,7 @@ describe("applyMinimaxApiConfig", () => { ).toMatchObject({ alias: "Minimax", params: { custom: "value" } }); }); - it("replaces existing minimax provider entirely", () => { + it("merges existing minimax provider models", () => { const cfg = applyMinimaxApiConfig({ models: { providers: { @@ -204,7 +204,11 @@ describe("applyMinimaxApiConfig", () => { "https://api.minimax.io/anthropic", ); expect(cfg.models?.providers?.minimax?.api).toBe("anthropic-messages"); - expect(cfg.models?.providers?.minimax?.models[0]?.id).toBe("MiniMax-M2.1"); + expect(cfg.models?.providers?.minimax?.apiKey).toBe("old-key"); + expect(cfg.models?.providers?.minimax?.models.map((m) => m.id)).toEqual([ + "old-model", + "MiniMax-M2.1", + ]); }); it("preserves other providers when adding minimax", () => { diff --git a/src/commands/onboard-auth.ts b/src/commands/onboard-auth.ts index 43f16ac2a..90406d145 100644 --- a/src/commands/onboard-auth.ts +++ b/src/commands/onboard-auth.ts @@ -334,11 +334,27 @@ export function applyMinimaxApiProviderConfig( modelId: string = "MiniMax-M2.1", ): ClawdbotConfig { const providers = { ...cfg.models?.providers }; + const existingProvider = providers.minimax; + const existingModels = Array.isArray(existingProvider?.models) + ? existingProvider.models + : []; + const apiModel = buildMinimaxApiModelDefinition(modelId); + const hasApiModel = existingModels.some((model) => model.id === modelId); + const mergedModels = hasApiModel + ? existingModels + : [...existingModels, apiModel]; + const { apiKey: existingApiKey, ...existingProviderRest } = + (existingProvider ?? {}) as Record as { apiKey?: string }; + const resolvedApiKey = + typeof existingApiKey === "string" ? existingApiKey : undefined; + const normalizedApiKey = + resolvedApiKey?.trim() === "minimax" ? "" : resolvedApiKey; providers.minimax = { + ...existingProviderRest, baseUrl: MINIMAX_API_BASE_URL, - // apiKey omitted: resolved via MINIMAX_API_KEY env var or auth profile by default. api: "anthropic-messages", - models: [buildMinimaxApiModelDefinition(modelId)], + ...(normalizedApiKey?.trim() ? { apiKey: normalizedApiKey } : {}), + models: mergedModels.length > 0 ? mergedModels : [apiModel], }; const models = { ...cfg.agents?.defaults?.models };