feat: simplify provider plugin install input

This commit is contained in:
Tak Hoffman
2026-03-12 09:14:42 -05:00
parent 5d0012471c
commit aecb2fc62d
4 changed files with 367 additions and 60 deletions

View File

@@ -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<typeof vi.fn>).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({

View File

@@ -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<string, unknown> {
@@ -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",

View File

@@ -95,10 +95,18 @@ function mockRepoLocalPathExists() {
async function runInitialValueForChannel(channel: "dev" | "beta") {
const runtime = makeRuntime();
const select = vi.fn((async <T extends string>() => "skip" as T) as WizardPrompter["select"]);
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
const select = vi.fn((async <T extends string>() => "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 <T extends string>() => "skip" as T) as WizardPrompter["select"]);
const prompter = makePrompter({ select: select as unknown as WizardPrompter["select"] });
const select = vi.fn((async <T extends string>() => "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 = {};

View File

@@ -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<string>();
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<InstallChoice> {
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<InstallChoice>({
workspaceDir?: string;
allowLocal: boolean;
}): Promise<string | null> {
const { entry, localPath, prompter, defaultSource, workspaceDir, allowLocal } = params;
const action = await prompter.select<InstallChoice>({
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 };