diff --git a/src/commands/configure.wizard.test.ts b/src/commands/configure.wizard.test.ts index 2958cef72a1..743dbce2d48 100644 --- a/src/commands/configure.wizard.test.ts +++ b/src/commands/configure.wizard.test.ts @@ -23,6 +23,9 @@ const mocks = vi.hoisted(() => ({ const loadOpenClawPlugins = vi.hoisted(() => vi.fn(() => ({ searchProviders: [] as unknown[], plugins: [] as unknown[] })), ); +const loadPluginManifestRegistry = vi.hoisted(() => + vi.fn(() => ({ plugins: [] as unknown[], diagnostics: [] as unknown[] })), +); const ensureOnboardingPluginInstalled = vi.hoisted(() => vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg, installed: false })), ); @@ -55,6 +58,10 @@ vi.mock("../plugins/loader.js", () => ({ loadOpenClawPlugins, })); +vi.mock("../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry, +})); + vi.mock("./onboarding/plugin-install.js", () => ({ ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry, @@ -144,6 +151,8 @@ describe("runConfigureWizard", () => { mocks.summarizeExistingConfig.mockReset(); loadOpenClawPlugins.mockReset(); loadOpenClawPlugins.mockReturnValue({ searchProviders: [], plugins: [] }); + loadPluginManifestRegistry.mockReset(); + loadPluginManifestRegistry.mockReturnValue({ plugins: [], diagnostics: [] }); ensureOnboardingPluginInstalled.mockReset(); ensureOnboardingPluginInstalled.mockImplementation( async ({ cfg }: { cfg: OpenClawConfig }) => ({ @@ -241,7 +250,6 @@ describe("runConfigureWizard", () => { tools: expect.objectContaining({ web: expect.objectContaining({ search: expect.objectContaining({ - provider: "tavily", enabled: true, }), }), @@ -327,6 +335,7 @@ describe("runConfigureWizard", () => { }), }), ); + expect(mocks.writeConfigFile.mock.calls[0]?.[0]?.tools?.web?.search?.provider).toBeUndefined(); }); it("re-prompts invalid plugin config values during configure", async () => { @@ -436,7 +445,7 @@ describe("runConfigureWizard", () => { ); }); - it("installs a plugin search provider from configure and continues setup", async () => { + it("configures a bundled plugin search provider from configure without the external install step", async () => { loadOpenClawPlugins.mockImplementation(({ config }: { config: OpenClawConfig }) => { const enabled = config.plugins?.entries?.["tavily-search"]?.enabled === true; return enabled @@ -482,6 +491,36 @@ describe("runConfigureWizard", () => { } : { searchProviders: [], plugins: [] }; }); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "Search the web using Tavily.", + origin: "bundled", + source: "/tmp/bundled/tavily-search", + configSchema: { + type: "object", + required: ["apiKey"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + searchDepth: { type: "string", enum: ["basic", "advanced"] }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + searchDepth: { + label: "Search depth", + }, + }, + }, + ], + diagnostics: [], + }); ensureOnboardingPluginInstalled.mockImplementation( async ({ cfg }: { cfg: OpenClawConfig }) => ({ cfg: { @@ -523,7 +562,7 @@ describe("runConfigureWizard", () => { mocks.clackConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(true); mocks.clackSelect.mockImplementation(async (params: { message: string }) => { if (params.message === "Choose active web search provider") { - return "__install_plugin__"; + return "tavily"; } if (params.message.startsWith("Search depth")) { return "advanced"; @@ -541,23 +580,8 @@ describe("runConfigureWizard", () => { }, ); - expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith( - expect.objectContaining({ - entry: expect.objectContaining({ - id: "tavily-search", - install: expect.objectContaining({ - npmSpec: "@openclaw/tavily-search", - localPath: "extensions/tavily-search", - }), - }), - workspaceDir: "/tmp/configure-install-workspace", - }), - ); - expect(reloadOnboardingPluginRegistry).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceDir: "/tmp/configure-install-workspace", - }), - ); + expect(ensureOnboardingPluginInstalled).not.toHaveBeenCalled(); + expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled(); expect(mocks.writeConfigFile).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ diff --git a/src/commands/onboard-search.test.ts b/src/commands/onboard-search.test.ts index 6dc1dc9a735..6fb3a66f7a6 100644 --- a/src/commands/onboard-search.test.ts +++ b/src/commands/onboard-search.test.ts @@ -182,6 +182,66 @@ describe("setupSearch", () => { ); }); + it("shows bundled plugin providers directly in the picker instead of the external install path", async () => { + loadOpenClawPlugins.mockReturnValue({ + searchProviders: [], + plugins: [], + typedHooks: [], + }); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "tavily-search", + name: "Tavily Search", + description: "Search the web using Tavily.", + origin: "bundled", + source: "/tmp/bundled/tavily-search", + configSchema: { + type: "object", + required: ["apiKey"], + properties: { + apiKey: { type: "string", minLength: 1, pattern: "^tvly-\\S+$" }, + }, + }, + configUiHints: { + apiKey: { + label: "Tavily API key", + placeholder: "tvly-...", + sensitive: true, + }, + }, + }, + ], + diagnostics: [], + }); + + const cfg: OpenClawConfig = {}; + const { prompter } = createPrompter({ selectValue: "__skip__" }); + await setupSearch(cfg, runtime, prompter); + + const providerSelectCall = (prompter.select as ReturnType).mock.calls.find( + (call) => call[0]?.message === "Choose active web search provider", + ); + expect(providerSelectCall?.[0]).toEqual( + expect.objectContaining({ + options: expect.arrayContaining([ + expect.objectContaining({ + value: "tavily", + label: "Tavily Search", + hint: expect.stringContaining("Bundled plugin"), + }), + ]), + }), + ); + expect(providerSelectCall?.[0]?.options).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + value: "__install_plugin__", + }), + ]), + ); + }); + it("uses the updated configure-or-install action label", async () => { vi.stubEnv("BRAVE_API_KEY", "BSA-test-key"); loadOpenClawPlugins.mockReturnValue({ diff --git a/src/commands/onboard-search.ts b/src/commands/onboard-search.ts index 763b17273a7..c8097f2238f 100644 --- a/src/commands/onboard-search.ts +++ b/src/commands/onboard-search.ts @@ -133,7 +133,13 @@ export function resolveInstallableSearchProviderPlugins( const loadedPluginProviderIds = new Set( providerEntries.filter((entry) => entry.kind === "plugin").map((entry) => entry.value), ); - return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((entry) => ({ + return SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.filter((entry) => { + const providerEntry = providerEntries.find( + (providerEntry) => + providerEntry.kind === "plugin" && providerEntry.value === entry.providerId, + ); + return providerEntry?.kind !== "plugin" || providerEntry.origin !== "bundled"; + }).map((entry) => ({ ...entry, description: loadedPluginProviderIds.has(entry.providerId) ? `${entry.description} Already installed.` @@ -570,6 +576,39 @@ export async function resolveSearchProviderPickerEntries( pluginEntries = []; } + try { + loadPluginManifestRegistry({ + config, + workspaceDir, + cache: false, + }); + const loadedPluginProviderIds = new Set(pluginEntries.map((entry) => entry.value)); + const bundledManifestEntries = SEARCH_PROVIDER_PLUGIN_INSTALL_CATALOG.map((installEntry) => + buildPluginSearchProviderEntryFromManifest({ + config, + installEntry, + workspaceDir, + }), + ) + .filter( + (entry): entry is PluginSearchProviderEntry => + Boolean(entry) && entry.origin === "bundled" && !loadedPluginProviderIds.has(entry.value), + ) + .map((entry) => { + const pluginConfig = getPluginConfig(config, entry.pluginId); + const validation = validatePluginSearchProviderConfig(entry, pluginConfig); + return { + ...entry, + configured: validation.ok, + }; + }); + pluginEntries = [...pluginEntries, ...bundledManifestEntries].toSorted((left, right) => + left.label.localeCompare(right.label), + ); + } catch { + // Ignore manifest lookup failures and fall back to loaded entries only. + } + return [...builtins, ...pluginEntries]; } @@ -602,13 +641,13 @@ function buildPluginSearchProviderEntryFromManifest(params: { value: params.installEntry.providerId, label: params.installEntry.meta.label, hint: [ - params.installEntry.description || pluginRecord.description || "Plugin-provided web search", + pluginRecord.description || "Plugin-provided web search", formatPluginSourceHint(pluginRecord.origin), ].join(" ยท "), configured: false, pluginId: pluginRecord.id, origin: pluginRecord.origin, - description: params.installEntry.description || pluginRecord.description, + description: pluginRecord.description, docsUrl: undefined, configFieldOrder: undefined, configJsonSchema: pluginRecord.configSchema,