import type { ClawdbotConfig } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, resolveGatewayPort, writeConfigFile, } from "../config/config.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; import { createClackPrompter } from "../wizard/clack-prompter.js"; import { WizardCancelledError } from "../wizard/prompts.js"; import { removeChannelConfigWizard } from "./configure.channels.js"; import { maybeInstallDaemon } from "./configure.daemon.js"; import { promptGatewayConfig } from "./configure.gateway.js"; import { promptAuthConfig } from "./configure.gateway-auth.js"; import type { ChannelsWizardMode, ConfigureWizardParams, WizardSection, } from "./configure.shared.js"; import { CONFIGURE_SECTION_OPTIONS, confirm, intro, outro, select, text, } from "./configure.shared.js"; import { healthCommand } from "./health.js"; import { formatHealthCheckFailure } from "./health-format.js"; import { noteChannelStatus, setupChannels } from "./onboard-channels.js"; import { applyWizardMetadata, DEFAULT_WORKSPACE, ensureWorkspaceAndSessions, guardCancel, printWizardHeader, probeGatewayReachable, resolveControlUiLinks, summarizeExistingConfig, waitForGatewayReachable, } from "./onboard-helpers.js"; import { promptRemoteGatewayConfig } from "./onboard-remote.js"; import { setupSkills } from "./onboard-skills.js"; type ConfigureSectionChoice = WizardSection | "__continue"; async function promptConfigureSection( runtime: RuntimeEnv, hasSelection: boolean, ): Promise { return guardCancel( await select({ message: "Select sections to configure", options: [ ...CONFIGURE_SECTION_OPTIONS, { value: "__continue", label: "Continue", hint: hasSelection ? "Done" : "Skip for now", }, ], initialValue: CONFIGURE_SECTION_OPTIONS[0]?.value, }), runtime, ); } async function promptChannelMode(runtime: RuntimeEnv): Promise { return guardCancel( await select({ message: "Channels", options: [ { value: "configure", label: "Configure/link", hint: "Add/update channels; disable unselected accounts", }, { value: "remove", label: "Remove channel config", hint: "Delete channel tokens/settings from clawdbot.json", }, ], initialValue: "configure", }), runtime, ) as ChannelsWizardMode; } async function promptWebToolsConfig( nextConfig: ClawdbotConfig, runtime: RuntimeEnv, ): Promise { const existingSearch = nextConfig.tools?.web?.search; const existingFetch = nextConfig.tools?.web?.fetch; const hasSearchKey = Boolean(existingSearch?.apiKey); note( [ "Web search lets your agent look things up online using the `web_search` tool.", "It requires a Brave Search API key (you can store it in the config or set BRAVE_API_KEY in the Gateway environment).", "Docs: https://docs.clawd.bot/tools/web", ].join("\n"), "Web search", ); const enableSearch = guardCancel( await confirm({ message: "Enable web_search (Brave Search)?", initialValue: existingSearch?.enabled ?? hasSearchKey, }), runtime, ); let nextSearch = { ...existingSearch, enabled: enableSearch, }; if (enableSearch) { const keyInput = guardCancel( await text({ message: hasSearchKey ? "Brave Search API key (leave blank to keep current or use BRAVE_API_KEY)" : "Brave Search API key (paste it here; leave blank to use BRAVE_API_KEY)", placeholder: hasSearchKey ? "Leave blank to keep current" : "BSA...", }), runtime, ); const key = String(keyInput ?? "").trim(); if (key) { nextSearch = { ...nextSearch, apiKey: key }; } else if (!hasSearchKey) { note( [ "No key stored yet, so web_search will stay unavailable.", "Store a key here or set BRAVE_API_KEY in the Gateway environment.", "Docs: https://docs.clawd.bot/tools/web", ].join("\n"), "Web search", ); } } const enableFetch = guardCancel( await confirm({ message: "Enable web_fetch (keyless HTTP fetch)?", initialValue: existingFetch?.enabled ?? true, }), runtime, ); const nextFetch = { ...existingFetch, enabled: enableFetch, }; return { ...nextConfig, tools: { ...nextConfig.tools, web: { ...nextConfig.tools?.web, search: nextSearch, fetch: nextFetch, }, }, }; } export async function runConfigureWizard( opts: ConfigureWizardParams, runtime: RuntimeEnv = defaultRuntime, ) { try { printWizardHeader(runtime); intro(opts.command === "update" ? "Clawdbot update wizard" : "Clawdbot configure"); const prompter = createClackPrompter(); const snapshot = await readConfigFileSnapshot(); const 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.valid) { outro("Config invalid. Run `clawdbot doctor` to repair it, then re-run configure."); runtime.exit(1); return; } } 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; } let nextConfig = { ...baseConfig }; let didSetGatewayMode = false; if (nextConfig.gateway?.mode !== "local") { nextConfig = { ...nextConfig, gateway: { ...nextConfig.gateway, mode: "local", }, }; didSetGatewayMode = true; } let workspaceDir = nextConfig.agents?.defaults?.workspace ?? baseConfig.agents?.defaults?.workspace ?? DEFAULT_WORKSPACE; let gatewayPort = resolveGatewayPort(baseConfig); let gatewayToken: string | undefined = nextConfig.gateway?.auth?.token ?? baseConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; const persistConfig = async () => { nextConfig = applyWizardMetadata(nextConfig, { command: opts.command, mode, }); await writeConfigFile(nextConfig); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`); }; if (opts.sections) { const selected = opts.sections; if (!selected || selected.length === 0) { outro("No changes selected."); return; } 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, prompter); } if (selected.includes("web")) { nextConfig = await promptWebToolsConfig(nextConfig, runtime); } if (selected.includes("gateway")) { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; gatewayToken = gateway.token; } if (selected.includes("channels")) { await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, skipConfirm: true, skipStatusNote: true, }); } else { nextConfig = await removeChannelConfigWizard(nextConfig, runtime); } } if (selected.includes("skills")) { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); } await persistConfig(); 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, port: gatewayPort, gatewayToken }); } if (selected.includes("health")) { const localLinks = resolveControlUiLinks({ bind: nextConfig.gateway?.bind ?? "loopback", port: gatewayPort, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, }); const remoteUrl = nextConfig.gateway?.remote?.url?.trim(); const wsUrl = nextConfig.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; const token = nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; const password = nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; await waitForGatewayReachable({ url: wsUrl, token, password, deadlineMs: 15_000, }); 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", ); } } } else { let ranSection = false; let didConfigureGateway = false; while (true) { const choice = await promptConfigureSection(runtime, ranSection); if (choice === "__continue") break; ranSection = true; if (choice === "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); await persistConfig(); } if (choice === "model") { nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); await persistConfig(); } if (choice === "web") { nextConfig = await promptWebToolsConfig(nextConfig, runtime); await persistConfig(); } if (choice === "gateway") { const gateway = await promptGatewayConfig(nextConfig, runtime); nextConfig = gateway.config; gatewayPort = gateway.port; gatewayToken = gateway.token; didConfigureGateway = true; await persistConfig(); } if (choice === "channels") { await noteChannelStatus({ cfg: nextConfig, prompter }); const channelMode = await promptChannelMode(runtime); if (channelMode === "configure") { nextConfig = await setupChannels(nextConfig, runtime, prompter, { allowDisable: true, allowSignalInstall: true, skipConfirm: true, skipStatusNote: true, }); } else { nextConfig = await removeChannelConfigWizard(nextConfig, runtime); } await persistConfig(); } if (choice === "skills") { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); await persistConfig(); } if (choice === "daemon") { if (!didConfigureGateway) { 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, port: gatewayPort, gatewayToken, }); } if (choice === "health") { const localLinks = resolveControlUiLinks({ bind: nextConfig.gateway?.bind ?? "loopback", port: gatewayPort, customBindHost: nextConfig.gateway?.customBindHost, basePath: undefined, }); const remoteUrl = nextConfig.gateway?.remote?.url?.trim(); const wsUrl = nextConfig.gateway?.mode === "remote" && remoteUrl ? remoteUrl : localLinks.wsUrl; const token = nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; const password = nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; await waitForGatewayReachable({ url: wsUrl, token, password, deadlineMs: 15_000, }); 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", ); } } } if (!ranSection) { if (didSetGatewayMode) { await persistConfig(); outro("Gateway mode set to local."); return; } outro("No changes selected."); return; } } 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, customBindHost: nextConfig.gateway?.customBindHost, basePath: nextConfig.gateway?.controlUi?.basePath, }); // Try both new and old passwords since gateway may still have old config. const newPassword = nextConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; const oldPassword = baseConfig.gateway?.auth?.password ?? process.env.CLAWDBOT_GATEWAY_PASSWORD; const token = nextConfig.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN; let gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: newPassword, }); // If new password failed and it's different from old password, try old too. if (!gatewayProbe.ok && newPassword !== oldPassword && oldPassword) { gatewayProbe = await probeGatewayReachable({ url: links.wsUrl, token, password: oldPassword, }); } 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."); } catch (err) { if (err instanceof WizardCancelledError) { runtime.exit(0); return; } throw err; } }