mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 11:20:43 +00:00
[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 headc29cda6005. - Required merge gates passed before the squash merge. Prepared head SHA:c29cda6005Review: 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:
committed by
GitHub
parent
47f76c563f
commit
eee3aeae00
@@ -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", () => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
110
src/cli/plugins-search-command.test.ts
Normal file
110
src/cli/plugins-search-command.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
91
src/cli/plugins-search-command.ts
Normal file
91
src/cli/plugins-search-command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
196
src/cli/plugins-uninstall-command.ts
Normal file
196
src/cli/plugins-uninstall-command.ts
Normal 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.");
|
||||
}
|
||||
Reference in New Issue
Block a user