Plugin SDK: add plugin config TUI prompts to onboard and configure wizards (#60590) (#60590)

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
This commit is contained in:
George Zhang 2026-04-03 17:19:19 -07:00 committed by GitHub
parent f6380ae4b7
commit 881f7dc82f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 549 additions and 0 deletions

View File

@ -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",

View File

@ -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);

View File

@ -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<string, PluginConfigUiHint>,
configSchema?: Record<string, unknown>,
) {
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);
});
});

View File

@ -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<string, PluginConfigUiHint>;
/** JSON schema from the plugin manifest (used for type/enum info). */
jsonSchema?: Record<string, unknown>;
};
type JsonSchemaProperty = {
type?: string;
enum?: unknown[];
description?: string;
};
function resolveJsonSchemaProperty(
jsonSchema: Record<string, unknown> | 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<string, unknown>)[fieldKey];
if (!prop || typeof prop !== "object") {
return undefined;
}
return prop as JsonSchemaProperty;
}
function getExistingPluginConfig(
config: OpenClawConfig,
pluginId: string,
): Record<string, unknown> {
return (config.plugins?.entries?.[pluginId]?.config as Record<string, unknown>) ?? {};
}
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<string, PluginConfigUiHint>;
configSchema?: Record<string, unknown>;
enabled?: boolean;
}>;
}): ConfigurablePlugin[] {
const result: ConfigurablePlugin[] = [];
for (const plugin of params.manifestPlugins) {
if (!plugin.configUiHints) {
continue;
}
// Only include non-advanced fields
const promptableHints: Record<string, PluginConfigUiHint> = {};
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<string, PluginConfigUiHint>;
configSchema?: Record<string, unknown>;
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<OpenClawConfig> {
const { plugin, config, prompter } = params;
const existing = getExistingPluginConfig(config, plugin.id);
const updatedConfig: Record<string, unknown> = { ...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<OpenClawConfig> {
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<OpenClawConfig> {
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,
});
}

View File

@ -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);