[codex] add Crestodian plugin management (#75869)

Summary:
- The branch adds ClawHub plugin search and Crestodian plugin list/search/install/uninstall flows, with docs, changelog, tests, runtime injection, and regenerated config baseline hashes.
- Reproducibility: not applicable. as a bug reproduction request. The high-confidence verification path is cur ... surface search plus exact-head diff/source inspection against the PR's targeted tests and queued CI checks.

ClawSweeper fixups:
- Included follow-up commit: Repair Crestodian plugin management config schema drift

Validation:
- ClawSweeper review passed for head c29cda6005.
- Required merge gates passed before the squash merge.

Prepared head SHA: c29cda6005
Review: https://github.com/openclaw/openclaw/pull/75869#issuecomment-4362360704

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-05-02 04:12:38 +01:00
committed by GitHub
parent 47f76c563f
commit eee3aeae00
20 changed files with 920 additions and 270 deletions

View File

@@ -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", () => ({

View File

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

View File

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

View File

@@ -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:<repo>@<ref>`.");
return defaultRuntime.exit(1);
runtime.error("`--pin` is not supported with `git:` installs; use `git:<repo>@<ref>`.");
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);
}
}

View File

@@ -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<OpenClawConfig> {
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<HookInstallUpdate, "hookId" | "hooks">;
successMessage?: string;
runtime?: RuntimeEnv;
}): Promise<OpenClawConfig> {
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;
}

View File

@@ -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<void> {
export async function runPluginsListCommand(
opts: PluginsListOptions,
runtime: RuntimeEnv = defaultRuntime,
): Promise<void> {
const { buildPluginRegistrySnapshotReport } = await import("../plugins/status.js");
const cfg = getRuntimeConfig();
const report = buildPluginRegistrySnapshotReport({
@@ -31,19 +34,17 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
plugins: list,
diagnostics: report.diagnostics,
};
defaultRuntime.writeJson(payload);
writeRuntimeJson(runtime, payload);
return;
}
if (list.length === 0) {
defaultRuntime.log(theme.muted("No plugins found."));
runtime.log(theme.muted("No plugins found."));
return;
}
const enabled = list.filter((p) => 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<v
});
if (usedRoots.size > 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<v
if (!dir) {
continue;
}
defaultRuntime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`);
runtime.log(` ${theme.command(`${key}:`)} ${theme.muted(dir)}`);
}
defaultRuntime.log("");
runtime.log("");
}
defaultRuntime.log(
runtime.log(
renderTable({
width: tableWidth,
columns: [
@@ -110,5 +111,5 @@ export async function runPluginsListCommand(opts: PluginsListOptions): Promise<v
lines.push(formatPluginLine(plugin, true));
lines.push("");
}
defaultRuntime.log(lines.join("\n").trim());
runtime.log(lines.join("\n").trim());
}

View File

@@ -0,0 +1,110 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
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);
});
});

View File

@@ -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<string, ClawHubPackageSearchResult>();
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<void> {
const query = normalizeOptionalString(
Array.isArray(queryParts) ? queryParts.join(" ") : queryParts,
);
if (!query) {
runtime.error("Usage: openclaw plugins search <query>");
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);
}
}

View File

@@ -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<void> {
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<string, unknown> | 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.");
}