From 881f7dc82f7ed10de7cf36028dcd8d11d3555274 Mon Sep 17 00:00:00 2001 From: George Zhang Date: Fri, 3 Apr 2026 17:19:19 -0700 Subject: [PATCH] Plugin SDK: add plugin config TUI prompts to onboard and configure wizards (#60590) (#60590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire uiHints from plugin manifests into the TUI wizard so sandbox/tool plugins get interactive config prompts during openclaw onboard (manual flow) and openclaw configure --section plugins. - Add setup.plugin-config.ts: discovers plugins with non-advanced uiHints, generates type-aware prompts (enum→select, boolean→confirm, array→csv, string/number→text) from jsonSchema + uiHints metadata. - Onboard: new step after Skills, before Hooks (skipped in QuickStart). Only shows plugins with unconfigured fields. - Configure: new 'plugins' section in the section menu. Shows all configurable plugins with configured/total field counts. Closes #60030 --- src/commands/configure.shared.ts | 2 + src/commands/configure.wizard.ts | 19 ++ src/wizard/setup.plugin-config.test.ts | 146 ++++++++++ src/wizard/setup.plugin-config.ts | 372 +++++++++++++++++++++++++ src/wizard/setup.ts | 10 + 5 files changed, 549 insertions(+) create mode 100644 src/wizard/setup.plugin-config.test.ts create mode 100644 src/wizard/setup.plugin-config.ts diff --git a/src/commands/configure.shared.ts b/src/commands/configure.shared.ts index 638bfc62650..92841c98aa2 100644 --- a/src/commands/configure.shared.ts +++ b/src/commands/configure.shared.ts @@ -14,6 +14,7 @@ export const CONFIGURE_WIZARD_SECTIONS = [ "gateway", "daemon", "channels", + "plugins", "skills", "health", ] as const; @@ -64,6 +65,7 @@ export const CONFIGURE_SECTION_OPTIONS: Array<{ label: "Channels", hint: "Link WhatsApp/Telegram/etc and defaults", }, + { value: "plugins", label: "Plugins", hint: "Configure plugin settings (sandbox, tools, etc.)" }, { value: "skills", label: "Skills", hint: "Install/enable workspace skills" }, { value: "health", diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index a0cddb0cca1..24fa34c1578 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -559,6 +559,15 @@ export async function runConfigureWizard( await configureChannelsSection(); } + if (selected.includes("plugins")) { + const { configurePluginConfig } = await import("../wizard/setup.plugin-config.js"); + nextConfig = await configurePluginConfig({ + config: nextConfig, + prompter, + workspaceDir: resolveUserPath(workspaceDir), + }); + } + if (selected.includes("skills")) { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); @@ -616,6 +625,16 @@ export async function runConfigureWizard( await persistConfig(); } + if (choice === "plugins") { + const { configurePluginConfig } = await import("../wizard/setup.plugin-config.js"); + nextConfig = await configurePluginConfig({ + config: nextConfig, + prompter, + workspaceDir: resolveUserPath(workspaceDir), + }); + await persistConfig(); + } + if (choice === "skills") { const wsDir = resolveUserPath(workspaceDir); nextConfig = await setupSkills(nextConfig, wsDir, runtime, prompter); diff --git a/src/wizard/setup.plugin-config.test.ts b/src/wizard/setup.plugin-config.test.ts new file mode 100644 index 00000000000..82d91d02bd9 --- /dev/null +++ b/src/wizard/setup.plugin-config.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginConfigUiHint } from "../plugins/types.js"; +import { discoverConfigurablePlugins, discoverUnconfiguredPlugins } from "./setup.plugin-config.js"; + +function makeManifestPlugin( + id: string, + uiHints?: Record, + configSchema?: Record, +) { + return { + id, + name: id, + configUiHints: uiHints, + configSchema, + enabled: true, + enabledByDefault: true, + }; +} + +describe("discoverConfigurablePlugins", () => { + it("returns plugins with non-advanced uiHints", () => { + const plugins = [ + makeManifestPlugin("openshell", { + mode: { label: "Mode", help: "Sandbox mode" }, + gateway: { label: "Gateway", help: "Gateway name" }, + gpu: { label: "GPU", advanced: true }, + }), + ]; + const result = discoverConfigurablePlugins({ manifestPlugins: plugins }); + expect(result).toHaveLength(1); + expect(result[0]).toBeDefined(); + expect(result[0].id).toBe("openshell"); + expect(Object.keys(result[0].uiHints)).toEqual(["mode", "gateway"]); + // Advanced field excluded + expect(result[0].uiHints.gpu).toBeUndefined(); + }); + + it("excludes plugins with no uiHints", () => { + const plugins = [makeManifestPlugin("bare-plugin")]; + const result = discoverConfigurablePlugins({ manifestPlugins: plugins }); + expect(result).toHaveLength(0); + }); + + it("excludes plugins where all fields are advanced", () => { + const plugins = [ + makeManifestPlugin("all-advanced", { + gpu: { label: "GPU", advanced: true }, + timeout: { label: "Timeout", advanced: true }, + }), + ]; + const result = discoverConfigurablePlugins({ manifestPlugins: plugins }); + expect(result).toHaveLength(0); + }); + + it("sorts results alphabetically by name", () => { + const plugins = [ + makeManifestPlugin("zeta", { a: { label: "A" } }), + makeManifestPlugin("alpha", { b: { label: "B" } }), + ]; + const result = discoverConfigurablePlugins({ manifestPlugins: plugins }); + expect(result.map((p) => p.id)).toEqual(["alpha", "zeta"]); + }); +}); + +describe("discoverUnconfiguredPlugins", () => { + it("returns plugins with at least one unconfigured field", () => { + const plugins = [ + makeManifestPlugin("openshell", { + mode: { label: "Mode" }, + gateway: { label: "Gateway" }, + }), + ]; + const config: OpenClawConfig = { + plugins: { + entries: { + openshell: { + config: { mode: "mirror" }, + }, + }, + }, + }; + const result = discoverUnconfiguredPlugins({ + manifestPlugins: plugins, + config, + }); + // gateway is unconfigured + expect(result).toHaveLength(1); + expect(result[0]).toBeDefined(); + expect(result[0].id).toBe("openshell"); + }); + + it("excludes plugins where all fields are configured", () => { + const plugins = [ + makeManifestPlugin("openshell", { + mode: { label: "Mode" }, + gateway: { label: "Gateway" }, + }), + ]; + const config: OpenClawConfig = { + plugins: { + entries: { + openshell: { + config: { mode: "mirror", gateway: "my-gw" }, + }, + }, + }, + }; + const result = discoverUnconfiguredPlugins({ + manifestPlugins: plugins, + config, + }); + expect(result).toHaveLength(0); + }); + + it("treats empty string as unconfigured", () => { + const plugins = [ + makeManifestPlugin("test-plugin", { + endpoint: { label: "Endpoint" }, + }), + ]; + const config: OpenClawConfig = { + plugins: { + entries: { + "test-plugin": { + config: { endpoint: "" }, + }, + }, + }, + }; + const result = discoverUnconfiguredPlugins({ + manifestPlugins: plugins, + config, + }); + expect(result).toHaveLength(1); + }); + + it("returns empty when no plugins have uiHints", () => { + const plugins = [makeManifestPlugin("bare")]; + const result = discoverUnconfiguredPlugins({ + manifestPlugins: plugins, + config: {}, + }); + expect(result).toHaveLength(0); + }); +}); diff --git a/src/wizard/setup.plugin-config.ts b/src/wizard/setup.plugin-config.ts new file mode 100644 index 00000000000..001850c2933 --- /dev/null +++ b/src/wizard/setup.plugin-config.ts @@ -0,0 +1,372 @@ +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginConfigUiHint } from "../plugins/types.js"; +import type { WizardPrompter } from "./prompts.js"; + +/** + * A discovered plugin that has configurable fields via uiHints. + */ +export type ConfigurablePlugin = { + id: string; + name: string; + /** uiHints from the plugin manifest, keyed by config field name. */ + uiHints: Record; + /** JSON schema from the plugin manifest (used for type/enum info). */ + jsonSchema?: Record; +}; + +type JsonSchemaProperty = { + type?: string; + enum?: unknown[]; + description?: string; +}; + +function resolveJsonSchemaProperty( + jsonSchema: Record | undefined, + fieldKey: string, +): JsonSchemaProperty | undefined { + if (!jsonSchema) { + return undefined; + } + const properties = jsonSchema.properties; + if (!properties || typeof properties !== "object") { + return undefined; + } + const prop = (properties as Record)[fieldKey]; + if (!prop || typeof prop !== "object") { + return undefined; + } + return prop as JsonSchemaProperty; +} + +function getExistingPluginConfig( + config: OpenClawConfig, + pluginId: string, +): Record { + return (config.plugins?.entries?.[pluginId]?.config as Record) ?? {}; +} + +function formatCurrentValue(value: unknown): string { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "string") { + return value; + } + if (typeof value === "boolean" || typeof value === "number") { + return String(value); + } + if (Array.isArray(value)) { + return value.join(", "); + } + return JSON.stringify(value); +} + +/** + * Discover plugins that have non-advanced uiHints fields. + * Returns only plugins that have at least one promptable field. + */ +export function discoverConfigurablePlugins(params: { + manifestPlugins: ReadonlyArray<{ + id: string; + name?: string; + configUiHints?: Record; + configSchema?: Record; + enabled?: boolean; + }>; +}): ConfigurablePlugin[] { + const result: ConfigurablePlugin[] = []; + for (const plugin of params.manifestPlugins) { + if (!plugin.configUiHints) { + continue; + } + // Only include non-advanced fields + const promptableHints: Record = {}; + for (const [key, hint] of Object.entries(plugin.configUiHints)) { + if (!hint.advanced) { + promptableHints[key] = hint; + } + } + if (Object.keys(promptableHints).length === 0) { + continue; + } + result.push({ + id: plugin.id, + name: plugin.name ?? plugin.id, + uiHints: promptableHints, + jsonSchema: plugin.configSchema, + }); + } + return result.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Discover plugins with unconfigured non-advanced fields (for onboard flow). + * Returns only plugins where at least one promptable field has no value yet. + */ +export function discoverUnconfiguredPlugins(params: { + manifestPlugins: ReadonlyArray<{ + id: string; + name?: string; + configUiHints?: Record; + configSchema?: Record; + enabled?: boolean; + }>; + config: OpenClawConfig; +}): ConfigurablePlugin[] { + const all = discoverConfigurablePlugins(params); + return all.filter((plugin) => { + const existing = getExistingPluginConfig(params.config, plugin.id); + return Object.keys(plugin.uiHints).some((key) => { + const val = existing[key]; + return val === undefined || val === null || val === ""; + }); + }); +} + +/** + * Prompt the user to configure a single plugin's fields via uiHints. + * Returns the updated config with plugin values applied. + */ +async function promptPluginFields(params: { + plugin: ConfigurablePlugin; + config: OpenClawConfig; + prompter: WizardPrompter; + /** When true, show all fields including already-configured ones (for configure flow). */ + showConfigured?: boolean; +}): Promise { + const { plugin, config, prompter } = params; + const existing = getExistingPluginConfig(config, plugin.id); + const updatedConfig: Record = { ...existing }; + let changed = false; + + for (const [key, hint] of Object.entries(plugin.uiHints)) { + const currentValue = existing[key]; + const hasValue = currentValue !== undefined && currentValue !== null && currentValue !== ""; + + // In onboard mode, skip already-configured fields + if (hasValue && !params.showConfigured) { + continue; + } + + const schemaProp = resolveJsonSchemaProperty(plugin.jsonSchema, key); + const label = hint.label ?? key; + const helpSuffix = hint.help ? ` — ${hint.help}` : ""; + + // Handle enum fields with select + if (schemaProp?.enum && Array.isArray(schemaProp.enum)) { + const options = schemaProp.enum.map((v) => ({ + value: String(v), + label: String(v), + })); + if (hasValue) { + options.unshift({ + value: "__keep__", + label: `Keep current (${formatCurrentValue(currentValue)})`, + }); + } + const selected = await prompter.select({ + message: `${label}${helpSuffix}`, + options, + initialValue: hasValue ? "__keep__" : undefined, + }); + if (selected !== "__keep__") { + updatedConfig[key] = selected; + changed = true; + } + continue; + } + + // Handle boolean fields with confirm + if (schemaProp?.type === "boolean") { + const confirmed = await prompter.confirm({ + message: `${label}${helpSuffix}`, + initialValue: typeof currentValue === "boolean" ? currentValue : false, + }); + if (confirmed !== currentValue) { + updatedConfig[key] = confirmed; + changed = true; + } + continue; + } + + // Handle array fields — prompt as comma-separated string + if (schemaProp?.type === "array") { + const currentStr = Array.isArray(currentValue) ? (currentValue as unknown[]).join(", ") : ""; + const input = await prompter.text({ + message: `${label} (comma-separated)${helpSuffix}`, + initialValue: currentStr, + placeholder: hint.placeholder ?? "value1, value2", + }); + const trimmed = input.trim(); + if (trimmed) { + const values = trimmed + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + updatedConfig[key] = values; + changed = true; + } + continue; + } + + // Default: text input (string, number, etc.) + const currentStr = formatCurrentValue(currentValue); + const input = await prompter.text({ + message: `${label}${helpSuffix}`, + initialValue: currentStr, + placeholder: hint.placeholder, + }); + const trimmed = input.trim(); + if (trimmed !== currentStr) { + // Try to parse as number if schema says number + if (schemaProp?.type === "number") { + const parsed = Number(trimmed); + if (Number.isFinite(parsed)) { + updatedConfig[key] = parsed; + changed = true; + } + } else { + updatedConfig[key] = trimmed || undefined; + changed = true; + } + } + } + + if (!changed) { + return config; + } + + // Merge updated plugin config back into the full config + return { + ...config, + plugins: { + ...config.plugins, + entries: { + ...config.plugins?.entries, + [plugin.id]: { + ...config.plugins?.entries?.[plugin.id], + config: updatedConfig, + }, + }, + }, + }; +} + +/** + * Run the plugin configuration step for the onboard wizard. + * Shows unconfigured plugin fields and prompts the user. + */ +export async function setupPluginConfig(params: { + config: OpenClawConfig; + prompter: WizardPrompter; + workspaceDir?: string; +}): Promise { + const { loadPluginManifestRegistry } = await import("../plugins/manifest-registry.js"); + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + }); + + const unconfigured = discoverUnconfiguredPlugins({ + manifestPlugins: registry.plugins.filter((p) => { + // Only show enabled plugins + const entry = params.config.plugins?.entries?.[p.id]; + // Plugin is discoverable if it's enabled or enabledByDefault and not denied + return p.enabledByDefault || entry?.enabled === true; + }), + config: params.config, + }); + + if (unconfigured.length === 0) { + return params.config; + } + + const selected = await params.prompter.multiselect({ + message: "Configure plugins (select to set up now, or skip)", + options: unconfigured.map((p) => ({ + value: p.id, + label: p.name, + hint: `${Object.keys(p.uiHints).length} field${Object.keys(p.uiHints).length === 1 ? "" : "s"}`, + })), + }); + + let config = params.config; + for (const pluginId of selected) { + const plugin = unconfigured.find((p) => p.id === pluginId); + if (!plugin) { + continue; + } + await params.prompter.note(`Configure ${plugin.name}`, "Plugin setup"); + config = await promptPluginFields({ + plugin, + config, + prompter: params.prompter, + }); + } + + return config; +} + +/** + * Run the plugin configuration step for the configure wizard. + * Shows all configurable plugins and all their non-advanced fields. + */ +export async function configurePluginConfig(params: { + config: OpenClawConfig; + prompter: WizardPrompter; + workspaceDir?: string; +}): Promise { + const { loadPluginManifestRegistry } = await import("../plugins/manifest-registry.js"); + const registry = loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + }); + + const configurable = discoverConfigurablePlugins({ + manifestPlugins: registry.plugins.filter((p) => { + const entry = params.config.plugins?.entries?.[p.id]; + return p.enabledByDefault || entry?.enabled === true; + }), + }); + + if (configurable.length === 0) { + await params.prompter.note("No plugins with configurable fields found.", "Plugins"); + return params.config; + } + + const selected = await params.prompter.select({ + message: "Select plugin to configure", + options: [ + ...configurable.map((p) => { + const existing = getExistingPluginConfig(params.config, p.id); + const configuredCount = Object.keys(p.uiHints).filter((k) => { + const val = existing[k]; + return val !== undefined && val !== null && val !== ""; + }).length; + const totalCount = Object.keys(p.uiHints).length; + return { + value: p.id, + label: p.name, + hint: `${configuredCount}/${totalCount} configured`, + }; + }), + { value: "__skip__", label: "Back", hint: "Return to section menu" }, + ], + }); + + if (selected === "__skip__") { + return params.config; + } + + const plugin = configurable.find((p) => p.id === selected); + if (!plugin) { + return params.config; + } + + return promptPluginFields({ + plugin, + config: params.config, + prompter: params.prompter, + showConfigured: true, + }); +} diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index c8c7588a6d1..66d6694e6ff 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -623,6 +623,16 @@ export async function runSetupWizard( nextConfig = await setupSkills(nextConfig, workspaceDir, runtime, prompter); } + // Plugin configuration (sandbox backends, tool plugins, etc.) + if (flow !== "quickstart") { + const { setupPluginConfig } = await import("./setup.plugin-config.js"); + nextConfig = await setupPluginConfig({ + config: nextConfig, + prompter, + workspaceDir, + }); + } + // Setup hooks (session memory on /new) const { setupInternalHooks } = await import("../commands/onboard-hooks.js"); nextConfig = await setupInternalHooks(nextConfig, runtime, prompter);