// Channel setup flow configures channels, auth, and workspace bindings. import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listActiveChannelSetupPlugins } from "../channels/plugins/setup-registry.js"; import type { ChannelOnboardingPostWriteHook, ChannelSetupConfiguredResult, ChannelSetupPlugin, ChannelSetupResult, ChannelSetupStatus, ChannelSetupWizardAdapter, SetupChannelsOptions, } from "../channels/plugins/setup-wizard-types.js"; import { formatCliCommand } from "../cli/command-format.js"; import { normalizeExternalChannelSetupConfig } from "../commands/channel-setup/config-compatibility.js"; import { resolveChannelSetupEntries, shouldShowChannelInSetup, } from "../commands/channel-setup/discovery.js"; import { loadChannelSetupPluginRegistrySnapshotForChannel } from "../commands/channel-setup/plugin-install.js"; import { resolveChannelSetupWizardAdapterForPlugin } from "../commands/channel-setup/registry.js"; import { getTrustedChannelPluginCatalogEntry, listTrustedChannelPluginCatalogEntries, } from "../commands/channel-setup/trusted-catalog.js"; import { withCommandPluginMetadata } from "../commands/config-validation.js"; import { hasConfiguredCommandOwners } from "../commands/doctor-command-owner.js"; import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import { createConfigIO } from "../config/io.factory.js"; import { createManagedRuntimeEnvBase } from "../config/io.read-helpers.js"; import { formatConfigIssueSummary } from "../config/issue-format.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { resolveBundledPluginSources } from "../plugins/bundled-sources.js"; import { enablePluginWithCapabilityConsent } from "../plugins/enable.js"; import { getPluginCache, withPluginCache } from "../plugins/plugin-cache.js"; import { withPluginLifecycleLease } from "../plugins/plugin-lifecycle-lease.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { t } from "../wizard/i18n/index.js"; import { createPluginCapabilityConsentPrompter } from "../wizard/plugin-capability-consent.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { ensureChannelSetupPluginInstalledWithNavigation as runPluginInstallWithNavigation, runScopedChannelStep as runNavigationScope, } from "./channel-setup-navigation.js"; import { formatAccountLabel, maybeConfigureCommandOwner, maybeConfigureDmPolicies, promptConfiguredAction, promptRemovalAccountId, } from "./channel-setup.prompts.js"; import { collectChannelStatus, findBundledSourceForCatalogChannel, noteChannelPrimer, resolveCatalogChannelSelectionHint, resolveChannelSelectionNoteLines, resolveChannelSetupSelectionContributions, resolveChannelSetupWorkspaceDir, resolveQuickstartDefault, } from "./channel-setup.status.js"; export function createChannelSetupHooks(params: { runtime: RuntimeEnv; beforePersistentEffect?: () => Promise; }) { const hooks = new Map(); return { onPostWriteHook: (hook: ChannelOnboardingPostWriteHook) => { hooks.set(`${hook.channel}:${hook.accountId}`, hook); }, async runPostWriteHooks(configPath: string) { await runCollectedChannelOnboardingPostWriteHooks({ hooks: [...hooks.values()], configPath, runtime: params.runtime, ...(params.beforePersistentEffect ? { beforePersistentEffect: params.beforePersistentEffect } : {}), }); hooks.clear(); }, }; } export async function runCollectedChannelOnboardingPostWriteHooks(params: { hooks: ChannelOnboardingPostWriteHook[]; configPath: string; runtime: RuntimeEnv; beforePersistentEffect?: () => Promise; }): Promise { if (params.hooks.length === 0) { return; } // Writer receipts bind the file even if config selection changes after commit. // Hooks execute against fresh runtime values; persisted config may contain env refs. const { snapshot, pluginMetadataSnapshot } = await createConfigIO({ configPath: params.configPath, env: createManagedRuntimeEnvBase(), observe: false, }).readConfigFileSnapshotWithPluginMetadata({ allowCurrentPluginMetadata: false }); await withCommandPluginMetadata( { config: snapshot.runtimeConfig, snapshot: pluginMetadataSnapshot }, async () => { for (const hook of params.hooks) { await params.beforePersistentEffect?.(); try { if (!snapshot.exists || !snapshot.valid) { const reason = snapshot.exists ? formatConfigIssueSummary(snapshot.issues) : "file not found"; throw new Error( `Saved config is unavailable: ${reason}. Run openclaw doctor --fix, then retry setup.`, ); } await hook.run({ cfg: snapshot.runtimeConfig, runtime: params.runtime }); } catch (err) { const message = formatErrorMessage(err); params.runtime.error( `Channel ${hook.channel} post-setup warning for "${hook.accountId}": ${message}`, ); } } }, ); } export function createChannelOnboardingPostWriteHook(params: { accountId?: string; adapter?: Pick; channel: ChannelChoice; previousCfg: OpenClawConfig; }): ChannelOnboardingPostWriteHook | undefined { if (!params.accountId || !params.adapter?.afterConfigWritten) { return undefined; } return { channel: params.channel, accountId: params.accountId, run: async ({ cfg, runtime }) => await params.adapter?.afterConfigWritten?.({ previousCfg: params.previousCfg, cfg, accountId: params.accountId!, runtime, }), }; } // Channel-specific prompts moved into setup flow adapters. export async function setupChannels( cfg: OpenClawConfig, runtime: RuntimeEnv, prompter: WizardPrompter, options?: SetupChannelsOptions, ): Promise { let next = cfg; const deferStatusUntilSelection = options?.deferStatusUntilSelection === true; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, }; const scopedPluginsById = new Map(); let preparedWorkspaceDir = options?.workspaceDir; const resolveWorkspaceDir = () => (preparedWorkspaceDir ??= resolveChannelSetupWorkspaceDir(next)); const rememberScopedPlugin = (plugin: ChannelSetupPlugin) => { const channel = plugin.id; scopedPluginsById.set(channel, plugin); }; const activePluginsById = new Map(); const rememberActivePlugin = (plugin: ChannelSetupPlugin) => { activePluginsById.set(plugin.id, plugin); return plugin; }; const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelSetupPlugin | undefined => scopedPluginsById.get(channel) ?? activePluginsById.get(channel); const listVisibleInstalledPlugins = (): ChannelSetupPlugin[] => { const merged = new Map(); const registryPlugins = listActiveChannelSetupPlugins().map(rememberActivePlugin); for (const plugin of registryPlugins) { if (shouldShowChannelInSetup(plugin.meta)) { merged.set(plugin.id, plugin); } } for (const plugin of scopedPluginsById.values()) { if (shouldShowChannelInSetup(plugin.meta)) { merged.set(plugin.id, plugin); } } return Array.from(merged.values()); }; const resolveVisibleChannelEntries = () => resolveChannelSetupEntries({ cfg: next, installedPlugins: listVisibleInstalledPlugins(), workspaceDir: resolveWorkspaceDir(), }); const loadScopedChannelPlugin = async ( channel: ChannelChoice, pluginId?: string, setup?: { forceReload?: boolean; forceSetupOnlyChannelPlugins?: boolean; }, ): Promise => { const existing = getVisibleChannelPlugin(channel); if (existing && setup?.forceReload !== true) { return existing; } const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({ cfg: next, runtime, channel, ...(pluginId ? { pluginId } : {}), workspaceDir: resolveWorkspaceDir(), forceSetupOnlyChannelPlugins: setup?.forceSetupOnlyChannelPlugins ?? true, }); const plugin = snapshot.channelSetups.find((entry) => entry.plugin.id === channel)?.plugin ?? snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; if (plugin) { rememberScopedPlugin(plugin); return plugin; } const bundledPlugin = getBundledChannelSetupPlugin(channel); if (bundledPlugin) { rememberScopedPlugin(bundledPlugin); return bundledPlugin; } return undefined; }; const setupCache = getPluginCache(); const enableChannelPluginForSetup = async (channel: ChannelChoice) => await withPluginLifecycleLease({}, async () => { const result = await enablePluginWithCapabilityConsent(next, channel, { workspaceDir: resolveWorkspaceDir(), onCapabilityConsent: createPluginCapabilityConsentPrompter( prompter, options?.beforePersistentEffect, ), }); next = result.config; if (result.enabled) { // Capture the reviewed runtime before another lifecycle operation replaces it. await withPluginCache(setupCache, () => loadScopedChannelPlugin(channel)); } return result; }); const getVisibleSetupFlowAdapter = (channel: ChannelChoice) => { const scopedPlugin = scopedPluginsById.get(channel); if (scopedPlugin) { return resolveChannelSetupWizardAdapterForPlugin(scopedPlugin); } return resolveChannelSetupWizardAdapterForPlugin(getVisibleChannelPlugin(channel)); }; const preloadConfiguredExternalPlugins = async () => { // Keep setup memory bounded by snapshot-loading only configured external plugins. listVisibleInstalledPlugins(); const workspaceDir = resolveWorkspaceDir(); const preloadTasks: Promise[] = []; // Security: keep trusted workspace overrides eligible during setup while // falling back from untrusted workspace shadows to the non-workspace entry. for (const entry of listTrustedChannelPluginCatalogEntries({ cfg: next, workspaceDir })) { const channel = entry.id as ChannelChoice; if (getVisibleChannelPlugin(channel)) { continue; } const explicitlyEnabled = next.plugins?.entries?.[entry.pluginId ?? channel]?.enabled === true; if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { continue; } preloadTasks.push(loadScopedChannelPlugin(channel, entry.pluginId)); } await Promise.all(preloadTasks); }; if (!deferStatusUntilSelection) { await preloadConfiguredExternalPlugins(); } const statusSummary = deferStatusUntilSelection ? { statusByChannel: new Map(), statusLines: [] } : await withCommandPluginMetadata({ config: next, workspaceDir: resolveWorkspaceDir() }, () => collectChannelStatus({ cfg: next, workspaceDir: resolveWorkspaceDir(), options, accountOverrides, installedPlugins: listVisibleInstalledPlugins(), resolveAdapter: getVisibleSetupFlowAdapter, }), ); const { statusByChannel, statusLines } = statusSummary; if (!options?.skipStatusNote && statusLines.length > 0) { await prompter.note(statusLines.join("\n"), t("wizard.channels.statusTitle")); } const targetedChannel = options?.finishAfterInitialSelection && options.initialSelection?.length === 1 ? options.initialSelection[0] : undefined; const shouldConfigure = options?.skipConfirm || targetedChannel ? true : await prompter.confirm({ message: t("wizard.channels.setupConfirm"), initialValue: true, }); if (!shouldConfigure) { return cfg; } const primerChannels = resolveVisibleChannelEntries().entries.map((entry) => ({ id: entry.id, label: entry.meta.label, blurb: entry.meta.blurb, })); await noteChannelPrimer(prompter, primerChannels); const quickstartDefault = options?.initialSelection?.[0] ?? (deferStatusUntilSelection ? undefined : resolveQuickstartDefault(statusByChannel)); const shouldPromptAccountIds = options?.promptAccountIds === true; const accountIdsByChannel = new Map(); const recordAccount = (channel: ChannelChoice, accountId: string) => { options?.onAccountId?.(channel, accountId); const adapter = getVisibleSetupFlowAdapter(channel); adapter?.onAccountRecorded?.(accountId, options); accountIdsByChannel.set(channel, accountId); }; const selection: ChannelChoice[] = []; let finishSetupRequested = false; const addSelection = (channel: ChannelChoice) => { if (!selection.includes(channel)) { selection.push(channel); } }; const resolveConfigDisabledHint = (channel: ChannelChoice): string | undefined => { if (next.plugins?.enabled === false) { return "plugins disabled"; } if (next.plugins?.entries?.[channel]?.enabled === false) { return "plugin disabled"; } if ( typeof (next.channels as Record | undefined)?.[channel] ?.enabled === "boolean" ) { return (next.channels as Record)[channel]?.enabled === false ? "disabled" : undefined; } return undefined; }; const resolveAccountDisabledHint = ( channel: ChannelChoice, accountId?: string, ): string | undefined => { const plugin = getVisibleChannelPlugin(channel); if (!plugin) { return undefined; } const account = plugin.config.resolveAccount( next, accountId ?? resolveChannelDefaultAccountId({ plugin, cfg: next }), ); let enabled: boolean | undefined; if (plugin.config.isEnabled) { enabled = plugin.config.isEnabled(account, next); } else if (typeof (account as { enabled?: boolean })?.enabled === "boolean") { enabled = (account as { enabled?: boolean }).enabled; } return enabled === false ? "disabled" : undefined; }; const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { const configDisabledHint = resolveConfigDisabledHint(channel); return configDisabledHint || deferStatusUntilSelection ? configDisabledHint : resolveAccountDisabledHint(channel); }; const getChannelEntries = () => { const resolved = resolveVisibleChannelEntries(); return { entries: resolved.entries, catalogById: resolved.installableCatalogById, installedCatalogById: resolved.installedCatalogById, }; }; // Decorates the runtime status map with synthetic `selectionHint` entries for // installable catalog channels (e.g. WeCom shipped via npm). In QuickStart we // run with `deferStatusUntilSelection`, which leaves `statusByChannel` empty // until the user picks a channel — without this overlay the selection menu // would render those options without any "download from " hint. // // Bundled channels (Signal / Tlon / Twitch / Slack ...) reach this code path // too whenever their plugin is not yet enabled, because they share the same // "installable catalog" bucket. For those we must NOT show "download from // " — the plugin already lives under `extensions/` and the // hint would mislead users into thinking the plugin is missing. const buildStatusByChannelForSelection = ( catalogById: ReturnType["catalogById"], ): Map => { const decorated = new Map(statusByChannel); if (catalogById.size === 0) { return decorated; } const bundledSources = resolveBundledPluginSources({ workspaceDir: resolveWorkspaceDir(), }); for (const [channel, entry] of catalogById) { if (decorated.has(channel)) { continue; } const bundledLocalPath = findBundledSourceForCatalogChannel({ bundled: bundledSources, entry })?.localPath ?? null; decorated.set(channel, { channel, configured: false, statusLines: [], selectionHint: resolveCatalogChannelSelectionHint(entry, { bundledLocalPath }), }); } return decorated; }; const resolveSelectionContributions = () => withCommandPluginMetadata({ config: next, workspaceDir: resolveWorkspaceDir() }, () => { const { entries, catalogById } = getChannelEntries(); return resolveChannelSetupSelectionContributions({ entries, statusByChannel: buildStatusByChannelForSelection(catalogById), resolveDisabledHint, }); }); const refreshStatus = async (channel: ChannelChoice) => await withCommandPluginMetadata( { config: next, workspaceDir: resolveWorkspaceDir() }, async () => { const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { return undefined; } const status = await adapter.getStatus({ cfg: next, options, accountOverrides }); statusByChannel.set(channel, status); return status; }, ); const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise => { const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( t("wizard.channels.disabledDuringSetup", { channel, hint: disabledHint, command: formatCliCommand("openclaw channels add"), }), t("wizard.channels.setupTitle"), ); return false; } const result = await enableChannelPluginForSetup(channel); if (!result.enabled) { await prompter.note( t("wizard.channels.pluginEnableFailed", { channel, reason: result.reason ?? "plugin disabled", command: formatCliCommand("openclaw plugins list"), }), t("wizard.channels.setupTitle"), ); return false; } const plugin = getVisibleChannelPlugin(channel); const adapter = getVisibleSetupFlowAdapter(channel); if (!plugin) { if (adapter) { await prompter.note( t("wizard.channels.pluginMissingRecoverable", { channel, listCommand: formatCliCommand("openclaw plugins list"), enableCommand: formatCliCommand("openclaw plugins enable " + channel), }), t("wizard.channels.setupTitle"), ); await refreshStatus(channel); return true; } await prompter.note( t("wizard.channels.pluginNotAvailable", { channel }), t("wizard.channels.setupTitle"), ); return false; } await refreshStatus(channel); return true; }; const applySetupResult = async (channel: ChannelChoice, result: ChannelSetupResult) => { const previousCfg = next; next = normalizeExternalChannelSetupConfig({ cfg: result.cfg, channel }); if (result.completion === "paused") { // Persist partial setup state, but do not run configured-account hooks, // routing, or DM policy prompts until setup actually completes. finishSetupRequested = true; return; } const plugin = getVisibleChannelPlugin(channel); if (plugin) { options?.onResolvedPlugin?.(channel, plugin); } const adapter = getVisibleSetupFlowAdapter(channel); if (result.accountId) { recordAccount(channel, result.accountId); const postWriteHook = createChannelOnboardingPostWriteHook({ accountId: result.accountId, adapter, channel, previousCfg, }); if (postWriteHook) { if (!options?.onPostWriteHook) { throw new Error( `Channel setup internal error: ${channel} produced a post-write hook without a transaction sink.`, ); } options.onPostWriteHook(postWriteHook); } } addSelection(channel); if (channel === targetedChannel) { finishSetupRequested = true; } try { await refreshStatus(channel); } catch (error) { const detail = sanitizeTerminalText(formatErrorMessage(error)); statusByChannel.set(channel, { channel, configured: isChannelConfigured(next, channel), statusLines: [], selectionHint: "status unavailable", }); await prompter.note( `Status unavailable (${detail}).\nRetry: ${formatCliCommand(`openclaw channels status --channel ${channel}`)}`, t("wizard.channels.statusTitle"), ); } }; const applyCustomSetupResult = async ( channel: ChannelChoice, result: ChannelSetupConfiguredResult, ) => { if (result === "skip") { return false; } await applySetupResult(channel, result); return true; }; const runScopedChannelStep = async ( runner: (prompter: WizardPrompter, options: SetupChannelsOptions) => Promise, onPersistentEffect?: () => void, ) => await runNavigationScope({ prompter, options, runner: (scopedPrompter, scopedOptions) => withCommandPluginMetadata({ config: next, workspaceDir: resolveWorkspaceDir() }, () => runner(scopedPrompter, scopedOptions), ), ...(onPersistentEffect ? { onPersistentEffect } : {}), }); const configureChannel = async ( channel: ChannelChoice, setupPrompter: WizardPrompter, setupOptions: SetupChannelsOptions, ) => { if (scopedPluginsById.has(channel)) { await loadScopedChannelPlugin(channel, undefined, { forceReload: true, forceSetupOnlyChannelPlugins: true, }); } const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { await prompter.note( t("wizard.channels.noInteractiveSetup", { channel, command: formatCliCommand(`openclaw channels add --channel ${channel} --help`), }), t("wizard.channels.setupTitle"), ); return; } const result = await adapter.configure({ cfg: next, runtime, prompter: setupPrompter, options: setupOptions, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), }); await applySetupResult(channel, result); }; const handleConfiguredChannel = async ( channel: ChannelChoice, label: string, setupPrompter: WizardPrompter, setupOptions: SetupChannelsOptions, ) => { const plugin = getVisibleChannelPlugin(channel); const adapter = getVisibleSetupFlowAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, runtime, prompter: setupPrompter, options: setupOptions, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), configured: true, label, }); await applyCustomSetupResult(channel, custom); return; } const supportsDisable = Boolean( setupOptions.allowDisable && (plugin?.config.setAccountEnabled || adapter?.disable), ); const supportsDelete = Boolean(setupOptions.allowDisable && plugin?.config.deleteAccount); const action = await promptConfiguredAction({ prompter: setupPrompter, label, supportsDisable, supportsDelete, }); if (action === "skip") { return; } if (action === "update") { await configureChannel(channel, setupPrompter, setupOptions); return; } if (!setupOptions.allowDisable) { return; } if (action === "delete" && !supportsDelete) { await setupPrompter.note( t("wizard.channels.configuredDeleteUnsupported", { label }), t("wizard.channels.removeTitle"), ); return; } const shouldPromptAccount = action === "delete" ? Boolean(plugin?.config.deleteAccount) : Boolean(plugin?.config.setAccountEnabled); const accountId = shouldPromptAccount ? await promptRemovalAccountId({ cfg: next, prompter: setupPrompter, label, channel, plugin, }) : DEFAULT_ACCOUNT_ID; const resolvedAccountId = normalizeAccountId(accountId) ?? (plugin ? resolveChannelDefaultAccountId({ plugin, cfg: next }) : DEFAULT_ACCOUNT_ID); const accountLabel = formatAccountLabel(resolvedAccountId); if (action === "delete") { const confirmed = await setupPrompter.confirm({ message: t("wizard.channels.deleteAccount", { label, account: accountLabel }), initialValue: false, }); if (!confirmed) { return; } if (plugin?.config.deleteAccount) { next = plugin.config.deleteAccount({ cfg: next, accountId: resolvedAccountId }); } await refreshStatus(channel); return; } if (plugin?.config.setAccountEnabled) { next = plugin.config.setAccountEnabled({ cfg: next, accountId: resolvedAccountId, enabled: false, }); } else if (adapter?.disable) { next = adapter.disable(next); } await refreshStatus(channel); }; const ensureChannelSetupPluginInstalledWithNavigation = async ( channel: ChannelChoice, install: Parameters[0]["install"], ) => await withPluginLifecycleLease({}, async () => { const outcome = await runPluginInstallWithNavigation({ install, prompter, options }); if (outcome.status !== "back" && outcome.value.installed) { next = outcome.value.cfg; await withPluginCache(setupCache, () => loadScopedChannelPlugin(channel, outcome.value.pluginId ?? install.entry.pluginId), ); } return outcome; }); const handleChannelChoice = async ( channel: ChannelChoice, ): Promise<"done" | "retry_selection"> => { const cfgBeforeChoice = next; let cfgOnBack = cfgBeforeChoice; const scopedPluginsBeforeChoice = new Map(scopedPluginsById); const statusBeforeChoice = new Map(statusByChannel); const returnToSelection = (): "retry_selection" => { next = cfgOnBack; scopedPluginsById.clear(); for (const [id, plugin] of scopedPluginsBeforeChoice) { scopedPluginsById.set(id, plugin); } statusByChannel.clear(); for (const [id, status] of statusBeforeChoice) { statusByChannel.set(id, status); } return "retry_selection"; }; let deferredDisabledHint = deferStatusUntilSelection ? resolveConfigDisabledHint(channel) : undefined; let resumingDisabledChannel = false; if (deferredDisabledHint) { if (deferredDisabledHint === "disabled") { const resume = channel === targetedChannel ? true : await prompter.confirm({ message: t("wizard.channels.resumeDisabledSetup", { channel }), initialValue: true, }); if (!resume) { return "done"; } const channels = next.channels as | Record | undefined> | undefined; next = { ...next, channels: { ...next.channels, [channel]: { ...channels?.[channel], enabled: true, }, }, } as OpenClawConfig; resumingDisabledChannel = true; } else if (deferredDisabledHint === "plugin disabled") { const resume = channel === targetedChannel ? true : await prompter.confirm({ message: t("wizard.channels.resumeDisabledPluginSetup", { channel }), initialValue: true, }); if (!resume) { return "done"; } const result = await enableChannelPluginForSetup(channel); if (!result.enabled) { await prompter.note( t("wizard.channels.pluginEnableFailed", { channel, reason: result.reason ?? "plugin disabled", command: formatCliCommand("openclaw plugins list"), }), t("wizard.channels.setupTitle"), ); return "done"; } resumingDisabledChannel = true; } else { await prompter.note( t("wizard.channels.disabledBeforeSetup", { channel, hint: deferredDisabledHint, }), t("wizard.channels.setupTitle"), ); return "done"; } deferredDisabledHint = resolveConfigDisabledHint(channel); if (deferredDisabledHint) { await prompter.note( t("wizard.channels.disabledBeforeSetup", { channel, hint: deferredDisabledHint, }), t("wizard.channels.setupTitle"), ); return "done"; } } const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); const installedCatalogEntry = installedCatalogById.get(channel); if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation(channel, { cfg: next, entry: catalogEntry, runtime, workspaceDir, autoConfirmSingleSource: true, }); if (installOutcome.status === "back") { return returnToSelection(); } const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } if (installOutcome.persistentEffectStarted) { cfgOnBack = next; } await refreshStatus(channel); } else if (installedCatalogEntry) { let plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); if (!plugin && installedCatalogEntry.install?.npmSpec) { // The channel is recorded in the user's config (e.g. a stale // `channels.` entry left over from a previous install) but the // plugin runtime cannot be loaded from disk — typically because the // externalized npm package was uninstalled or pruned during an // upgrade. Rather than dead-ending with "plugin not available", fall // back to the catalog-driven install flow so onboard can recover by // reinstalling the official external plugin. // // Preserve the same disabled-config guard used by // `enableBundledPluginForSetup` so an operator-disabled channel // cannot be silently reinstalled/re-enabled through this path. const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }), t("wizard.channels.setupTitle"), ); return "done"; } const workspaceDir = resolveWorkspaceDir(); const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation(channel, { cfg: next, entry: installedCatalogEntry, runtime, workspaceDir, autoConfirmSingleSource: true, }); if (installOutcome.status === "back") { return returnToSelection(); } const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } if (installOutcome.persistentEffectStarted) { cfgOnBack = next; } plugin = getVisibleChannelPlugin(channel); } if (!plugin) { await prompter.note( t("wizard.channels.pluginNotAvailable", { channel }), t("wizard.channels.setupTitle"), ); return "done"; } await refreshStatus(channel); } else { // Neither discovery bucket yielded an entry for this channel. This can // happen when `channels.` in user config carries stale fields (e.g. // `appId`, tokens) left over from a previous install: `isStatically- // ChannelConfigured` returns true, which removes the channel from the // `installableCatalogEntries` bucket, while a missing/pruned plugin on // disk keeps it out of `installedCatalogEntries`. Before falling back // to the bundled-plugin enable path, consult the catalog directly so // users with a stale config entry for an externalized channel (qqbot, // imessage, discord, whatsapp, ...) still get auto-install instead // of a dead-end "plugin not available" note. const fallbackCatalogEntry = getTrustedChannelPluginCatalogEntry(channel, { cfg: next, workspaceDir: resolveWorkspaceDir(), }); if (fallbackCatalogEntry?.install?.npmSpec) { // Preserve the same disabled-config guard used by // `enableBundledPluginForSetup` so an operator-disabled channel // cannot be silently reinstalled/re-enabled through this path. This // mirrors the guard that was previously enforced inside the // bundled-enable fallback. const disabledHint = resolveConfigDisabledHint(channel); if (disabledHint) { await prompter.note( t("wizard.channels.disabledBeforeSetup", { channel, hint: disabledHint }), t("wizard.channels.setupTitle"), ); return "done"; } const workspaceDir = resolveWorkspaceDir(); const installOutcome = await ensureChannelSetupPluginInstalledWithNavigation(channel, { cfg: next, entry: fallbackCatalogEntry, runtime, workspaceDir, autoConfirmSingleSource: true, }); if (installOutcome.status === "back") { return returnToSelection(); } const result = installOutcome.value; next = result.cfg; if (!result.installed) { return "retry_selection"; } if (installOutcome.persistentEffectStarted) { cfgOnBack = next; } await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { return "done"; } } } const plugin = getVisibleChannelPlugin(channel); const adapter = getVisibleSetupFlowAdapter(channel); const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel; const status = statusByChannel.get(channel); const configured = resumingDisabledChannel ? false : (status?.configured ?? false); const configureInteractive = adapter?.configureInteractive; if (configureInteractive) { const outcome = await runScopedChannelStep( async (scopedPrompter, scopedOptions) => await configureInteractive({ cfg: next, runtime, prompter: scopedPrompter, options: scopedOptions, accountOverrides, shouldPromptAccountIds, forceAllowFrom: forceAllowFromChannels.has(channel), configured, label, }), ); if (outcome.status === "back") { return returnToSelection(); } const custom = outcome.value; const applied = await withCommandPluginMetadata( { config: next, workspaceDir: resolveWorkspaceDir() }, () => applyCustomSetupResult(channel, custom), ); if (!applied) { return "done"; } return "done"; } if (configured) { const outcome = await runScopedChannelStep( async (scopedPrompter, scopedOptions) => await handleConfiguredChannel(channel, label, scopedPrompter, scopedOptions), ); if (outcome.status === "back") { return returnToSelection(); } return "done"; } const outcome = await runScopedChannelStep( async (scopedPrompter, scopedOptions) => await configureChannel(channel, scopedPrompter, scopedOptions), ); if (outcome.status === "back") { return returnToSelection(); } return "done"; }; // Targeted setup finishes after success, but Back must re-enter the shared // picker instead of ending the wizard. const targetedSetupReturnedToPicker = targetedChannel ? (await handleChannelChoice(targetedChannel)) === "retry_selection" : false; if (!targetedChannel && options?.quickstartDefaults) { const skipValue = "__skip__" as const; const quickstartInitialValue = options?.initialSelection?.[0] ?? skipValue; while (true) { const contributions = await resolveSelectionContributions(); const choice = await prompter.select({ message: t("wizard.channels.selectQuickstart"), options: [ { value: skipValue, label: t("common.skipForNow"), hint: t("wizard.channels.skipLaterHint", { command: formatCliCommand("openclaw channels add"), }), }, ...contributions.map((contribution) => contribution.option), ], initialValue: quickstartInitialValue, searchable: true, }); if (choice === skipValue) { break; } if ((await handleChannelChoice(choice)) === "done") { break; } } } else if (!targetedChannel || targetedSetupReturnedToPicker) { const doneValue = "__done__" as const; const initialValue = options?.initialSelection?.[0] ?? quickstartDefault; while (true) { const contributions = await resolveSelectionContributions(); const choice = await prompter.select({ message: t("wizard.channels.select"), options: [ ...contributions.map((contribution) => contribution.option), { value: doneValue, label: t("common.finished"), hint: selection.length > 0 ? t("wizard.channels.doneHint") : t("common.skipForNow"), }, ], initialValue, }); if (choice === doneValue) { break; } await handleChannelChoice(choice); if (finishSetupRequested) { break; } } } options?.onSelection?.(selection); const selectedLines = resolveChannelSelectionNoteLines({ cfg: next, workspaceDir: resolveWorkspaceDir(), installedPlugins: listVisibleInstalledPlugins(), selection, }); if (selectedLines.length > 0) { await prompter.note(selectedLines.join("\n"), t("wizard.channels.selectedTitle")); } if (!options?.skipDmPolicyPrompt) { next = await withCommandPluginMetadata( { config: next, workspaceDir: resolveWorkspaceDir() }, () => maybeConfigureDmPolicies({ cfg: next, selection, prompter, accountIdsByChannel, resolveAdapter: getVisibleSetupFlowAdapter, }), ); } if (hasConfiguredCommandOwners(next)) { return next; } const ownerChannels: Array<{ id: ChannelChoice; label: string }> = []; await withCommandPluginMetadata( { config: next, workspaceDir: resolveWorkspaceDir() }, async () => { for (const id of selection) { try { if ( resolveConfigDisabledHint(id) || resolveAccountDisabledHint(id, accountIdsByChannel.get(id)) ) { continue; } // A later setup action can remove or disable an earlier selection. const status = await refreshStatus(id); if (status?.configured) { ownerChannels.push({ id, label: getVisibleChannelPlugin(id)?.meta.label ?? id }); } } catch (error) { await prompter.note( `Status unavailable (${sanitizeTerminalText(formatErrorMessage(error))}).\nRetry: ${formatCliCommand(`openclaw channels status --channel ${id}`)}`, t("wizard.channels.statusTitle"), ); } } }, ); return await maybeConfigureCommandOwner({ cfg: next, channels: ownerChannels, prompter }); } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */