diff --git a/CHANGELOG.md b/CHANGELOG.md index a3947c27233..580409e4127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/channel-setup: auto-skip the redundant "Install \?" confirmation when only one install source (npm or local) exists, show `download from ` hints for installable catalog channels in the picker, and suppress misleading npm hints for already-bundled channels. Fixes #73419. Thanks @sliverp. - BlueBubbles: tighten DM-vs-group routing across the outbound session route (`chat_guid:iMessage;-;...` DMs no longer classified as groups), reaction handling (drop group reactions that arrive without any chat identifier instead of synthesizing a `"group"` literal peerId), inbound `chatGuid` fallback (no longer fall back to the sender's DM chatGuid when resolving a group whose webhook omits chatGuid+chatId+chatIdentifier), and short message id resolution (carry caller chat context so a numeric short id reused after a long group conversation cannot silently resolve to a message in a different chat, with the same cross-chat guard applied to full GUIDs so retries cannot bypass it). Thanks @zqchris. - Agents/approvals: fail restart-interrupted sessions whose transcript tail is still `approval-pending` instead of replaying stale exec approval IDs into the new Gateway process after restart. Fixes #65486. Thanks @mjmai20682068-create. - CLI/Gateway: use method-specific least-privilege scopes for classified CLI Gateway calls while preserving legacy broad scopes for unclassified plugin methods, so read-only commands no longer create admin/write/pairing scope-upgrade prompts. Fixes #68634. Thanks @nightmusher. diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index ca9da3878da..5662a9aacfd 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -558,6 +558,46 @@ describe("ensureChannelSetupPluginInstalled", () => { expect(runtime.error).not.toHaveBeenCalled(); }); + it("skips the install prompt when autoConfirmSingleSource is set and only npm is available", async () => { + const runtime = makeRuntime(); + const { prompter, select } = makeSkipInstallPrompter(); + const cfg: OpenClawConfig = {}; + // npm-only entry (no local path) + const npmOnlyEntry: ChannelPluginCatalogEntry = { + id: "wecom", + pluginId: "wecom", + meta: { + id: "wecom", + label: "WeCom", + selectionLabel: "WeCom", + docsPath: "/channels/wecom", + blurb: "WeCom channel", + }, + install: { + npmSpec: "@openclaw/wecom@2026.4.23", + }, + }; + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "wecom", + installPath: "/tmp/wecom", + }); + vi.mocked(fs.existsSync).mockReturnValue(false); + resolveBundledPluginSources.mockReturnValue(new Map()); + + const result = await ensureChannelSetupPluginInstalled({ + cfg, + entry: npmOnlyEntry, + prompter, + runtime, + autoConfirmSingleSource: true, + }); + + expect(select).not.toHaveBeenCalled(); + expect(result.installed).toBe(true); + expect(result.pluginId).toBe("wecom"); + }); + it("clears discovery cache before reloading the setup plugin registry", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index b6ce164b47d..fd34b6d15da 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -42,6 +42,7 @@ export async function ensureChannelSetupPluginInstalled(params: { runtime: RuntimeEnv; workspaceDir?: string; promptInstall?: boolean; + autoConfirmSingleSource?: boolean; }): Promise { const result = await ensureOnboardingPluginInstalled({ cfg: params.cfg, @@ -50,6 +51,9 @@ export async function ensureChannelSetupPluginInstalled(params: { runtime: params.runtime, workspaceDir: params.workspaceDir, ...(params.promptInstall !== undefined ? { promptInstall: params.promptInstall } : {}), + ...(params.autoConfirmSingleSource !== undefined + ? { autoConfirmSingleSource: params.autoConfirmSingleSource } + : {}), }); return { cfg: result.cfg, diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index dadd4a824dd..ef11d3e08de 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -18,7 +18,9 @@ vi.mock("../cli/plugins-registry-refresh.js", () => ({ })); const resolveBundledPluginSources = vi.hoisted(() => vi.fn(() => new Map())); -const findBundledPluginSourceInMap = vi.hoisted(() => vi.fn(() => null)); +const findBundledPluginSourceInMap = vi.hoisted(() => + vi.fn<(...args: unknown[]) => { localPath: string } | undefined>(() => undefined), +); vi.mock("../plugins/bundled-sources.js", () => ({ resolveBundledPluginSources, findBundledPluginSourceInMap, @@ -126,7 +128,7 @@ describe("ensureOnboardingPluginInstalled", () => { timeoutMs: 300_000, }), ); - expect(update).toHaveBeenCalledWith("Downloading demo-plugin…"); + expect(update).toHaveBeenCalledWith("Downloading"); expect(stop).toHaveBeenCalledWith("Installed WeCom plugin"); expect(buildNpmResolutionInstallFields).toHaveBeenCalledWith(npmResolution); expect(recordPluginInstall).toHaveBeenCalledWith( @@ -481,6 +483,68 @@ describe("ensureOnboardingPluginInstalled", () => { }); }); + it("hides the npm download option for bundled plugins so the menu matches non-npm channels", async () => { + await withTempDir( + { prefix: "openclaw-onboarding-install-bundled-prompt-" }, + async (temp) => { + const bundledDir = path.join(temp, "dist", "extensions", "tlon"); + await fs.mkdir(bundledDir, { recursive: true }); + const realBundledDir = await fs.realpath(bundledDir); + // Both code paths that surface a bundled plugin to the install + // pipeline must agree on the local path: the catalog-driven + // resolver (used when an npm spec is present) and the pluginId + // fallback. We stub both so the prompt sees a stable bundled path. + resolveBundledInstallPlanForCatalogEntry.mockReturnValue({ + bundledSource: { localPath: realBundledDir }, + }); + findBundledPluginSourceInMap.mockReturnValue({ localPath: realBundledDir }); + + let captured: + | { + message: string; + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + initialValue: "npm" | "local" | "skip"; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "tlon", + label: "Tlon", + install: { + npmSpec: "@openclaw/tlon", + defaultChoice: "npm", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + }); + + expect(captured).toBeDefined(); + // "Download from npm (@openclaw/tlon)" must NOT appear: the bundled + // copy is what gets enabled, so the npm hint would only confuse + // users into thinking the plugin is missing. + expect(captured?.options).toEqual([ + { + value: "local", + label: "Use local plugin path", + hint: realBundledDir, + }, + { value: "skip", label: "Skip for now" }, + ]); + expect(captured?.initialValue).toBe("local"); + findBundledPluginSourceInMap.mockReset(); + resolveBundledInstallPlanForCatalogEntry.mockReset(); + }, + ); + }); + it("enables bundled plugins without adding their bundled directory as a local install", async () => { await withTempDir({ prefix: "openclaw-onboarding-install-bundled-record-" }, async (temp) => { const bundledDir = path.join(temp, "dist", "extensions", "discord"); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 339daf4c9e9..612981bfdda 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -286,10 +286,27 @@ function resolveInstallDefaultChoice(params: { async function promptInstallChoice(params: { entry: OnboardingPluginInstallEntry; localPath?: string | null; + bundledLocalPath?: string | null; defaultChoice: InstallChoice; prompter: WizardPrompter; + /** When true and only one real install source (npm *or* local, not both) + * exists, skip the "Install ? / Skip" prompt and resolve directly + * to that source. Useful when the caller already knows the user's intent + * (e.g. they just picked the channel in a previous menu). */ + autoConfirmSingleSource?: boolean; }): Promise { - const npmSpec = resolveNpmSpecForOnboarding(params.entry.install); + const rawNpmSpec = resolveNpmSpecForOnboarding(params.entry.install); + // When the plugin already ships bundled with the host (i.e. lives under + // `extensions/` and is discovered via `resolveBundledPluginSources`), + // the bundled copy is the source of truth: it is version-locked to the + // current host build and is what `defaultChoice` will pick anyway (see + // `resolveInstallDefaultChoice`). Surfacing a "Download from npm (...)" + // option in that case is misleading — it suggests the plugin is missing + // and forces the user to reason about an npm catalog channel that, for + // bundled channels, only exists as a fallback for non-bundled builds. + // Hide the npm option entirely in this scenario so bundled channels like + // Tlon look identical to Twitch / Slack in the menu. + const npmSpec = params.bundledLocalPath ? null : rawNpmSpec; const safeLabel = sanitizeTerminalText(params.entry.label); const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null; const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null; @@ -307,6 +324,20 @@ async function promptInstallChoice(params: { ...(safeLocalPath ? { hint: safeLocalPath } : {}), }); } + + if (params.autoConfirmSingleSource) { + const realSources: InstallChoice[] = []; + if (safeNpmSpec) { + realSources.push("npm"); + } + if (params.localPath) { + realSources.push("local"); + } + if (realSources.length === 1) { + return realSources[0]; + } + } + options.push({ value: "skip", label: "Skip for now" }); const initialValue = @@ -366,6 +397,120 @@ async function applyPluginEnablement(params: { return enableResult; } +type AnimatedProgress = { + setLabel: (label: string) => void; + stop: () => void; +}; + +const PROGRESS_BAR_WIDTH = 16; +const PROGRESS_BAR_TICK_MS = 200; +const PROGRESS_BAR_DURATION_MS = 10_000; +const PROGRESS_BAR_MAX_PERCENT = 99; + +/** + * Maps a verbose install log line (e.g. `Downloading @scope/pkg@1.2.3 from + * ClawHub…`, `Extracting /tmp/…/wecom-…-2026.4.23.tgz…`, `Installing to + * /home/.../plugins/demo…`) to a short verb suitable for a progress label. + * + * Falls back to the raw message when no known verb prefix is recognised so + * that unexpected log lines still surface to the user instead of being + * swallowed. + */ +function shortenInstallLabel(message: string): string { + const trimmed = message.trim(); + // Match a leading verb phrase. Order matters: more specific phrases first. + const patterns: Array<[RegExp, string]> = [ + [/^Downloading\b/i, "Downloading"], + [/^Extracting\b/i, "Extracting"], + [/^Installing\s+to\b/i, "Installing"], + [/^Installing\b/i, "Installing"], + [/^Resolving\b/i, "Resolving"], + [/^Cloning\b/i, "Cloning"], + [/^Verifying\b/i, "Verifying"], + [/^Preparing\b/i, "Preparing"], + [/^Linking\b/i, "Linking"], + [/^Linked\b/i, "Linking"], + [/^Compatibility\b/i, "Resolving"], + [/^ClawHub\b/i, "Resolving"], + ]; + for (const [pattern, label] of patterns) { + if (pattern.test(trimmed)) { + return label; + } + } + return trimmed; +} + +/** + * Wraps a {@link WizardProgress} so the spinner message keeps a steadily + * growing ASCII bar attached to whatever the current install step label is. + * + * The plugin install pipeline only emits coarse `info` log lines, so without + * animation the spinner can sit on the same string for many seconds with no + * visible feedback. We render a deterministic left-to-right filling bar that + * advances linearly over {@link PROGRESS_BAR_DURATION_MS} (default 10s) up to + * {@link PROGRESS_BAR_MAX_PERCENT} (99%). If the install takes longer than the + * preset duration the bar simply stays pinned at 99% — never wrapping back to + * 0% — so the user always sees forward motion and a ceiling that signals + * "almost there, just waiting on the last bit". + * + * The bare label is forwarded to `progress.update` first on every label + * change so callers/tests that assert on the unadorned message continue to + * observe it before any decorated frame is overlaid. + */ +function createAnimatedInstallProgress( + progress: { update: (message: string) => void }, + options: { totalMs?: number } = {}, +): AnimatedProgress { + const totalMs = options.totalMs ?? PROGRESS_BAR_DURATION_MS; + let currentLabel = ""; + const startedAt = Date.now(); + + const computePercent = (): number => { + const elapsed = Date.now() - startedAt; + const raw = Math.floor((elapsed / totalMs) * 100); + return Math.max(0, Math.min(PROGRESS_BAR_MAX_PERCENT, raw)); + }; + + const renderBar = (): string => { + const percent = computePercent(); + const filled = Math.round((percent / 100) * PROGRESS_BAR_WIDTH); + const bar = + "█".repeat(filled) + "░".repeat(Math.max(0, PROGRESS_BAR_WIDTH - filled)); + return `[${bar}] ${percent}%`; + }; + + const decorate = (label: string): string => { + if (!label) { + return renderBar(); + } + return `${label} ${renderBar()}`; + }; + + const timer = setInterval(() => { + if (currentLabel) { + progress.update(decorate(currentLabel)); + } + }, PROGRESS_BAR_TICK_MS); + // Animation is decorative: never let it hold the event loop open if a caller + // forgets to stop us (e.g. an unexpected throw bypasses the `finally`). + if (typeof timer.unref === "function") { + timer.unref(); + } + + return { + setLabel: (label: string) => { + currentLabel = label; + // Always emit the bare label first so existing log/test expectations + // continue to observe the unadorned message before any animation frame. + progress.update(label); + }, + stop: () => { + clearInterval(timer); + }, + }; +} + async function installPluginFromNpmSpecWithProgress(params: { entry: OnboardingPluginInstallEntry; npmSpec: string; @@ -380,12 +525,14 @@ async function installPluginFromNpmSpecWithProgress(params: { > { const safeLabel = sanitizeTerminalText(params.entry.label); const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`); + const animated = createAnimatedInstallProgress(progress); + animated.setLabel("Preparing"); const updateProgress = (message: string) => { - const next = sanitizeTerminalText(message).trim(); - if (!next) { + const sanitized = sanitizeTerminalText(message).trim(); + if (!sanitized) { return; } - progress.update(next); + animated.setLabel(shortenInstallLabel(sanitized)); }; try { @@ -405,6 +552,7 @@ async function installPluginFromNpmSpecWithProgress(params: { }), ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, ); + animated.stop(); if (result.ok) { progress.stop(`Installed ${safeLabel} plugin`); } else { @@ -415,6 +563,7 @@ async function installPluginFromNpmSpecWithProgress(params: { result, }; } catch (error) { + animated.stop(); if (isTimeoutError(error)) { progress.stop(`Install timed out: ${safeLabel}`); return { status: "timed_out" }; @@ -437,6 +586,7 @@ export async function ensureOnboardingPluginInstalled(params: { runtime: RuntimeEnv; workspaceDir?: string; promptInstall?: boolean; + autoConfirmSingleSource?: boolean; }): Promise { const { entry, prompter, runtime, workspaceDir } = params; let next = params.cfg; @@ -463,8 +613,10 @@ export async function ensureOnboardingPluginInstalled(params: { : await promptInstallChoice({ entry, localPath, + bundledLocalPath, defaultChoice, prompter, + autoConfirmSingleSource: params.autoConfirmSingleSource, }); if (choice === "skip") { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index d87cd6db344..0b89292f723 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -132,9 +132,7 @@ function isWorkspaceDerivedPath( const realDir = realpathExistingServicePathDir(dir); const realCwd = realpathServicePathDir(cwd); const realHome = home ? realpathServicePathDir(home) : undefined; - return Boolean( - realDir && realCwd && realHome !== realCwd && isSameOrChildPath(realDir, realCwd), - ); + return Boolean(realDir && realCwd && realHome !== realCwd && isSameOrChildPath(realDir, realCwd)); } function addEnvConfiguredBinDir( diff --git a/src/flows/channel-setup.status.test.ts b/src/flows/channel-setup.status.test.ts index 27ccf3fad59..305378d99d2 100644 --- a/src/flows/channel-setup.status.test.ts +++ b/src/flows/channel-setup.status.test.ts @@ -61,6 +61,17 @@ vi.mock("../config/channel-configured.js", () => ({ ) => isChannelConfigured(cfg, channelId), })); +// Avoid touching the real `extensions/` tree from unit tests. Status +// rendering for installable catalog entries asks `bundled-sources` whether +// a plugin already lives in-tree to decide between +// "install plugin to enable" vs "bundled · enable to use". For these tests +// we want the installable-catalog branch unconditionally, so we stub the +// bundled lookup to "nothing is bundled". +vi.mock("../plugins/bundled-sources.js", () => ({ + resolveBundledPluginSources: () => new Map(), + findBundledPluginSourceInMap: () => undefined, +})); + import { collectChannelStatus, noteChannelPrimer, diff --git a/src/flows/channel-setup.status.ts b/src/flows/channel-setup.status.ts index 919454eb58e..491d088d6fc 100644 --- a/src/flows/channel-setup.status.ts +++ b/src/flows/channel-setup.status.ts @@ -17,6 +17,11 @@ import type { import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + findBundledPluginSourceInMap, + resolveBundledPluginSources, + type BundledPluginSource, +} from "../plugins/bundled-sources.js"; import { formatDocsLink } from "../terminal/links.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import type { WizardPrompter } from "../wizard/prompts.js"; @@ -127,6 +132,63 @@ function formatSetupDisplayMeta(meta: ChannelMeta): ChannelMeta { }; } +/** + * Hint shown next to an installable channel option in the selection menu when + * we don't yet have a runtime-collected status. Mirrors the "configured" / + * "installed" affordance other channels get so users can see "download from + * " before committing to install. + * + * Bundled channels (the plugin lives under `extensions/` in the host + * repo, e.g. Signal / Tlon / Twitch / Slack) are NOT downloaded from npm — + * they ship with the host. Even when their `package.json` declares an + * `npmSpec` (or the catalog falls back to the package name), surfacing + * "download from " misleads users into believing the plugin is + * missing. For bundled channels we suppress the npm hint entirely so the + * menu shows the same neutral "plugin · install" affordance used when no + * npm source is known. + */ +export function resolveCatalogChannelSelectionHint( + entry: { install?: { npmSpec?: string } }, + options?: { bundledLocalPath?: string | null }, +): string { + const npmSpec = entry.install?.npmSpec?.trim(); + if (npmSpec && !options?.bundledLocalPath) { + return `download from ${formatSetupSelectionLabel(npmSpec, npmSpec)}`; + } + return ""; +} + +/** + * Look up the bundled-source entry for a catalog channel, regardless of + * whether the catalog refers to it by `pluginId` or `npmSpec`. We use this + * to detect bundled channels in the selection menu so we can suppress the + * misleading "download from " hint for plugins that already ship + * with the host (Signal / Tlon / Twitch / Slack ...). + */ +export function findBundledSourceForCatalogChannel(params: { + bundled: ReadonlyMap; + entry: { id: string; pluginId?: string; install?: { npmSpec?: string } }; +}): BundledPluginSource | undefined { + const pluginId = params.entry.pluginId?.trim() || params.entry.id.trim(); + if (pluginId) { + const byId = findBundledPluginSourceInMap({ + bundled: params.bundled, + lookup: { kind: "pluginId", value: pluginId }, + }); + if (byId) { + return byId; + } + } + const npmSpec = params.entry.install?.npmSpec?.trim(); + if (npmSpec) { + return findBundledPluginSourceInMap({ + bundled: params.bundled, + lookup: { kind: "npmSpec", value: npmSpec }, + }); + } + return undefined; +} + export async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; @@ -141,6 +203,7 @@ export async function collectChannelStatus(params: { installedPlugins, workspaceDir, }); + const bundledSources = resolveBundledPluginSources({ workspaceDir }); const resolveAdapter = params.resolveAdapter ?? ((channel: ChannelChoice) => @@ -199,15 +262,22 @@ export async function collectChannelStatus(params: { quickstartScore: 0, }; }); - const catalogStatuses = installableCatalogEntries.map((entry) => ({ - channel: entry.id, - configured: false, - statusLines: [ - `${formatSetupSelectionLabel(entry.meta.label, entry.id)}: install plugin to enable`, - ], - selectionHint: "plugin · install", - quickstartScore: 0, - })); + const catalogStatuses = installableCatalogEntries.map((entry) => { + const bundledLocalPath = + findBundledSourceForCatalogChannel({ bundled: bundledSources, entry })?.localPath ?? null; + const isBundled = Boolean(bundledLocalPath); + // For bundled channels we already have the plugin code on disk; the user + // just needs to enable + configure it. Reflect that in the status line so + // it does not read like a fresh "install plugin to enable" download flow. + const statusLabel = isBundled ? "bundled · enable to use" : "install plugin to enable"; + return { + channel: entry.id, + configured: false, + statusLines: [`${formatSetupSelectionLabel(entry.meta.label, entry.id)}: ${statusLabel}`], + selectionHint: resolveCatalogChannelSelectionHint(entry, { bundledLocalPath }), + quickstartScore: 0, + }; + }); const combinedStatuses = [ ...statusEntries, ...fallbackStatuses, diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index dcbc98c2663..ed8c0ba5ad4 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -176,8 +176,10 @@ vi.mock("./channel-setup.prompts.js", () => ({ vi.mock("./channel-setup.status.js", () => ({ collectChannelStatus: (params: Parameters[0]) => collectChannelStatus(params), + findBundledSourceForCatalogChannel: vi.fn(() => undefined), noteChannelPrimer: vi.fn(), noteChannelStatus: vi.fn(), + resolveCatalogChannelSelectionHint: vi.fn(() => "download from "), resolveChannelSelectionNoteLines: vi.fn(() => []), resolveChannelSetupSelectionContributions: vi.fn(() => []), resolveQuickstartDefault: vi.fn(() => undefined), diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index b3d80f484f3..f48c02e6e0d 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -28,6 +28,7 @@ import type { ChannelChoice } from "../commands/onboard-types.js"; import { isChannelConfigured } from "../config/channel-configured.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { resolveBundledPluginSources } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -40,7 +41,9 @@ import { } from "./channel-setup.prompts.js"; import { collectChannelStatus, + findBundledSourceForCatalogChannel, noteChannelPrimer, + resolveCatalogChannelSelectionHint, resolveChannelSelectionNoteLines, resolveChannelSetupSelectionContributions, resolveQuickstartDefault, @@ -315,6 +318,43 @@ export async function setupChannels( }; }; + // 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 refreshStatus = async (channel: ChannelChoice) => { const adapter = getVisibleSetupFlowAdapter(channel); if (!adapter) { @@ -538,6 +578,7 @@ export async function setupChannels( prompter, runtime, workspaceDir, + autoConfirmSingleSource: true, }); next = result.cfg; if (!result.installed) { @@ -591,13 +632,13 @@ export async function setupChannels( if (options?.quickstartDefaults) { while (true) { - const { entries } = getChannelEntries(); + const { entries, catalogById } = getChannelEntries(); const choice = await prompter.select({ message: "Select channel (QuickStart)", options: [ ...resolveChannelSetupSelectionContributions({ entries, - statusByChannel, + statusByChannel: buildStatusByChannelForSelection(catalogById), resolveDisabledHint, }).map((contribution) => contribution.option), { @@ -620,13 +661,13 @@ export async function setupChannels( const doneValue = "__done__" as const; const initialValue = options?.initialSelection?.[0] ?? quickstartDefault; while (true) { - const { entries } = getChannelEntries(); + const { entries, catalogById } = getChannelEntries(); const choice = await prompter.select({ message: "Select a channel", options: [ ...resolveChannelSetupSelectionContributions({ entries, - statusByChannel, + statusByChannel: buildStatusByChannelForSelection(catalogById), resolveDisabledHint, }).map((contribution) => contribution.option), {