diff --git a/CHANGELOG.md b/CHANGELOG.md index f9775b7f009..8c051d5d743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai - Plugins/ClawHub: prefer versioned ClawPack artifacts when ClawHub publishes digest metadata, verifying the ClawPack response header and downloaded bytes before installing. Thanks @vincentkoc. - Plugins/ClawHub: persist ClawPack digest metadata on ClawHub plugin install and update records so registry refreshes and download verification can reuse stored artifact facts. Thanks @vincentkoc. - Plugins/ClawHub: allow official bundled-plugin cutovers to prefer ClawHub installs with npm fallback only when the ClawHub package or version is absent. Thanks @vincentkoc. +- Plugins/Crestodian: add ClawHub plugin search plus Crestodian plugin list/search/install/uninstall operations, with approval and audit coverage for install and uninstall. - Providers/OpenAI: add `extraBody`/`extra_body` passthrough for OpenAI-compatible TTS endpoints, so custom speech servers can receive fields such as `lang` in `/audio/speech` requests. Fixes #39900. Thanks @R3NK0R. - Dependencies: refresh workspace dependency pins, including TypeBox 1.1.37, AWS SDK 3.1041.0, Microsoft Teams 2.0.9, and Marked 18.0.3. Thanks @mariozechner, @aws, and @microsoft. - Discord/channels: add reusable message-channel access groups plus Discord channel-audience DM authorization, so allowlists can reference `accessGroup:` across channel auth paths. (#75813) diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 322e0e3af12..2e542edd228 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -35224e7970e71225a51482432f1618ae3b54be9615956d8554a0e2df3d263bc8 config-baseline.json -80e6e8dce647aef2d1310de55a81d27de52cca47fc24bd7ad81b80f43a72b84c config-baseline.core.json -eab8a85eefa2792fb8b98a07698e5ec31ff0b6f8af6222767e8049dcc5c4f529 config-baseline.channel.json -af71b84b2411d8ccabcc6e09de0ee41f8212ff9869a6677698b6e7e3afdfaa47 config-baseline.plugin.json +ae25cb1d397f1ea9642047ef13d35300c807cb1cd67f681c0b5af83b572b3638 config-baseline.json +0a1907d595765b8bb7a41348d14323920ab50e402be49a19a45a4e2499306407 config-baseline.core.json +c401cd3450f1737bc92418cfea301d20b54b7fbef9e6049834acc01af338e538 config-baseline.channel.json +7731a0b93cb335b56fac4c807447ba659fea51ea7a6cd844dc0ef5616669ee75 config-baseline.plugin.json diff --git a/docs/cli/crestodian.md b/docs/cli/crestodian.md index ef3c9e58cb1..0202afac9d5 100644 --- a/docs/cli/crestodian.md +++ b/docs/cli/crestodian.md @@ -71,6 +71,10 @@ agents create agent work workspace ~/Projects/work models set default model openai/gpt-5.5 +plugins list +plugins search slack +plugin install clawhub:openclaw-codex-app-server +plugin uninstall openclaw-codex-app-server talk to work agent talk to agent for ~/Projects/work audit @@ -99,6 +103,8 @@ Read-only operations can run immediately: - show overview - list agents +- list installed plugins +- search ClawHub plugins - show model/backend status - run status or health checks - check Gateway reachability @@ -116,6 +122,8 @@ you pass `--yes` for a direct command: - change the default model - start, stop, or restart the Gateway - create agents +- install plugins from ClawHub or npm +- uninstall plugins - run doctor repairs that rewrite config or state Applied writes are recorded in: @@ -240,6 +248,9 @@ Security contract for remote rescue: - Require an explicit owner identity. Rescue must not accept wildcard sender rules, open group policy, unauthenticated webhooks, or anonymous channels. - Owner DMs only by default. Group/channel rescue requires explicit opt-in. +- Plugin search and list are read-only. Plugin install is local-only by default + because it downloads executable code. Plugin uninstall can be allowed as an + approved repair operation when rescue policy permits persistent writes. - Remote rescue cannot open the local TUI or switch into an interactive agent session. Use local `openclaw` for agent handoff. - Persistent writes still require approval, even in rescue mode. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 3dc42428f03..931733d7c77 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -31,6 +31,9 @@ openclaw plugins list openclaw plugins list --enabled openclaw plugins list --verbose openclaw plugins list --json +openclaw plugins search +openclaw plugins search --limit 20 +openclaw plugins search --json openclaw plugins install openclaw plugins inspect openclaw plugins inspect --runtime @@ -64,6 +67,7 @@ Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON Sch ### Install ```bash +openclaw plugins search "calendar" # search ClawHub plugins openclaw plugins install # ClawHub first, then npm openclaw plugins install clawhub: # ClawHub only openclaw plugins install npm: # npm only @@ -82,6 +86,10 @@ openclaw plugins install --marketplace https://github.com// +`plugins search` queries ClawHub for installable plugin packages and prints +install-ready package names. It searches code-plugin and bundle-plugin packages, +not skills. Use `openclaw skills search` for ClawHub skills. + ClawHub is the primary distribution and discovery surface for most plugins. Npm remains a supported fallback and direct-install path. During the migration to @@ -217,6 +225,9 @@ openclaw plugins list openclaw plugins list --enabled openclaw plugins list --verbose openclaw plugins list --json +openclaw plugins search +openclaw plugins search --limit 20 +openclaw plugins search --json ``` @@ -233,6 +244,11 @@ openclaw plugins list --json `plugins list` reads the persisted local plugin registry first, with a manifest-only derived fallback when the registry is missing or invalid. It is useful for checking whether a plugin is installed, enabled, and visible to cold startup planning, but it is not a live runtime probe of an already-running Gateway process. After changing plugin code, enablement, hook policy, or `plugins.load.paths`, restart the Gateway that serves the channel before expecting new `register(api)` code or hooks to run. For remote/container deployments, verify you are restarting the actual `openclaw gateway run` child, not only a wrapper process. +`plugins search` is a remote ClawHub catalog lookup. It does not inspect local +state, mutate config, install packages, or load plugin runtime code. Search +results include the ClawHub package name, family, channel, version, summary, and +an install hint such as `openclaw plugins install clawhub:`. + For bundled plugin work inside a packaged Docker image, bind-mount the plugin source directory over the matching packaged source path, such as `/app/extensions/synology-chat`. OpenClaw will discover that mounted source diff --git a/docs/tools/clawhub.md b/docs/tools/clawhub.md index 6e5f1a68c62..3ef358d7ab0 100644 --- a/docs/tools/clawhub.md +++ b/docs/tools/clawhub.md @@ -60,11 +60,14 @@ Site: [clawhub.ai](https://clawhub.ai) ```bash + openclaw plugins search "calendar" openclaw plugins install clawhub: openclaw plugins update --all ``` - Bare npm-safe plugin specs are also tried against ClawHub before npm: + `plugins search` queries the ClawHub plugin catalog and prints install-ready + package names. Bare npm-safe plugin specs are also tried against ClawHub + before npm: ```bash openclaw plugins install openclaw-codex-app-server diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 2f04c927ca7..6335cb60b00 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -27,6 +27,12 @@ temporary set of OpenClaw-owned plugin packages while that migration finishes. ```bash + # Search ClawHub plugins + openclaw plugins search "calendar" + + # From ClawHub + openclaw plugins install clawhub:openclaw-codex-app-server + # From npm openclaw plugins install npm:@acme/openclaw-plugin @@ -433,6 +439,7 @@ openclaw plugins list # compact inventory openclaw plugins list --enabled # only enabled plugins openclaw plugins list --verbose # per-plugin detail lines openclaw plugins list --json # machine-readable inventory +openclaw plugins search # search ClawHub plugin catalog openclaw plugins inspect # static detail openclaw plugins inspect --runtime # registered hooks/tools/CLI/gateway methods openclaw plugins inspect --json # machine-readable diff --git a/src/cli/plugins-cli-test-helpers.ts b/src/cli/plugins-cli-test-helpers.ts index 8341acfc58d..e60a58d3ef5 100644 --- a/src/cli/plugins-cli-test-helpers.ts +++ b/src/cli/plugins-cli-test-helpers.ts @@ -151,6 +151,8 @@ function restoreRuntimeCaptureMocks() { vi.mock("../runtime.js", () => ({ defaultRuntime, + writeRuntimeJson: (runtime: CliMockOutputRuntime, value: unknown, space = 2) => + runtime.writeJson(value, space), })); vi.mock("../config/config.js", () => ({ diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 11fdf71337c..365062b59c6 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,17 +1,10 @@ -import os from "node:os"; -import path from "node:path"; import type { Command } from "commander"; import { getRuntimeConfig, readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; -import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; -import { - tracePluginLifecyclePhase, - tracePluginLifecyclePhaseAsync, -} from "../plugins/plugin-lifecycle-trace.js"; +import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; -import { shortenHomePath } from "../utils.js"; import type { PluginInspectOptions } from "./plugins-inspect-command.js"; import type { PluginsListOptions } from "./plugins-list-command.js"; import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; @@ -26,6 +19,11 @@ export type PluginMarketplaceListOptions = { json?: boolean; }; +export type PluginSearchOptions = { + json?: boolean; + limit?: number; +}; + export type PluginUninstallOptions = { keepFiles?: boolean; /** @deprecated Use keepFiles. */ @@ -74,6 +72,17 @@ export function registerPluginsCli(program: Command) { await runPluginsListCommand(opts); }); + plugins + .command("search") + .description("Search ClawHub plugin packages") + .argument("[query...]", "Search query") + .option("--limit ", "Max results", (value) => Number.parseInt(value, 10)) + .option("--json", "Print JSON", false) + .action(async (queryParts: string[], opts: PluginSearchOptions) => { + const { runPluginsSearchCommand } = await import("./plugins-search-command.js"); + await runPluginsSearchCommand(queryParts, opts); + }); + plugins .command("inspect") .alias("info") @@ -162,172 +171,8 @@ export function registerPluginsCli(program: Command) { .option("--force", "Skip confirmation prompt", false) .option("--dry-run", "Show what would be removed without making changes", false) .action(async (id: string, opts: PluginUninstallOptions) => { - const { - loadInstalledPluginIndexInstallRecords, - removePluginInstallRecordFromRecords, - withoutPluginInstallRecords, - withPluginInstallRecords, - } = await import("../plugins/installed-plugin-index-records.js"); - const { buildPluginSnapshotReport } = await import("../plugins/status.js"); - const { - applyPluginUninstallDirectoryRemoval, - formatUninstallActionLabels, - formatUninstallSlotResetPreview, - planPluginUninstall, - resolveUninstallChannelConfigKeys, - UNINSTALL_ACTION_LABELS, - } = await import("../plugins/uninstall.js"); - const { commitPluginInstallRecordsWithConfig } = - await import("./plugins-install-record-commit.js"); - const { refreshPluginRegistryAfterConfigMutation } = - await import("./plugins-registry-refresh.js"); - const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); - const { promptYesNo } = await import("./prompt.js"); - const snapshot = await tracePluginLifecyclePhaseAsync( - "config read", - () => readConfigFileSnapshot(), - { command: "uninstall" }, - ); - const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; - const installRecords = await tracePluginLifecyclePhaseAsync( - "install records load", - () => loadInstalledPluginIndexInstallRecords(), - { command: "uninstall" }, - ); - const cfg = withPluginInstallRecords(sourceConfig, installRecords); - const report = tracePluginLifecyclePhase( - "plugin registry snapshot", - () => buildPluginSnapshotReport({ config: cfg }), - { command: "uninstall" }, - ); - const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions"); - const keepFiles = Boolean(opts.keepFiles || opts.keepConfig); - - if (opts.keepConfig) { - defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`.")); - } - - const { plugin, pluginId } = resolvePluginUninstallId({ - rawId: id, - config: cfg, - plugins: report.plugins, - }); - const hasEntry = pluginId in (cfg.plugins?.entries ?? {}); - const hasInstall = pluginId in (cfg.plugins?.installs ?? {}); - - if (!hasEntry && !hasInstall) { - if (plugin) { - defaultRuntime.error( - `Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`, - ); - } else { - defaultRuntime.error(`Plugin not found: ${id}`); - } - return defaultRuntime.exit(1); - } - - const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; - const plan = planPluginUninstall({ - config: cfg, - pluginId, - channelIds, - deleteFiles: !keepFiles, - extensionsDir, - }); - if (!plan.ok) { - defaultRuntime.error(plan.error); - return defaultRuntime.exit(1); - } - - const preview: string[] = []; - if (plan.actions.entry) { - preview.push(UNINSTALL_ACTION_LABELS.entry); - } - if (plan.actions.install) { - preview.push(UNINSTALL_ACTION_LABELS.install); - } - if (plan.actions.allowlist) { - preview.push(UNINSTALL_ACTION_LABELS.allowlist); - } - if (plan.actions.denylist) { - preview.push(UNINSTALL_ACTION_LABELS.denylist); - } - if (plan.actions.loadPath) { - preview.push(UNINSTALL_ACTION_LABELS.loadPath); - } - if (plan.actions.memorySlot) { - preview.push(formatUninstallSlotResetPreview("memory")); - } - if (plan.actions.contextEngineSlot) { - preview.push(formatUninstallSlotResetPreview("contextEngine")); - } - const channels = cfg.channels as Record | undefined; - if (plan.actions.channelConfig && hasInstall && channels) { - for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) { - if (Object.hasOwn(channels, key)) { - preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`); - } - } - } - if (plan.directoryRemoval) { - preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`); - } - - const pluginName = plugin?.name || pluginId; - defaultRuntime.log( - `Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`, - ); - defaultRuntime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`); - - if (opts.dryRun) { - defaultRuntime.log(theme.muted("Dry run, no changes made.")); - return; - } - - if (!opts.force) { - const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`); - if (!confirmed) { - defaultRuntime.log("Cancelled."); - return; - } - } - - const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); - const nextConfig = withoutPluginInstallRecords(plan.config); - await tracePluginLifecyclePhaseAsync( - "config mutation", - () => - commitPluginInstallRecordsWithConfig({ - previousInstallRecords: installRecords, - nextInstallRecords, - nextConfig, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - }), - { command: "uninstall" }, - ); - const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); - for (const warning of directoryResult.warnings) { - defaultRuntime.log(theme.warn(warning)); - } - await refreshPluginRegistryAfterConfigMutation({ - config: nextConfig, - reason: "source-changed", - installRecords: nextInstallRecords, - traceCommand: "uninstall", - logger: { - warn: (message) => defaultRuntime.log(theme.warn(message)), - }, - }); - - const removed = formatUninstallActionLabels({ - ...plan.actions, - directory: directoryResult.directoryRemoved, - }); - - defaultRuntime.log( - `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, - ); - defaultRuntime.log("Restart the gateway to apply changes."); + const { runPluginUninstallCommand } = await import("./plugins-uninstall-command.js"); + await runPluginUninstallCommand(id, opts); }); plugins diff --git a/src/cli/plugins-command-helpers.ts b/src/cli/plugins-command-helpers.ts index 64747fdcfe9..59c61a0f9fd 100644 --- a/src/cli/plugins-command-helpers.ts +++ b/src/cli/plugins-command-helpers.ts @@ -6,7 +6,7 @@ import { loadPluginManifestRegistryForPluginRegistry } from "../plugins/plugin-r import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { buildPluginDiagnosticsReport } from "../plugins/status.js"; import type { PluginLogger } from "../plugins/types.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { theme } from "../terminal/theme.js"; @@ -129,23 +129,23 @@ export function applySlotSelectionForPlugin( return { config: result.config, warnings: result.warnings }; } -export function createPluginInstallLogger(): { +export function createPluginInstallLogger(runtime: RuntimeEnv = defaultRuntime): { info: (msg: string) => void; warn: (msg: string) => void; } { return { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(theme.warn(msg)), + info: (msg) => runtime.log(msg), + warn: (msg) => runtime.log(theme.warn(msg)), }; } -export function createHookPackInstallLogger(): { +export function createHookPackInstallLogger(runtime: RuntimeEnv = defaultRuntime): { info: (msg: string) => void; warn: (msg: string) => void; } { return { - info: (msg) => defaultRuntime.log(msg), - warn: (msg) => defaultRuntime.log(theme.warn(msg)), + info: (msg) => runtime.log(msg), + warn: (msg) => runtime.log(theme.warn(msg)), }; } @@ -191,16 +191,16 @@ export function formatPluginInstallWithHookFallbackError( return `${pluginError}\nAlso not a valid hook pack: ${hookError}`; } -export function logHookPackRestartHint() { - defaultRuntime.log("Restart the gateway to load hooks."); +export function logHookPackRestartHint(runtime: RuntimeEnv = defaultRuntime) { + runtime.log("Restart the gateway to load hooks."); } -export function logSlotWarnings(warnings: string[]) { +export function logSlotWarnings(warnings: string[], runtime: RuntimeEnv = defaultRuntime) { if (warnings.length === 0) { return; } for (const warning of warnings) { - defaultRuntime.log(theme.warn(warning)); + runtime.log(theme.warn(warning)); } } diff --git a/src/cli/plugins-install-command.ts b/src/cli/plugins-install-command.ts index 6afbc77e5b5..52aef877a35 100644 --- a/src/cli/plugins-install-command.ts +++ b/src/cli/plugins-install-command.ts @@ -22,7 +22,7 @@ import { } from "../plugins/marketplace.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; import { validateJsonSchemaValue } from "../plugins/schema-validator.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { shortenHomePath } from "../utils.js"; import { looksLikeLocalInstallSpec } from "./install-spec.js"; @@ -110,6 +110,7 @@ async function installBundledPluginSource(params: { rawSpec: string; bundledSource: BundledPluginSource; warning: string; + runtime?: RuntimeEnv; }) { const existingEntry = params.snapshot.config.plugins?.entries?.[params.bundledSource.pluginId]; const shouldEnable = hasValidBundledPluginConfig({ @@ -136,6 +137,7 @@ async function installBundledPluginSource(params: { }, enable: shouldEnable, warningMessage: [params.warning, configWarning].filter(Boolean).join("\n"), + runtime: params.runtime, }); } @@ -145,6 +147,7 @@ async function tryInstallHookPackFromLocalPath(params: { installMode: "install" | "update"; safetyOverrides?: InstallSafetyOverrides; link?: boolean; + runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false; error: string }> { if (params.link) { const stat = fs.statSync(params.resolvedPath); @@ -193,6 +196,7 @@ async function tryInstallHookPackFromLocalPath(params: { version: probe.version, }, successMessage: `Linked hook pack path: ${shortenHomePath(params.resolvedPath)}`, + runtime: params.runtime, }); return { ok: true }; } @@ -201,7 +205,7 @@ async function tryInstallHookPackFromLocalPath(params: { ...resolveInstallSafetyOverrides(params.safetyOverrides ?? {}), path: params.resolvedPath, mode: params.installMode, - logger: createHookPackInstallLogger(), + logger: createHookPackInstallLogger(params.runtime), }); if (!result.ok) { return result; @@ -218,6 +222,7 @@ async function tryInstallHookPackFromLocalPath(params: { installPath: result.targetDir, version: result.version, }, + runtime: params.runtime, }); return { ok: true }; } @@ -227,11 +232,12 @@ async function tryInstallHookPackFromNpmSpec(params: { installMode: "install" | "update"; spec: string; pin?: boolean; + runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false; error: string }> { const result = await installHooksFromNpmSpec({ spec: params.spec, mode: params.installMode, - logger: createHookPackInstallLogger(), + logger: createHookPackInstallLogger(params.runtime), }); if (!result.ok) { return result; @@ -243,7 +249,7 @@ async function tryInstallHookPackFromNpmSpec(params: { result.targetDir, result.version, result.npmResolution, - defaultRuntime.log, + params.runtime?.log ?? defaultRuntime.log, theme.warn, ); await persistHookPackInstall({ @@ -251,6 +257,7 @@ async function tryInstallHookPackFromNpmSpec(params: { hookPackId: result.hookPackId, hooks: result.hooks, install: installRecord, + runtime: params.runtime, }); return { ok: true }; } @@ -263,17 +270,18 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { safetyOverrides: InstallSafetyOverrides; allowBundledFallback: boolean; extensionsDir: string; + runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromNpmSpec({ ...params.safetyOverrides, mode: params.installMode, spec: params.spec, extensionsDir: params.extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(params.runtime), }); if (!result.ok) { if (isTerminalPluginInstallSecurityFailure(result.code)) { - defaultRuntime.error(result.error); + (params.runtime ?? defaultRuntime).error(result.error); return { ok: false }; } if (params.allowBundledFallback) { @@ -288,6 +296,7 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { rawSpec: params.spec, bundledSource: bundledFallbackPlan.bundledSource, warning: bundledFallbackPlan.warning, + runtime: params.runtime, }); return { ok: true }; } @@ -297,11 +306,12 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { installMode: params.installMode, spec: params.spec, pin: params.pin, + runtime: params.runtime, }); if (hookFallback.ok) { return { ok: true }; } - defaultRuntime.error( + (params.runtime ?? defaultRuntime).error( formatPluginInstallWithHookFallbackError(result.error, hookFallback.error), ); return { ok: false }; @@ -313,13 +323,14 @@ async function tryInstallPluginOrHookPackFromNpmSpec(params: { result.targetDir, result.version, result.npmResolution, - defaultRuntime.log, + params.runtime?.log ?? defaultRuntime.log, theme.warn, ); await persistPluginInstall({ snapshot: params.snapshot, pluginId: result.pluginId, install: installRecord, + runtime: params.runtime, }); return { ok: true }; } @@ -330,16 +341,17 @@ async function tryInstallPluginFromGitSpec(params: { spec: string; safetyOverrides: InstallSafetyOverrides; extensionsDir: string; + runtime?: RuntimeEnv; }): Promise<{ ok: true } | { ok: false }> { const result = await installPluginFromGitSpec({ ...params.safetyOverrides, mode: params.installMode, spec: params.spec, extensionsDir: params.extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(params.runtime), }); if (!result.ok) { - defaultRuntime.error(result.error); + (params.runtime ?? defaultRuntime).error(result.error); return { ok: false }; } @@ -356,6 +368,7 @@ async function tryInstallPluginFromGitSpec(params: { gitRef: result.git.ref, gitCommit: result.git.commit, }, + runtime: params.runtime, }); return { ok: true }; } @@ -452,7 +465,9 @@ export async function runPluginInstallCommand(params: { pin?: boolean; marketplace?: string; }; + runtime?: RuntimeEnv; }) { + const runtime = params.runtime ?? defaultRuntime; const shorthand = !params.opts.marketplace ? await tracePluginLifecyclePhaseAsync( "marketplace shortcut resolution", @@ -461,8 +476,8 @@ export async function runPluginInstallCommand(params: { ) : null; if (shorthand?.ok === false) { - defaultRuntime.error(shorthand.error); - return defaultRuntime.exit(1); + runtime.error(shorthand.error); + return runtime.exit(1); } const raw = shorthand?.ok ? shorthand.plugin : params.raw; @@ -473,47 +488,47 @@ export async function runPluginInstallCommand(params: { }; if (opts.marketplace) { if (opts.link) { - defaultRuntime.error("`--link` is not supported with `--marketplace`."); - return defaultRuntime.exit(1); + runtime.error("`--link` is not supported with `--marketplace`."); + return runtime.exit(1); } if (opts.pin) { - defaultRuntime.error("`--pin` is not supported with `--marketplace`."); - return defaultRuntime.exit(1); + runtime.error("`--pin` is not supported with `--marketplace`."); + return runtime.exit(1); } } const gitPrefix = raw.trim().toLowerCase().startsWith("git:"); const gitSpec = parseGitPluginSpec(raw); if (gitPrefix && !gitSpec) { - defaultRuntime.error(`unsupported git: plugin spec: ${raw}`); - return defaultRuntime.exit(1); + runtime.error(`unsupported git: plugin spec: ${raw}`); + return runtime.exit(1); } if (gitSpec && opts.link) { - defaultRuntime.error("`--link` is not supported with `git:` installs."); - return defaultRuntime.exit(1); + runtime.error("`--link` is not supported with `git:` installs."); + return runtime.exit(1); } if (gitSpec && opts.pin) { - defaultRuntime.error("`--pin` is not supported with `git:` installs; use `git:@`."); - return defaultRuntime.exit(1); + runtime.error("`--pin` is not supported with `git:` installs; use `git:@`."); + return runtime.exit(1); } if (opts.link && opts.force) { - defaultRuntime.error("`--force` is not supported with `--link`."); - return defaultRuntime.exit(1); + runtime.error("`--force` is not supported with `--link`."); + return runtime.exit(1); } const requestResolution = resolvePluginInstallRequestContext({ rawSpec: raw, marketplace: opts.marketplace, }); if (!requestResolution.ok) { - defaultRuntime.error(requestResolution.error); - return defaultRuntime.exit(1); + runtime.error(requestResolution.error); + return runtime.exit(1); } const request = requestResolution.request; const snapshot = await loadConfigForInstall(request).catch((error: unknown) => { - defaultRuntime.error(formatErrorMessage(error)); + runtime.error(formatErrorMessage(error)); return null; }); if (!snapshot) { - return defaultRuntime.exit(1); + return runtime.exit(1); } const cfg = snapshot.config; const installMode = resolveInstallMode(opts.force); @@ -527,11 +542,11 @@ export async function runPluginInstallCommand(params: { mode: installMode, plugin: raw, extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); + runtime.error(result.error); + return runtime.exit(1); } await persistPluginInstall({ @@ -545,6 +560,7 @@ export async function runPluginInstallCommand(params: { marketplaceSource: result.marketplaceSource, marketplacePlugin: result.marketplacePlugin, }, + runtime, }); return; } @@ -560,12 +576,12 @@ export async function runPluginInstallCommand(params: { path: resolved, dryRun: true, extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(runtime), }); if (!probe.ok) { if (isTerminalPluginInstallSecurityFailure(probe.code)) { - defaultRuntime.error(probe.error); - return defaultRuntime.exit(1); + runtime.error(probe.error); + return runtime.exit(1); } const hookFallback = await tryInstallHookPackFromLocalPath({ snapshot, @@ -573,14 +589,13 @@ export async function runPluginInstallCommand(params: { resolvedPath: resolved, safetyOverrides, link: true, + runtime, }); if (hookFallback.ok) { return; } - defaultRuntime.error( - formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error), - ); - return defaultRuntime.exit(1); + runtime.error(formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error)); + return runtime.exit(1); } await persistPluginInstall({ @@ -605,6 +620,7 @@ export async function runPluginInstallCommand(params: { version: probe.version, }, successMessage: `Linked plugin path: ${shortenHomePath(resolved)}`, + runtime, }); return; } @@ -614,26 +630,25 @@ export async function runPluginInstallCommand(params: { mode: installMode, path: resolved, extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(runtime), }); if (!result.ok) { if (isTerminalPluginInstallSecurityFailure(result.code)) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); + runtime.error(result.error); + return runtime.exit(1); } const hookFallback = await tryInstallHookPackFromLocalPath({ snapshot, installMode, resolvedPath: resolved, safetyOverrides, + runtime, }); if (hookFallback.ok) { return; } - defaultRuntime.error( - formatPluginInstallWithHookFallbackError(result.error, hookFallback.error), - ); - return defaultRuntime.exit(1); + runtime.error(formatPluginInstallWithHookFallbackError(result.error, hookFallback.error)); + return runtime.exit(1); } const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; @@ -646,20 +661,21 @@ export async function runPluginInstallCommand(params: { installPath: result.targetDir, version: result.version, }, + runtime, }); return; } if (opts.link) { - defaultRuntime.error("`--link` requires a local path."); - return defaultRuntime.exit(1); + runtime.error("`--link` requires a local path."); + return runtime.exit(1); } const npmPrefixSpec = parseNpmPrefixSpec(raw); if (npmPrefixSpec !== null) { if (!npmPrefixSpec) { - defaultRuntime.error("unsupported npm: spec: missing package"); - return defaultRuntime.exit(1); + runtime.error("unsupported npm: spec: missing package"); + return runtime.exit(1); } const npmPrefixResult = await tryInstallPluginOrHookPackFromNpmSpec({ snapshot, @@ -669,9 +685,10 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: false, extensionsDir, + runtime, }); if (!npmPrefixResult.ok) { - return defaultRuntime.exit(1); + return runtime.exit(1); } return; } @@ -683,9 +700,10 @@ export async function runPluginInstallCommand(params: { spec: raw, safetyOverrides, extensionsDir, + runtime, }); if (!gitResult.ok) { - return defaultRuntime.exit(1); + return runtime.exit(1); } return; } @@ -702,8 +720,8 @@ export async function runPluginInstallCommand(params: { ".zip", ]) ) { - defaultRuntime.error(`Path not found: ${resolved}`); - return defaultRuntime.exit(1); + runtime.error(`Path not found: ${resolved}`); + return runtime.exit(1); } const bundledPreNpmPlan = resolveBundledInstallPlanBeforeNpm({ @@ -719,6 +737,7 @@ export async function runPluginInstallCommand(params: { rawSpec: raw, bundledSource: bundledPreNpmPlan.bundledSource, warning: bundledPreNpmPlan.warning, + runtime, }), { command: "install", @@ -736,11 +755,11 @@ export async function runPluginInstallCommand(params: { mode: installMode, spec: raw, extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(runtime), }); if (!result.ok) { - defaultRuntime.error(result.error); - return defaultRuntime.exit(1); + runtime.error(result.error); + return runtime.exit(1); } await persistPluginInstall({ @@ -762,6 +781,7 @@ export async function runPluginInstallCommand(params: { clawpackManifestSha256: result.clawhub.clawpackManifestSha256, clawpackSize: result.clawhub.clawpackSize, }, + runtime, }); return; } @@ -773,7 +793,7 @@ export async function runPluginInstallCommand(params: { mode: installMode, spec: preferredClawHubSpec, extensionsDir, - logger: createPluginInstallLogger(), + logger: createPluginInstallLogger(runtime), }); if (clawhubResult.ok) { await persistPluginInstall({ @@ -795,12 +815,13 @@ export async function runPluginInstallCommand(params: { clawpackManifestSha256: clawhubResult.clawhub.clawpackManifestSha256, clawpackSize: clawhubResult.clawhub.clawpackSize, }, + runtime, }); return; } if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") { - defaultRuntime.error(clawhubResult.error); - return defaultRuntime.exit(1); + runtime.error(clawhubResult.error); + return runtime.exit(1); } } @@ -812,8 +833,9 @@ export async function runPluginInstallCommand(params: { safetyOverrides, allowBundledFallback: true, extensionsDir, + runtime, }); if (!npmResult.ok) { - return defaultRuntime.exit(1); + return runtime.exit(1); } } diff --git a/src/cli/plugins-install-persist.ts b/src/cli/plugins-install-persist.ts index 3a73e46fc6a..eb4decdcc7c 100644 --- a/src/cli/plugins-install-persist.ts +++ b/src/cli/plugins-install-persist.ts @@ -9,7 +9,7 @@ import { } from "../plugins/installed-plugin-index-records.js"; import type { PluginInstallUpdate } from "../plugins/installs.js"; import { tracePluginLifecyclePhaseAsync } from "../plugins/plugin-lifecycle-trace.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { theme } from "../terminal/theme.js"; import { applySlotSelectionForPlugin, @@ -65,7 +65,9 @@ export async function persistPluginInstall(params: { enable?: boolean; successMessage?: string; warningMessage?: string; + runtime?: RuntimeEnv; }): Promise { + const runtime = params.runtime ?? defaultRuntime; const installConfig = params.enable === false ? params.snapshot.config @@ -114,15 +116,15 @@ export async function persistPluginInstall(params: { installRecords: nextInstallRecords, traceCommand: "install", logger: { - warn: (message) => defaultRuntime.log(theme.warn(message)), + warn: (message) => runtime.log(theme.warn(message)), }, }); - logSlotWarnings(slotResult.warnings); + logSlotWarnings(slotResult.warnings, runtime); if (params.warningMessage) { - defaultRuntime.log(theme.warn(params.warningMessage)); + runtime.log(theme.warn(params.warningMessage)); } - defaultRuntime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`); - defaultRuntime.log("Restart the gateway to load plugins."); + runtime.log(params.successMessage ?? `Installed plugin: ${params.pluginId}`); + runtime.log("Restart the gateway to load plugins."); return next; } @@ -132,7 +134,9 @@ export async function persistHookPackInstall(params: { hooks: string[]; install: Omit; successMessage?: string; + runtime?: RuntimeEnv; }): Promise { + const runtime = params.runtime ?? defaultRuntime; let next = enableInternalHookEntries(params.snapshot.config, params.hooks); next = recordHookInstall(next, { hookId: params.hookPackId, @@ -143,7 +147,7 @@ export async function persistHookPackInstall(params: { nextConfig: next, baseHash: params.snapshot.baseHash, }); - defaultRuntime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); - logHookPackRestartHint(); + runtime.log(params.successMessage ?? `Installed hook pack: ${params.hookPackId}`); + logHookPackRestartHint(runtime); return next; } diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 76bad83418b..778a4dce82d 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -1,6 +1,6 @@ import { getRuntimeConfig } from "../config/config.js"; import { formatPluginSourceForTable, resolvePluginSourceRoots } from "../plugins/source-display.js"; -import { defaultRuntime } from "../runtime.js"; +import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { quietPluginJsonLogger } from "./plugins-command-helpers.js"; @@ -12,7 +12,10 @@ export type PluginsListOptions = { verbose?: boolean; }; -export async function runPluginsListCommand(opts: PluginsListOptions): Promise { +export async function runPluginsListCommand( + opts: PluginsListOptions, + runtime: RuntimeEnv = defaultRuntime, +): Promise { const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js"); const cfg = getRuntimeConfig(); const report = buildPluginRegistrySnapshotReport({ @@ -31,19 +34,17 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise p.enabled).length; - defaultRuntime.log( - `${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`, - ); + runtime.log(`${theme.heading("Plugins")} ${theme.muted(`(${enabled}/${list.length} enabled)`)}`); if (!opts.verbose) { const tableWidth = getTerminalTableWidth(); @@ -74,7 +75,7 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise 0) { - defaultRuntime.log(theme.muted("Source roots:")); + runtime.log(theme.muted("Source roots:")); for (const key of ["stock", "workspace", "global"] as const) { if (!usedRoots.has(key)) { continue; @@ -83,12 +84,12 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: vi.fn((value: unknown) => logs.push(String(value))), + error: vi.fn((value: unknown) => errors.push(String(value))), + writeJson: vi.fn((value: unknown, space = 2) => + logs.push(JSON.stringify(value, null, space > 0 ? space : undefined)), + ), + writeStdout: vi.fn((value: string) => + logs.push(value.endsWith("\n") ? value.slice(0, -1) : value), + ), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + }; + return { + logs, + errors, + runtime, + searchClawHubPackages: vi.fn(), + }; +}); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.runtime, + writeRuntimeJson: (runtime: typeof mocks.runtime, value: unknown, space = 2) => + runtime.writeJson(value, space), +})); + +vi.mock("../infra/clawhub.js", () => ({ + searchClawHubPackages: mocks.searchClawHubPackages, +})); + +const { runPluginsSearchCommand } = await import("./plugins-search-command.js"); + +describe("plugins search command", () => { + beforeEach(() => { + mocks.logs.length = 0; + mocks.errors.length = 0; + mocks.runtime.log.mockClear(); + mocks.runtime.error.mockClear(); + mocks.runtime.writeJson.mockClear(); + mocks.runtime.exit.mockClear(); + mocks.searchClawHubPackages.mockReset(); + }); + + it("searches ClawHub code and bundle plugin families", async () => { + mocks.searchClawHubPackages + .mockResolvedValueOnce([ + { + score: 12, + package: { + name: "openclaw-calendar", + displayName: "Calendar", + family: "code-plugin", + channel: "community", + isOfficial: false, + summary: "Calendar sync", + createdAt: 1, + updatedAt: 1, + latestVersion: "1.2.3", + }, + }, + ]) + .mockResolvedValueOnce([ + { + score: 10, + package: { + name: "openclaw-calendar-bundle", + displayName: "Calendar Bundle", + family: "bundle-plugin", + channel: "official", + isOfficial: true, + summary: "Calendar bundle", + createdAt: 1, + updatedAt: 1, + latestVersion: "2.0.0", + }, + }, + ]); + + await runPluginsSearchCommand(["calendar"], { limit: 5 }, mocks.runtime); + + expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({ + query: "calendar", + family: "code-plugin", + limit: 5, + }); + expect(mocks.searchClawHubPackages).toHaveBeenCalledWith({ + query: "calendar", + family: "bundle-plugin", + limit: 5, + }); + expect(mocks.logs.join("\n")).toContain("openclaw-calendar"); + expect(mocks.logs.join("\n")).toContain( + "Install: openclaw plugins install clawhub:openclaw-calendar", + ); + }); + + it("writes JSON results when requested", async () => { + mocks.searchClawHubPackages.mockResolvedValueOnce([]).mockResolvedValueOnce([]); + + await runPluginsSearchCommand("calendar", { json: true }, mocks.runtime); + + expect(mocks.runtime.writeJson).toHaveBeenCalledWith({ results: [] }, 2); + }); +}); diff --git a/src/cli/plugins-search-command.ts b/src/cli/plugins-search-command.ts new file mode 100644 index 00000000000..3ea5f496a7b --- /dev/null +++ b/src/cli/plugins-search-command.ts @@ -0,0 +1,91 @@ +import { + searchClawHubPackages, + type ClawHubPackageFamily, + type ClawHubPackageSearchResult, +} from "../infra/clawhub.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { defaultRuntime, writeRuntimeJson, type RuntimeEnv } from "../runtime.js"; +import { normalizeOptionalString } from "../shared/string-coerce.js"; +import { theme } from "../terminal/theme.js"; + +export type PluginsSearchOptions = { + json?: boolean; + limit?: number; +}; + +const INSTALLABLE_PLUGIN_FAMILIES: ClawHubPackageFamily[] = ["code-plugin", "bundle-plugin"]; + +function clampSearchLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || !limit || limit <= 0) { + return 20; + } + return Math.min(Math.max(Math.trunc(limit), 1), 100); +} + +function mergePackageSearchResults( + groups: readonly ClawHubPackageSearchResult[][], + limit: number, +): ClawHubPackageSearchResult[] { + const byName = new Map(); + for (const entry of groups.flat()) { + const existing = byName.get(entry.package.name); + if (!existing || entry.score > existing.score) { + byName.set(entry.package.name, entry); + } + } + return [...byName.values()].toSorted((a, b) => b.score - a.score).slice(0, limit); +} + +function formatPackageSearchLine(entry: ClawHubPackageSearchResult): string { + const pkg = entry.package; + const flags = [ + pkg.family, + pkg.channel, + pkg.isOfficial ? "official" : undefined, + pkg.latestVersion ? `v${pkg.latestVersion}` : undefined, + ].filter(Boolean); + const summary = pkg.summary ? ` ${theme.muted(pkg.summary)}` : ""; + return `${pkg.name} ${theme.muted(flags.join(" | "))}${summary}\n ${theme.muted(`Install: openclaw plugins install clawhub:${pkg.name}`)}`; +} + +export async function runPluginsSearchCommand( + queryParts: string[] | string, + opts: PluginsSearchOptions = {}, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const query = normalizeOptionalString( + Array.isArray(queryParts) ? queryParts.join(" ") : queryParts, + ); + if (!query) { + runtime.error("Usage: openclaw plugins search "); + return runtime.exit(1); + } + + const limit = clampSearchLimit(opts.limit); + try { + const groups = await Promise.all( + INSTALLABLE_PLUGIN_FAMILIES.map((family) => + searchClawHubPackages({ + query, + family, + limit, + }), + ), + ); + const results = mergePackageSearchResults(groups, limit); + + if (opts.json) { + writeRuntimeJson(runtime, { results }); + return; + } + if (results.length === 0) { + runtime.log("No ClawHub plugins found."); + return; + } + runtime.log(`${theme.heading("ClawHub plugins")} ${theme.muted(`(${results.length})`)}`); + runtime.log(results.map(formatPackageSearchLine).join("\n")); + } catch (error) { + runtime.error(formatErrorMessage(error)); + runtime.exit(1); + } +} diff --git a/src/cli/plugins-uninstall-command.ts b/src/cli/plugins-uninstall-command.ts new file mode 100644 index 00000000000..f633ffd3e0d --- /dev/null +++ b/src/cli/plugins-uninstall-command.ts @@ -0,0 +1,196 @@ +import os from "node:os"; +import path from "node:path"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + tracePluginLifecyclePhase, + tracePluginLifecyclePhaseAsync, +} from "../plugins/plugin-lifecycle-trace.js"; +import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; +import { theme } from "../terminal/theme.js"; +import { shortenHomePath } from "../utils.js"; + +export type PluginUninstallOptions = { + keepFiles?: boolean; + /** @deprecated Use keepFiles. */ + keepConfig?: boolean; + force?: boolean; + dryRun?: boolean; +}; + +export async function runPluginUninstallCommand( + id: string, + opts: PluginUninstallOptions = {}, + runtime: RuntimeEnv = defaultRuntime, +): Promise { + const { + loadInstalledPluginIndexInstallRecords, + removePluginInstallRecordFromRecords, + withoutPluginInstallRecords, + withPluginInstallRecords, + } = await import("../plugins/installed-plugin-index-records.js"); + const { buildPluginSnapshotReport } = await import("../plugins/status.js"); + const { + applyPluginUninstallDirectoryRemoval, + formatUninstallActionLabels, + formatUninstallSlotResetPreview, + planPluginUninstall, + resolveUninstallChannelConfigKeys, + UNINSTALL_ACTION_LABELS, + } = await import("../plugins/uninstall.js"); + const { commitPluginInstallRecordsWithConfig } = + await import("./plugins-install-record-commit.js"); + const { refreshPluginRegistryAfterConfigMutation } = + await import("./plugins-registry-refresh.js"); + const { resolvePluginUninstallId } = await import("./plugins-uninstall-selection.js"); + const { promptYesNo } = await import("./prompt.js"); + const snapshot = await tracePluginLifecyclePhaseAsync( + "config read", + () => readConfigFileSnapshot(), + { command: "uninstall" }, + ); + const sourceConfig = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig; + const installRecords = await tracePluginLifecyclePhaseAsync( + "install records load", + () => loadInstalledPluginIndexInstallRecords(), + { command: "uninstall" }, + ); + const cfg = withPluginInstallRecords(sourceConfig, installRecords); + const report = tracePluginLifecyclePhase( + "plugin registry snapshot", + () => buildPluginSnapshotReport({ config: cfg }), + { command: "uninstall" }, + ); + const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions"); + const keepFiles = Boolean(opts.keepFiles || opts.keepConfig); + + if (opts.keepConfig) { + runtime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`.")); + } + + const { plugin, pluginId } = resolvePluginUninstallId({ + rawId: id, + config: cfg, + plugins: report.plugins, + }); + const hasEntry = pluginId in (cfg.plugins?.entries ?? {}); + const hasInstall = pluginId in (cfg.plugins?.installs ?? {}); + + if (!hasEntry && !hasInstall) { + if (plugin) { + runtime.error( + `Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`, + ); + } else { + runtime.error(`Plugin not found: ${id}`); + } + runtime.exit(1); + return; + } + + const channelIds = plugin?.status === "loaded" ? plugin.channelIds : undefined; + const plan = planPluginUninstall({ + config: cfg, + pluginId, + channelIds, + deleteFiles: !keepFiles, + extensionsDir, + }); + if (!plan.ok) { + runtime.error(plan.error); + runtime.exit(1); + return; + } + + const preview: string[] = []; + if (plan.actions.entry) { + preview.push(UNINSTALL_ACTION_LABELS.entry); + } + if (plan.actions.install) { + preview.push(UNINSTALL_ACTION_LABELS.install); + } + if (plan.actions.allowlist) { + preview.push(UNINSTALL_ACTION_LABELS.allowlist); + } + if (plan.actions.denylist) { + preview.push(UNINSTALL_ACTION_LABELS.denylist); + } + if (plan.actions.loadPath) { + preview.push(UNINSTALL_ACTION_LABELS.loadPath); + } + if (plan.actions.memorySlot) { + preview.push(formatUninstallSlotResetPreview("memory")); + } + if (plan.actions.contextEngineSlot) { + preview.push(formatUninstallSlotResetPreview("contextEngine")); + } + const channels = cfg.channels as Record | undefined; + if (plan.actions.channelConfig && hasInstall && channels) { + for (const key of resolveUninstallChannelConfigKeys(pluginId, { channelIds })) { + if (Object.hasOwn(channels, key)) { + preview.push(`${UNINSTALL_ACTION_LABELS.channelConfig} (channels.${key})`); + } + } + } + if (plan.directoryRemoval) { + preview.push(`directory: ${shortenHomePath(plan.directoryRemoval.target)}`); + } + + const pluginName = plugin?.name || pluginId; + runtime.log( + `Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`, + ); + runtime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`); + + const nextConfig = withoutPluginInstallRecords(plan.config); + + if (opts.dryRun) { + runtime.log(theme.muted("Dry run, no changes made.")); + return; + } + + if (!opts.force) { + const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`); + if (!confirmed) { + runtime.log("Cancelled."); + return; + } + } + + const nextInstallRecords = removePluginInstallRecordFromRecords(installRecords, pluginId); + await tracePluginLifecyclePhaseAsync( + "config mutation", + () => + commitPluginInstallRecordsWithConfig({ + previousInstallRecords: installRecords, + nextInstallRecords, + nextConfig, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + }), + { command: "uninstall" }, + ); + const directoryResult = await applyPluginUninstallDirectoryRemoval(plan.directoryRemoval); + for (const warning of directoryResult.warnings) { + runtime.log(theme.warn(warning)); + } + await refreshPluginRegistryAfterConfigMutation({ + config: nextConfig, + reason: "source-changed", + installRecords: nextInstallRecords, + traceCommand: "uninstall", + logger: { + warn: (message) => runtime.log(theme.warn(message)), + }, + }); + + const removed = formatUninstallActionLabels({ + ...plan.actions, + directory: directoryResult.directoryRemoved, + }); + + runtime.log( + `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, + ); + runtime.log("Restart the gateway to apply changes."); +} diff --git a/src/crestodian/assistant-prompts.ts b/src/crestodian/assistant-prompts.ts index 6a586dddd76..86fbdc211ff 100644 --- a/src/crestodian/assistant-prompts.ts +++ b/src/crestodian/assistant-prompts.ts @@ -23,6 +23,10 @@ export const CRESTODIAN_ASSISTANT_SYSTEM_PROMPT = [ "- stop gateway", "- agents", "- models", + "- plugins list", + "- plugins search ", + "- plugin install ", + "- plugin uninstall ", "- audit", "- validate config", "- set default model ", diff --git a/src/crestodian/operations.test.ts b/src/crestodian/operations.test.ts index e58a80b0d79..653dc31a8c4 100644 --- a/src/crestodian/operations.test.ts +++ b/src/crestodian/operations.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RuntimeEnv } from "../runtime.js"; import { createCrestodianTestRuntime } from "./crestodian.test-helpers.js"; import { executeCrestodianOperation, @@ -205,6 +206,27 @@ describe("parseCrestodianOperation", () => { expect(parseCrestodianOperation("doctor fix")).toEqual({ kind: "doctor-fix" }); }); + it("parses plugin management operations", () => { + expect(parseCrestodianOperation("plugins list")).toEqual({ kind: "plugin-list" }); + expect(parseCrestodianOperation("list plugin")).toEqual({ kind: "plugin-list" }); + expect(parseCrestodianOperation("plugins search calendar sync")).toEqual({ + kind: "plugin-search", + query: "calendar sync", + }); + expect(parseCrestodianOperation("install npm plugin @openclaw/demo")).toEqual({ + kind: "plugin-install", + spec: "npm:@openclaw/demo", + }); + expect(parseCrestodianOperation("plugin install clawhub:openclaw-demo")).toEqual({ + kind: "plugin-install", + spec: "clawhub:openclaw-demo", + }); + expect(parseCrestodianOperation("plugin uninstall openclaw-demo")).toEqual({ + kind: "plugin-uninstall", + pluginId: "openclaw-demo", + }); + }); + it("parses agent creation requests", () => { expect( parseCrestodianOperation("create agent Work workspace /tmp/work model openai/gpt-5.2"), @@ -340,6 +362,119 @@ describe("parseCrestodianOperation", () => { }); }); + it("runs plugin list and search as read-only operations", async () => { + const { runtime, lines } = createCrestodianTestRuntime(); + const runPluginsList = vi.fn(async (pluginRuntime: RuntimeEnv) => { + pluginRuntime.log("plugin rows"); + }); + const runPluginsSearch = vi.fn(async (query: string, pluginRuntime: RuntimeEnv) => { + pluginRuntime.log(`search rows: ${query}`); + }); + + await expect( + executeCrestodianOperation({ kind: "plugin-list" }, runtime, { + deps: { runPluginsList, runPluginsSearch }, + }), + ).resolves.toMatchObject({ applied: false }); + await expect( + executeCrestodianOperation({ kind: "plugin-search", query: "calendar" }, runtime, { + deps: { runPluginsList, runPluginsSearch }, + }), + ).resolves.toMatchObject({ applied: false }); + + expect(runPluginsList).toHaveBeenCalledWith(runtime); + expect(runPluginsSearch).toHaveBeenCalledWith("calendar", runtime); + expect(lines.join("\n")).toContain("plugin rows"); + expect(lines.join("\n")).toContain("search rows: calendar"); + expect(lines.join("\n")).toContain("[crestodian] done: plugins.search"); + }); + + it("installs plugins only after approval and audits the write", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-install-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createCrestodianTestRuntime(); + const runPluginInstall = vi.fn(async (spec: string, pluginRuntime: RuntimeEnv) => { + pluginRuntime.log(`installed ${spec}`); + }); + + const plan = await executeCrestodianOperation( + { kind: "plugin-install", spec: "clawhub:openclaw-demo" }, + runtime, + { deps: { runPluginInstall } }, + ); + expect(plan).toMatchObject({ + applied: false, + message: "Plan: install plugin clawhub:openclaw-demo. Say yes to apply.", + }); + expect(runPluginInstall).not.toHaveBeenCalled(); + + await expect( + executeCrestodianOperation( + { kind: "plugin-install", spec: "clawhub:openclaw-demo" }, + runtime, + { + approved: true, + deps: { runPluginInstall }, + auditDetails: { rescue: true }, + }, + ), + ).resolves.toMatchObject({ applied: true }); + + expect(runPluginInstall).toHaveBeenCalledWith("clawhub:openclaw-demo", expect.any(Object)); + expect(lines.join("\n")).toContain("[crestodian] done: plugin.install"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "plugin.install", + summary: "Installed plugin clawhub:openclaw-demo", + details: { + rescue: true, + spec: "clawhub:openclaw-demo", + }, + }); + }); + + it("uninstalls plugins only after approval and audits the write", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-plugin-uninstall-")); + vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); + const { runtime, lines } = createCrestodianTestRuntime(); + const runPluginUninstall = vi.fn(async (pluginId: string, pluginRuntime: RuntimeEnv) => { + pluginRuntime.log(`uninstalled ${pluginId}`); + }); + + const plan = await executeCrestodianOperation( + { kind: "plugin-uninstall", pluginId: "openclaw-demo" }, + runtime, + { deps: { runPluginUninstall } }, + ); + expect(plan).toMatchObject({ + applied: false, + message: "Plan: uninstall plugin openclaw-demo. Say yes to apply.", + }); + expect(runPluginUninstall).not.toHaveBeenCalled(); + + await expect( + executeCrestodianOperation({ kind: "plugin-uninstall", pluginId: "openclaw-demo" }, runtime, { + approved: true, + deps: { runPluginUninstall }, + auditDetails: { rescue: true }, + }), + ).resolves.toMatchObject({ applied: true }); + + expect(runPluginUninstall).toHaveBeenCalledWith("openclaw-demo", expect.any(Object)); + expect(lines.join("\n")).toContain("[crestodian] done: plugin.uninstall"); + const auditPath = path.join(tempDir, "audit", "crestodian.jsonl"); + const audit = JSON.parse((await fs.readFile(auditPath, "utf8")).trim()); + expect(audit).toMatchObject({ + operation: "plugin.uninstall", + summary: "Uninstalled plugin openclaw-demo", + details: { + rescue: true, + pluginId: "openclaw-demo", + }, + }); + }); + it("runs setup bootstrap only after approval and audits it", async () => { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "crestodian-setup-")); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); diff --git a/src/crestodian/operations.ts b/src/crestodian/operations.ts index 46ecf592d82..5ac54c0b9b2 100644 --- a/src/crestodian/operations.ts +++ b/src/crestodian/operations.ts @@ -36,6 +36,10 @@ export type CrestodianOperation = | { kind: "gateway-restart" } | { kind: "agents" } | { kind: "models" } + | { kind: "plugin-list" } + | { kind: "plugin-search"; query: string } + | { kind: "plugin-install"; spec: string } + | { kind: "plugin-uninstall"; pluginId: string } | { kind: "audit" } | { kind: "create-agent"; agentId: string; workspace?: string; model?: string } | { kind: "open-tui"; agentId?: string; workspace?: string } @@ -71,6 +75,10 @@ export type CrestodianCommandDeps = { runGatewayRestart?: () => Promise; runGatewayStart?: () => Promise; runGatewayStop?: () => Promise; + runPluginInstall?: (spec: string, runtime: RuntimeEnv) => Promise; + runPluginUninstall?: (pluginId: string, runtime: RuntimeEnv) => Promise; + runPluginsList?: (runtime: RuntimeEnv) => Promise; + runPluginsSearch?: (query: string, runtime: RuntimeEnv) => Promise; runTui?: (opts: { local: boolean; session?: string; @@ -93,6 +101,13 @@ const CONFIG_SET_REF_RE = /^(?:config\s+set-ref|set\s+secretref|set\s+secret\s+ref)\s+(?[A-Za-z0-9_.[\]-]+)\s+(?:(?env|file|exec)\s+)?(?\S+)(?:\s+provider\s+(?[A-Za-z0-9_-]+))?$/i; const SETUP_RE = /^(?:setup(?!\s+agent\b)|set\s+me\s+up|set\s+up\s+openclaw|onboard|onboard\s+me|bootstrap|first\s+run)(?:\b|$)/i; +const PLUGIN_LIST_RE = /^(?:plugins?|clawhub)\s+list$|^list\s+plugins?$/i; +const PLUGIN_SEARCH_RE = + /^(?:(?:plugins?|clawhub)\s+search|search\s+plugins?(?:\s+for)?)\s+(?.+)$/i; +const PLUGIN_INSTALL_RE = + /^(?:(?:plugins?)\s+install|install\s+(?:(?npm|clawhub)\s+)?plugins?)\s+(?\S+)$/i; +const PLUGIN_UNINSTALL_RE = + /^(?:(?:plugins?)\s+(?:uninstall|remove)|(?:uninstall|remove)\s+plugins?)\s+(?[A-Za-z0-9_.@/-]+)$/i; const OPENAI_API_DEFAULT_MODEL_REF = `${DEFAULT_PROVIDER}/${DEFAULT_MODEL}`; const ANTHROPIC_API_DEFAULT_MODEL_REF = "anthropic/claude-opus-4-7"; @@ -140,6 +155,27 @@ export function parseCrestodianOperation(input: string): CrestodianOperation { ) { return { kind: "config-validate" }; } + if (PLUGIN_LIST_RE.test(trimmed)) { + return { kind: "plugin-list" }; + } + const pluginSearchMatch = trimmed.match(PLUGIN_SEARCH_RE); + if (pluginSearchMatch?.groups?.query?.trim()) { + return { kind: "plugin-search", query: pluginSearchMatch.groups.query.trim() }; + } + const pluginInstallMatch = trimmed.match(PLUGIN_INSTALL_RE); + if (pluginInstallMatch?.groups?.spec?.trim()) { + return { + kind: "plugin-install", + spec: normalizePluginInstallSpec( + pluginInstallMatch.groups.spec.trim(), + pluginInstallMatch.groups.source, + ), + }; + } + const pluginUninstallMatch = trimmed.match(PLUGIN_UNINSTALL_RE); + if (pluginUninstallMatch?.groups?.pluginId?.trim()) { + return { kind: "plugin-uninstall", pluginId: pluginUninstallMatch.groups.pluginId.trim() }; + } if (SETUP_RE.test(lower)) { const workspace = trimShellishToken(trimmed.match(WORKSPACE_RE)?.groups?.workspace); const model = trimmed.match(MODEL_RE)?.groups?.model; @@ -232,6 +268,32 @@ function trimShellishToken(value: string | undefined): string | undefined { return trimmed; } +function normalizePluginInstallSpec(spec: string, source: string | undefined): string { + const trimmed = spec.trim(); + const normalizedSource = source?.toLowerCase(); + if (normalizedSource === "npm" && !trimmed.toLowerCase().startsWith("npm:")) { + return `npm:${trimmed}`; + } + if (normalizedSource === "clawhub" && !trimmed.toLowerCase().startsWith("clawhub:")) { + return `clawhub:${trimmed}`; + } + return trimmed; +} + +function validateCrestodianPluginInstallSpec(spec: string): string | null { + const trimmed = spec.trim(); + if (!trimmed) { + return "Plugin install spec is required."; + } + if (/\s/.test(trimmed)) { + return "Crestodian plugin install accepts one npm or ClawHub package spec."; + } + if (/^(?:\.{1,2}\/|\/|~\/|file:|git(?:\+ssh|\+https)?:|https?:)/i.test(trimmed)) { + return "Crestodian plugin install accepts npm or ClawHub package specs only."; + } + return null; +} + export function isPersistentCrestodianOperation(operation: CrestodianOperation): boolean { return ( operation.kind === "set-default-model" || @@ -239,6 +301,8 @@ export function isPersistentCrestodianOperation(operation: CrestodianOperation): operation.kind === "config-set-ref" || operation.kind === "setup" || operation.kind === "doctor-fix" || + operation.kind === "plugin-install" || + operation.kind === "plugin-uninstall" || operation.kind === "create-agent" || operation.kind === "gateway-start" || operation.kind === "gateway-stop" || @@ -258,6 +322,10 @@ export function describeCrestodianPersistentOperation(operation: CrestodianOpera return formatSetupPlanDescription(operation); case "doctor-fix": return "run doctor repairs"; + case "plugin-install": + return `install plugin ${operation.spec}`; + case "plugin-uninstall": + return `uninstall plugin ${operation.pluginId}`; case "create-agent": return `create agent ${operation.agentId} with workspace ${formatCreateAgentWorkspace(operation.workspace)}`; case "gateway-start": @@ -491,6 +559,30 @@ export async function executeCrestodianOperation( ); return { applied: false }; } + if (operation.kind === "plugin-list") { + logQueued(runtime, "plugins.list"); + const runPluginsList = + opts.deps?.runPluginsList ?? + (async (pluginRuntime: RuntimeEnv) => { + const { runPluginsListCommand } = await import("../cli/plugins-list-command.js"); + await runPluginsListCommand({}, pluginRuntime); + }); + await runPluginsList(runtime); + runtime.log("[crestodian] done: plugins.list"); + return { applied: false }; + } + if (operation.kind === "plugin-search") { + logQueued(runtime, "plugins.search"); + const runPluginsSearch = + opts.deps?.runPluginsSearch ?? + (async (query: string, pluginRuntime: RuntimeEnv) => { + const { runPluginsSearchCommand } = await import("../cli/plugins-search-command.js"); + await runPluginsSearchCommand(query, {}, pluginRuntime); + }); + await runPluginsSearch(operation.query, runtime); + runtime.log("[crestodian] done: plugins.search"); + return { applied: false }; + } if (operation.kind === "audit") { runtime.log(`Audit log: ${resolveCrestodianAuditPath()}`); runtime.log("Only applied writes/actions are recorded; discovery stays quiet."); @@ -656,6 +748,74 @@ export async function executeCrestodianOperation( runtime.log("[crestodian] done: config.setRef"); return { applied: true }; } + if (operation.kind === "plugin-install") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + const validationError = validateCrestodianPluginInstallSpec(operation.spec); + if (validationError) { + runtime.error(validationError); + runtime.exit(1); + return { applied: false }; + } + logQueued(runtime, "plugin.install"); + const before = await readConfigFileSnapshotLazy(); + const runPluginInstall = + opts.deps?.runPluginInstall ?? + (async (spec: string, pluginRuntime: RuntimeEnv) => { + const { runPluginInstallCommand } = await import("../cli/plugins-install-command.js"); + await runPluginInstallCommand({ raw: spec, opts: {}, runtime: pluginRuntime }); + }); + await runPluginInstall(operation.spec, createNoExitRuntime(runtime)); + const after = await readConfigFileSnapshotLazy(); + await appendCrestodianAuditEntry({ + operation: "plugin.install", + summary: `Installed plugin ${operation.spec}`, + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + spec: operation.spec, + }, + }); + runtime.log("[crestodian] done: plugin.install"); + runtime.log("Restart the Gateway to apply installed plugin changes."); + return { applied: true }; + } + if (operation.kind === "plugin-uninstall") { + if (!opts.approved) { + const message = formatCrestodianPersistentPlan(operation); + runtime.log(message); + return { applied: false, message }; + } + logQueued(runtime, "plugin.uninstall"); + const before = await readConfigFileSnapshotLazy(); + const runPluginUninstall = + opts.deps?.runPluginUninstall ?? + (async (pluginId: string, pluginRuntime: RuntimeEnv) => { + const { runPluginUninstallCommand } = await import("../cli/plugins-uninstall-command.js"); + await runPluginUninstallCommand(pluginId, { force: true }, pluginRuntime); + }); + await runPluginUninstall(operation.pluginId, createNoExitRuntime(runtime)); + const after = await readConfigFileSnapshotLazy(); + await appendCrestodianAuditEntry({ + operation: "plugin.uninstall", + summary: `Uninstalled plugin ${operation.pluginId}`, + configPath: after.path || before.path || undefined, + configHashBefore: before.hash ?? null, + configHashAfter: after.hash ?? null, + details: { + ...opts.auditDetails, + pluginId: operation.pluginId, + }, + }); + runtime.log("[crestodian] done: plugin.uninstall"); + runtime.log("Restart the Gateway to apply plugin changes."); + return { applied: true }; + } if (operation.kind === "create-agent") { if (!opts.approved) { const message = formatCrestodianPersistentPlan(operation); diff --git a/src/crestodian/rescue-message.test.ts b/src/crestodian/rescue-message.test.ts index af5b2752303..4e172b0097e 100644 --- a/src/crestodian/rescue-message.test.ts +++ b/src/crestodian/rescue-message.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { CommandContext } from "../auto-reply/reply/commands-types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { RuntimeEnv } from "../runtime.js"; import { extractCrestodianRescueMessage, runCrestodianRescueMessage } from "./rescue-message.js"; const originalStateDir = process.env.OPENCLAW_STATE_DIR; @@ -196,6 +197,41 @@ describe("Crestodian rescue message", () => { expect(deps.runTui).not.toHaveBeenCalled(); }); + it("refuses plugin install from remote rescue", async () => { + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { + runPluginInstall: vi.fn(async () => { + throw new Error("remote rescue must not install plugins"); + }), + }; + + await expect( + runRescue("/crestodian plugin install clawhub:openclaw-demo", cfg, commandContext(), deps), + ).resolves.toContain("cannot install plugins from a message channel"); + expect(deps.runPluginInstall).not.toHaveBeenCalled(); + }); + + it("allows plugin list and search from remote rescue", async () => { + const cfg: OpenClawConfig = { crestodian: { rescue: { enabled: true } } }; + const deps = { + runPluginsList: vi.fn(async (runtime: RuntimeEnv) => { + runtime.log("plugin rows"); + }), + runPluginsSearch: vi.fn(async (query: string, runtime: RuntimeEnv) => { + runtime.log(`search rows: ${query}`); + }), + }; + + await expect( + runRescue("/crestodian plugins list", cfg, commandContext(), deps), + ).resolves.toContain("plugin rows"); + await expect( + runRescue("/crestodian plugins search calendar", cfg, commandContext(), deps), + ).resolves.toContain("search rows: calendar"); + expect(deps.runPluginsList).toHaveBeenCalledTimes(1); + expect(deps.runPluginsSearch).toHaveBeenCalledWith("calendar", expect.any(Object)); + }); + it("queues and applies persistent writes through conversational approval", async () => { const tempDir = await makeStateDir("models-"); vi.stubEnv("OPENCLAW_STATE_DIR", tempDir); diff --git a/src/crestodian/rescue-message.ts b/src/crestodian/rescue-message.ts index 7395285d5da..9ce375d3731 100644 --- a/src/crestodian/rescue-message.ts +++ b/src/crestodian/rescue-message.ts @@ -127,6 +127,12 @@ function formatUnsupportedRemoteOperation(operation: CrestodianOperation): strin "Use local `openclaw` for agent handoff, or ask for status, doctor, config, gateway, agents, or models.", ].join(" "); } + if (operation.kind === "plugin-install") { + return [ + "Crestodian rescue cannot install plugins from a message channel by default because plugin install downloads executable code.", + "Use local `openclaw crestodian` or `openclaw plugins install` instead.", + ].join(" "); + } return null; }