mirror of https://github.com/openclaw/openclaw.git
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:
parent
f6380ae4b7
commit
881f7dc82f
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue