From aecb2fc62dd942d207fe233dde6e879a19f6434c Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:14:42 -0500 Subject: [PATCH] feat: simplify provider plugin install input --- src/commands/onboard-search.test.ts | 136 ++++++++++++++++ src/commands/onboard-search.ts | 16 +- .../onboarding/plugin-install.test.ts | 122 +++++++++++--- src/commands/onboarding/plugin-install.ts | 153 ++++++++++++++---- 4 files changed, 367 insertions(+), 60 deletions(-) diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 59bae06c3d6..88c82146308 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -182,6 +182,72 @@ describe("setupSearch", () => { ); }); + it("uses the updated configure-or-install action label", async () => { + vi.stubEnv("BRAVE_API_KEY", "BSA-test-key"); + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [ + { + pluginId: "tavily-search", + provider: { + id: "tavily", + name: "Tavily Search", + description: "Plugin search", + isAvailable: () => true, + search: async () => ({ content: "ok" }), + }, + }, + ], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: undefined, + configUiHints: undefined, + }, + ], + typedHooks: [], + }); + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "brave", + enabled: true, + }, + }, + }, + plugins: { + entries: { + "tavily-search": { + enabled: true, + config: { apiKey: "tvly-installed-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ + actionValue: "__skip__", + selectValue: "__skip__", + }); + + await setupSearch(cfg, runtime, prompter); + + expect(prompter.select).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Web search setup", + options: expect.arrayContaining([ + expect.objectContaining({ + value: "__configure_provider__", + label: "Configure or install a provider", + }), + ]), + }), + ); + }); + it("passes workspaceDir when resolving plugin providers for setup", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ selectValue: "__skip__" }); @@ -286,6 +352,76 @@ describe("setupSearch", () => { }); }); + it("keeps the install option visible in configure-provider flow even when Tavily is already loaded", async () => { + vi.stubEnv("BRAVE_API_KEY", "BSA-test-key"); + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [ + { + pluginId: "tavily-search", + provider: { + id: "tavily", + name: "Tavily Search", + description: "Plugin search", + isAvailable: () => true, + search: async () => ({ content: "ok" }), + }, + }, + ], + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "External Tavily plugin", + origin: "workspace", + source: "/tmp/tavily-search", + configJsonSchema: undefined, + configUiHints: undefined, + }, + ], + typedHooks: [], + }); + + const cfg: OpenClawConfig = { + tools: { + web: { + search: { + provider: "brave", + enabled: true, + }, + }, + }, + plugins: { + entries: { + "tavily-search": { + enabled: true, + config: { apiKey: "tvly-installed-key" }, + }, + }, + }, + }; + const { prompter } = createPrompter({ + actionValue: "__configure_provider__", + selectValue: "__skip__", + }); + + await setupSearch(cfg, runtime, prompter); + + const configurePickerCall = (prompter.select as ReturnType).mock.calls.find( + (call) => call[0]?.message === "Choose provider to configure", + ); + expect(configurePickerCall?.[0]).toEqual( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ + value: "__install_plugin__", + label: "Install another provider plugin", + hint: "Add another supported web search plugin", + }), + ]), + }), + ); + }); + it("sets provider and key for perplexity", async () => { const cfg: OpenClawConfig = {}; const { prompter } = createPrompter({ diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 14659f89577..56faf27daf4 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -133,9 +133,12 @@ export function resolveInstallableSearchProviderPlugins( const loadedPluginProviderIds = new Set( providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value), ); - return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter( - (entry) => !loadedPluginProviderIds.has(entry.providerId), - ); + return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((entry) => ({ + ...entry, + description: loadedPluginProviderIds.has(entry.providerId) + ? `${entry.description} Already installed.` + : entry.description, + })); } function normalizePluginConfigObject(value: unknown): Record { @@ -935,7 +938,7 @@ export function buildSearchProviderPickerModel( { value: SEARCH_PROVIDER_INSTALL_SENTINEL as const, label: "Install another provider plugin", - hint: "Add Tavily or another supported web search plugin", + hint: "Add another supported web search plugin", }, ] : []), @@ -1262,8 +1265,9 @@ export async function promptSearchProviderFlow(params: { configureValue: SEARCH_PROVIDER_CONFIGURE_SENTINEL, switchValue: SEARCH_PROVIDER_SWITCH_ACTIVE_SENTINEL, skipValue: SEARCH_PROVIDER_SKIP_SENTINEL, - configureLabel: "Configure a provider", - configureHint: "Update keys or plugin settings without changing the active provider", + configureLabel: "Configure or install a provider", + configureHint: + "Update keys, plugin settings, or install a provider without changing the active provider", switchLabel: "Switch active provider", switchHint: "Change which provider web_search uses right now", skipHint: "Configure later with openclaw configure --section web", diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 2be78d9a6fc..ae79fb37a04 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -95,10 +95,18 @@ function mockRepoLocalPathExists() { async function runInitialValueForChannel(channel: "dev" | "beta") { const runtime = makeRuntime(); - const select = vi.fn((async () => "skip" as T) as WizardPrompter["select"]); - const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] }); + const select = vi.fn((async () => "enter" as T) as WizardPrompter["select"]); + const text = vi.fn(async ({ initialValue }: { initialValue?: string }) => initialValue ?? ""); + const prompter = makePrompter({ + select: select as unknown as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + }); const cfg: OpenClawConfig = { update: { channel } }; mockRepoLocalPathExists(); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "nope", + }); await ensureOnboardingPluginInstalled({ cfg, @@ -107,7 +115,7 @@ async function runInitialValueForChannel(channel: "dev" | "beta") { runtime, }); - const call = select.mock.calls[0]; + const call = text.mock.calls[0]; return call?.[0]?.initialValue; } @@ -123,7 +131,8 @@ describe("ensureOnboardingPluginInstalled", () => { it("installs from npm and enables the plugin", async () => { const runtime = makeRuntime(); const prompter = makePrompter({ - select: vi.fn(async () => "npm") as WizardPrompter["select"], + select: vi.fn(async () => "enter") as WizardPrompter["select"], + text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"], }); const cfg: OpenClawConfig = { plugins: { allow: ["other"] } }; vi.mocked(fs.existsSync).mockReturnValue(false); @@ -154,8 +163,11 @@ describe("ensureOnboardingPluginInstalled", () => { it("uses local path when selected", async () => { const runtime = makeRuntime(); + const note = vi.fn(async () => {}); const prompter = makePrompter({ - select: vi.fn(async () => "local") as WizardPrompter["select"], + select: vi.fn(async () => "enter") as WizardPrompter["select"], + text: vi.fn(async () => "extensions/zalo") as WizardPrompter["text"], + note, }); const cfg: OpenClawConfig = {}; mockRepoLocalPathExists(); @@ -169,22 +181,35 @@ describe("ensureOnboardingPluginInstalled", () => { expectPluginLoadedFromLocalPath(result); expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); + expect(note).toHaveBeenCalledWith( + `Using existing local plugin at ${path.resolve(process.cwd(), "extensions/zalo")}.\nNo download needed.`, + "Plugin install", + ); }); it("defaults to local on dev channel when local path exists", async () => { - expect(await runInitialValueForChannel("dev")).toBe("local"); + expect(await runInitialValueForChannel("dev")).toBe( + path.resolve(process.cwd(), "extensions/zalo"), + ); }); it("defaults to npm on beta channel even when local path exists", async () => { - expect(await runInitialValueForChannel("beta")).toBe("npm"); + expect(await runInitialValueForChannel("beta")).toBe("@openclaw/zalo"); }); it("defaults to bundled local path on beta channel when available", async () => { const runtime = makeRuntime(); - const select = vi.fn((async () => "skip" as T) as WizardPrompter["select"]); - const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] }); + const select = vi.fn((async () => "enter" as T) as WizardPrompter["select"]); + const text = vi.fn(async ({ initialValue }: { initialValue?: string }) => initialValue ?? ""); + const prompter = makePrompter({ + select: select as unknown as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + }); const cfg: OpenClawConfig = { update: { channel: "beta" } }; - vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.existsSync).mockImplementation((value) => { + const raw = String(value); + return raw === "/opt/openclaw/extensions/zalo" || raw.endsWith(`${path.sep}.git`); + }); resolveBundledPluginSources.mockReturnValue( new Map([ [ @@ -205,15 +230,9 @@ describe("ensureOnboardingPluginInstalled", () => { runtime, }); - expect(select).toHaveBeenCalledWith( + expect(text).toHaveBeenCalledWith( expect.objectContaining({ - initialValue: "local", - options: expect.arrayContaining([ - expect.objectContaining({ - value: "local", - hint: "/opt/openclaw/extensions/zalo", - }), - ]), + initialValue: "/opt/openclaw/extensions/zalo", }), ); }); @@ -223,7 +242,8 @@ describe("ensureOnboardingPluginInstalled", () => { const note = vi.fn(async () => {}); const confirm = vi.fn(async () => true); const prompter = makePrompter({ - select: vi.fn(async () => "npm") as WizardPrompter["select"], + select: vi.fn(async () => "enter") as WizardPrompter["select"], + text: vi.fn(async () => "@openclaw/zalo") as WizardPrompter["text"], note, confirm, }); @@ -242,10 +262,72 @@ describe("ensureOnboardingPluginInstalled", () => { }); expectPluginLoadedFromLocalPath(result); - expect(note).toHaveBeenCalled(); + expect(note).toHaveBeenCalledWith(`Failed to install @openclaw/zalo: nope`, "Plugin install"); + expect(note).toHaveBeenCalledWith( + `Using existing local plugin at ${path.resolve(process.cwd(), "extensions/zalo")}.\nNo download needed.`, + "Plugin install", + ); expect(runtime.error).not.toHaveBeenCalled(); }); + it("re-prompts when a path-like input does not exist", async () => { + const runtime = makeRuntime(); + const note = vi.fn(async () => {}); + const text = vi + .fn() + .mockResolvedValueOnce("./missing-plugin") + .mockResolvedValueOnce("@openclaw/zalo"); + const prompter = makePrompter({ + select: vi.fn(async () => "enter") as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + note, + }); + const cfg: OpenClawConfig = {}; + vi.mocked(fs.existsSync).mockReturnValue(false); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "zalo", + targetDir: "/tmp/zalo", + extensions: [], + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + expect(result.installed).toBe(true); + expect(note).toHaveBeenCalledWith("Path not found: ./missing-plugin", "Plugin install"); + expect(text).toHaveBeenCalledTimes(2); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ spec: "@openclaw/zalo" }), + ); + }); + + it("returns unchanged config when install is skipped", async () => { + const runtime = makeRuntime(); + const text = vi.fn(); + const prompter = makePrompter({ + select: vi.fn(async () => "skip") as WizardPrompter["select"], + text: text as unknown as WizardPrompter["text"], + }); + const cfg: OpenClawConfig = {}; + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + expect(result.installed).toBe(false); + expect(result.cfg).toBe(cfg); + expect(text).not.toHaveBeenCalled(); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + it("clears discovery cache before reloading the onboarding plugin registry", () => { const runtime = makeRuntime(); const cfg: OpenClawConfig = {}; diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index 37785026ec5..dc9158cb0a4 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -17,7 +17,7 @@ import { createPluginLoaderLogger } from "../../plugins/logger.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; -type InstallChoice = "npm" | "local" | "skip"; +type InstallChoice = "enter" | "skip"; export type InstallablePluginCatalogEntry = { id: string; @@ -75,6 +75,31 @@ function resolveLocalPath( return null; } +function resolveExistingPath( + rawValue: string, + workspaceDir: string | undefined, + allowLocal: boolean, +): string | null { + if (!allowLocal) { + return null; + } + const raw = rawValue.trim(); + if (!raw) { + return null; + } + const candidates = new Set(); + candidates.add(path.resolve(process.cwd(), raw)); + if (workspaceDir && workspaceDir !== process.cwd()) { + candidates.add(path.resolve(workspaceDir, raw)); + } + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + return null; +} + function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig { const existing = cfg.plugins?.load?.paths ?? []; const merged = Array.from(new Set([...existing, pluginPath])); @@ -93,31 +118,84 @@ function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawCon async function promptInstallChoice(params: { entry: InstallablePluginCatalogEntry; localPath?: string | null; - defaultChoice: InstallChoice; + defaultSource: string; prompter: WizardPrompter; -}): Promise { - const { entry, localPath, prompter, defaultChoice } = params; - const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath - ? [ - { - value: "local", - label: "Use local plugin path", - hint: localPath, - }, - ] - : []; - const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [ - { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, - ...localOptions, - { value: "skip", label: "Skip for now" }, - ]; - const initialValue: InstallChoice = - defaultChoice === "local" && !localPath ? "npm" : defaultChoice; - return await prompter.select({ + workspaceDir?: string; + allowLocal: boolean; +}): Promise { + const { entry, localPath, prompter, defaultSource, workspaceDir, allowLocal } = params; + const action = await prompter.select({ message: `Install ${entry.meta.label} plugin?`, - options, - initialValue, + options: [ + { + value: "enter", + label: "Enter package or local path", + hint: localPath + ? `${entry.install.npmSpec} or ${localPath}` + : `${entry.install.npmSpec} or ./path/to/plugin`, + }, + { value: "skip", label: "Skip for now" }, + ], + initialValue: "enter", }); + + if (action === "skip") { + return null; + } + + while (true) { + const source = ( + await prompter.text({ + message: "Plugin package or local path", + initialValue: defaultSource, + placeholder: localPath + ? `${entry.install.npmSpec} or ${localPath}` + : `${entry.install.npmSpec} or ./path/to/plugin`, + validate: (value) => + value.trim().length > 0 ? undefined : "Enter a package or local path", + }) + ).trim(); + + const existingPath = resolveExistingPath(source, workspaceDir, allowLocal); + if (existingPath) { + return existingPath; + } + + const looksLikePath = + source.startsWith(".") || + source.startsWith("/") || + source.startsWith("~") || + source.includes("/") || + source.includes("\\"); + if (looksLikePath) { + await prompter.note(`Path not found: ${source}`, "Plugin install"); + continue; + } + + return source; + } +} + +function resolveInstallDefaultSource(params: { + entry: InstallablePluginCatalogEntry; + defaultChoice: "npm" | "local"; + localPath?: string | null; +}): string { + const { entry, defaultChoice, localPath } = params; + if (defaultChoice === "local" && localPath) { + return localPath; + } + return entry.install.npmSpec; +} + +function isLikelyLocalPath(source: string): boolean { + return ( + source.startsWith(".") || + source.startsWith("/") || + source.startsWith("~") || + source.includes("/") || + source.includes("\\") + ); } function resolveInstallDefaultChoice(params: { @@ -172,25 +250,31 @@ export async function ensureOnboardingPluginInstalled(params: { localPath, bundledLocalPath, }); - const choice = await promptInstallChoice({ + const source = await promptInstallChoice({ entry, localPath, - defaultChoice, + defaultSource: resolveInstallDefaultSource({ entry, defaultChoice, localPath }), prompter, + workspaceDir, + allowLocal, }); - if (choice === "skip") { + if (!source) { return { cfg: next, installed: false }; } - if (choice === "local" && localPath) { - next = addPluginLoadPath(next, localPath); + if (isLikelyLocalPath(source)) { + await prompter.note( + [`Using existing local plugin at ${source}.`, "No download needed."].join("\n"), + "Plugin install", + ); + next = addPluginLoadPath(next, source); next = enablePluginInConfig(next, entry.id).config; return { cfg: next, installed: true }; } const result = await installPluginFromNpmSpec({ - spec: entry.install.npmSpec, + spec: source, logger: { info: (msg) => runtime.log?.(msg), warn: (msg) => runtime.log?.(msg), @@ -202,7 +286,7 @@ export async function ensureOnboardingPluginInstalled(params: { next = recordPluginInstall(next, { pluginId: result.pluginId, source: "npm", - spec: entry.install.npmSpec, + spec: source, installPath: result.targetDir, version: result.version, ...buildNpmResolutionInstallFields(result.npmResolution), @@ -210,10 +294,7 @@ export async function ensureOnboardingPluginInstalled(params: { return { cfg: next, installed: true }; } - await prompter.note( - `Failed to install ${entry.install.npmSpec}: ${result.error}`, - "Plugin install", - ); + await prompter.note(`Failed to install ${source}: ${result.error}`, "Plugin install"); if (localPath) { const fallback = await prompter.confirm({ @@ -221,6 +302,10 @@ export async function ensureOnboardingPluginInstalled(params: { initialValue: true, }); if (fallback) { + await prompter.note( + [`Using existing local plugin at ${localPath}.`, "No download needed."].join("\n"), + "Plugin install", + ); next = addPluginLoadPath(next, localPath); next = enablePluginInConfig(next, entry.id).config; return { cfg: next, installed: true };