mirror of https://github.com/openclaw/openclaw.git
447 lines
13 KiB
TypeScript
447 lines
13 KiB
TypeScript
import fs from "node:fs";
|
|
import { buildNpmInstallRecordFields } from "../../cli/npm-resolution.js";
|
|
import {
|
|
buildPreferredClawHubSpec,
|
|
createPluginInstallLogger,
|
|
decidePreferredClawHubFallback,
|
|
resolveFileNpmSpecToLocalPath,
|
|
} from "../../cli/plugins-command-helpers.js";
|
|
import { persistPluginInstall } from "../../cli/plugins-install-persist.js";
|
|
import {
|
|
readConfigFileSnapshot,
|
|
validateConfigObjectWithPlugins,
|
|
writeConfigFile,
|
|
} from "../../config/config.js";
|
|
import type { OpenClawConfig } from "../../config/config.js";
|
|
import type { PluginInstallRecord } from "../../config/types.plugins.js";
|
|
import { resolveArchiveKind } from "../../infra/archive.js";
|
|
import { parseClawHubPluginSpec } from "../../infra/clawhub.js";
|
|
import { installPluginFromClawHub } from "../../plugins/clawhub.js";
|
|
import { installPluginFromNpmSpec, installPluginFromPath } from "../../plugins/install.js";
|
|
import { clearPluginManifestRegistryCache } from "../../plugins/manifest-registry.js";
|
|
import type { PluginRecord } from "../../plugins/registry.js";
|
|
import {
|
|
buildAllPluginInspectReports,
|
|
buildPluginInspectReport,
|
|
buildPluginStatusReport,
|
|
formatPluginCompatibilityNotice,
|
|
type PluginStatusReport,
|
|
} from "../../plugins/status.js";
|
|
import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js";
|
|
import { resolveUserPath } from "../../utils.js";
|
|
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
|
import {
|
|
rejectNonOwnerCommand,
|
|
rejectUnauthorizedCommand,
|
|
requireCommandFlagEnabled,
|
|
requireGatewayClientScopeForInternalChannel,
|
|
} from "./command-gates.js";
|
|
import type { CommandHandler } from "./commands-types.js";
|
|
import { parsePluginsCommand } from "./plugins-commands.js";
|
|
|
|
function renderJsonBlock(label: string, value: unknown): string {
|
|
return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
|
|
}
|
|
|
|
function buildPluginInspectJson(params: {
|
|
id: string;
|
|
config: OpenClawConfig;
|
|
report: PluginStatusReport;
|
|
}): {
|
|
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>;
|
|
compatibilityWarnings: Array<{
|
|
code: string;
|
|
severity: string;
|
|
message: string;
|
|
}>;
|
|
install: PluginInstallRecord | null;
|
|
} | null {
|
|
const inspect = buildPluginInspectReport({
|
|
id: params.id,
|
|
config: params.config,
|
|
report: params.report,
|
|
});
|
|
if (!inspect) {
|
|
return null;
|
|
}
|
|
return {
|
|
inspect,
|
|
compatibilityWarnings: inspect.compatibility.map((warning) => ({
|
|
code: warning.code,
|
|
severity: warning.severity,
|
|
message: formatPluginCompatibilityNotice(warning),
|
|
})),
|
|
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
|
|
};
|
|
}
|
|
|
|
function buildAllPluginInspectJson(params: {
|
|
config: OpenClawConfig;
|
|
report: PluginStatusReport;
|
|
}): Array<{
|
|
inspect: ReturnType<typeof buildAllPluginInspectReports>[number];
|
|
compatibilityWarnings: Array<{
|
|
code: string;
|
|
severity: string;
|
|
message: string;
|
|
}>;
|
|
install: PluginInstallRecord | null;
|
|
}> {
|
|
return buildAllPluginInspectReports({
|
|
config: params.config,
|
|
report: params.report,
|
|
}).map((inspect) => ({
|
|
inspect,
|
|
compatibilityWarnings: inspect.compatibility.map((warning) => ({
|
|
code: warning.code,
|
|
severity: warning.severity,
|
|
message: formatPluginCompatibilityNotice(warning),
|
|
})),
|
|
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
|
|
}));
|
|
}
|
|
|
|
function formatPluginLabel(plugin: PluginRecord): string {
|
|
if (!plugin.name || plugin.name === plugin.id) {
|
|
return plugin.id;
|
|
}
|
|
return `${plugin.name} (${plugin.id})`;
|
|
}
|
|
|
|
function formatPluginsList(report: PluginStatusReport): string {
|
|
if (report.plugins.length === 0) {
|
|
return `🔌 No plugins found for workspace ${report.workspaceDir ?? "(unknown workspace)"}.`;
|
|
}
|
|
|
|
const loaded = report.plugins.filter((plugin) => plugin.status === "loaded").length;
|
|
const lines = [
|
|
`🔌 Plugins (${loaded}/${report.plugins.length} loaded)`,
|
|
...report.plugins.map((plugin) => {
|
|
const format = plugin.bundleFormat
|
|
? `${plugin.format ?? "openclaw"}/${plugin.bundleFormat}`
|
|
: (plugin.format ?? "openclaw");
|
|
return `- ${formatPluginLabel(plugin)} [${plugin.status}] ${format}`;
|
|
}),
|
|
];
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function findPlugin(report: PluginStatusReport, rawName: string): PluginRecord | undefined {
|
|
const target = rawName.trim().toLowerCase();
|
|
if (!target) {
|
|
return undefined;
|
|
}
|
|
return report.plugins.find(
|
|
(plugin) => plugin.id.toLowerCase() === target || plugin.name.toLowerCase() === target,
|
|
);
|
|
}
|
|
|
|
function looksLikeLocalPluginInstallSpec(raw: string): boolean {
|
|
return (
|
|
raw.startsWith(".") ||
|
|
raw.startsWith("~") ||
|
|
raw.startsWith("/") ||
|
|
raw.endsWith(".ts") ||
|
|
raw.endsWith(".js") ||
|
|
raw.endsWith(".mjs") ||
|
|
raw.endsWith(".cjs") ||
|
|
raw.endsWith(".tgz") ||
|
|
raw.endsWith(".tar.gz") ||
|
|
raw.endsWith(".tar") ||
|
|
raw.endsWith(".zip")
|
|
);
|
|
}
|
|
|
|
async function installPluginFromPluginsCommand(params: {
|
|
raw: string;
|
|
config: OpenClawConfig;
|
|
}): Promise<{ ok: true; pluginId: string } | { ok: false; error: string }> {
|
|
const fileSpec = resolveFileNpmSpecToLocalPath(params.raw);
|
|
if (fileSpec && !fileSpec.ok) {
|
|
return { ok: false, error: fileSpec.error };
|
|
}
|
|
const normalized = fileSpec && fileSpec.ok ? fileSpec.path : params.raw;
|
|
const resolved = resolveUserPath(normalized);
|
|
|
|
if (fs.existsSync(resolved)) {
|
|
const result = await installPluginFromPath({
|
|
path: resolved,
|
|
logger: createPluginInstallLogger(),
|
|
});
|
|
if (!result.ok) {
|
|
return { ok: false, error: result.error };
|
|
}
|
|
clearPluginManifestRegistryCache();
|
|
const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path";
|
|
await persistPluginInstall({
|
|
config: params.config,
|
|
pluginId: result.pluginId,
|
|
install: {
|
|
source,
|
|
sourcePath: resolved,
|
|
installPath: result.targetDir,
|
|
version: result.version,
|
|
},
|
|
});
|
|
return { ok: true, pluginId: result.pluginId };
|
|
}
|
|
|
|
if (looksLikeLocalPluginInstallSpec(params.raw)) {
|
|
return { ok: false, error: `Path not found: ${resolved}` };
|
|
}
|
|
|
|
const clawhubSpec = parseClawHubPluginSpec(params.raw);
|
|
if (clawhubSpec) {
|
|
const result = await installPluginFromClawHub({
|
|
spec: params.raw,
|
|
logger: createPluginInstallLogger(),
|
|
});
|
|
if (!result.ok) {
|
|
return { ok: false, error: result.error };
|
|
}
|
|
clearPluginManifestRegistryCache();
|
|
await persistPluginInstall({
|
|
config: params.config,
|
|
pluginId: result.pluginId,
|
|
install: {
|
|
source: "clawhub",
|
|
spec: params.raw,
|
|
installPath: result.targetDir,
|
|
version: result.version,
|
|
integrity: result.clawhub.integrity,
|
|
resolvedAt: result.clawhub.resolvedAt,
|
|
clawhubUrl: result.clawhub.clawhubUrl,
|
|
clawhubPackage: result.clawhub.clawhubPackage,
|
|
clawhubFamily: result.clawhub.clawhubFamily,
|
|
clawhubChannel: result.clawhub.clawhubChannel,
|
|
},
|
|
});
|
|
return { ok: true, pluginId: result.pluginId };
|
|
}
|
|
|
|
const preferredClawHubSpec = buildPreferredClawHubSpec(params.raw);
|
|
if (preferredClawHubSpec) {
|
|
const clawhubResult = await installPluginFromClawHub({
|
|
spec: preferredClawHubSpec,
|
|
logger: createPluginInstallLogger(),
|
|
});
|
|
if (clawhubResult.ok) {
|
|
clearPluginManifestRegistryCache();
|
|
await persistPluginInstall({
|
|
config: params.config,
|
|
pluginId: clawhubResult.pluginId,
|
|
install: {
|
|
source: "clawhub",
|
|
spec: preferredClawHubSpec,
|
|
installPath: clawhubResult.targetDir,
|
|
version: clawhubResult.version,
|
|
integrity: clawhubResult.clawhub.integrity,
|
|
resolvedAt: clawhubResult.clawhub.resolvedAt,
|
|
clawhubUrl: clawhubResult.clawhub.clawhubUrl,
|
|
clawhubPackage: clawhubResult.clawhub.clawhubPackage,
|
|
clawhubFamily: clawhubResult.clawhub.clawhubFamily,
|
|
clawhubChannel: clawhubResult.clawhub.clawhubChannel,
|
|
},
|
|
});
|
|
return { ok: true, pluginId: clawhubResult.pluginId };
|
|
}
|
|
if (decidePreferredClawHubFallback(clawhubResult) !== "fallback_to_npm") {
|
|
return { ok: false, error: clawhubResult.error };
|
|
}
|
|
}
|
|
|
|
const result = await installPluginFromNpmSpec({
|
|
spec: params.raw,
|
|
logger: createPluginInstallLogger(),
|
|
});
|
|
if (!result.ok) {
|
|
return { ok: false, error: result.error };
|
|
}
|
|
clearPluginManifestRegistryCache();
|
|
const installRecord = buildNpmInstallRecordFields({
|
|
spec: params.raw,
|
|
installPath: result.targetDir,
|
|
version: result.version,
|
|
resolution: result.npmResolution,
|
|
});
|
|
await persistPluginInstall({
|
|
config: params.config,
|
|
pluginId: result.pluginId,
|
|
install: installRecord,
|
|
});
|
|
return { ok: true, pluginId: result.pluginId };
|
|
}
|
|
|
|
async function loadPluginCommandState(workspaceDir: string): Promise<
|
|
| {
|
|
ok: true;
|
|
path: string;
|
|
config: OpenClawConfig;
|
|
report: PluginStatusReport;
|
|
}
|
|
| { ok: false; path: string; error: string }
|
|
> {
|
|
const snapshot = await readConfigFileSnapshot();
|
|
if (!snapshot.valid) {
|
|
return {
|
|
ok: false,
|
|
path: snapshot.path,
|
|
error: "Config file is invalid; fix it before using /plugins.",
|
|
};
|
|
}
|
|
const config = structuredClone(snapshot.resolved);
|
|
return {
|
|
ok: true,
|
|
path: snapshot.path,
|
|
config,
|
|
report: buildPluginStatusReport({ config, workspaceDir }),
|
|
};
|
|
}
|
|
|
|
export const handlePluginsCommand: CommandHandler = async (params, allowTextCommands) => {
|
|
if (!allowTextCommands) {
|
|
return null;
|
|
}
|
|
const pluginsCommand = parsePluginsCommand(params.command.commandBodyNormalized);
|
|
if (!pluginsCommand) {
|
|
return null;
|
|
}
|
|
const unauthorized = rejectUnauthorizedCommand(params, "/plugins");
|
|
if (unauthorized) {
|
|
return unauthorized;
|
|
}
|
|
const allowInternalReadOnly =
|
|
(pluginsCommand.action === "list" || pluginsCommand.action === "inspect") &&
|
|
isInternalMessageChannel(params.command.channel);
|
|
const nonOwner = allowInternalReadOnly ? null : rejectNonOwnerCommand(params, "/plugins");
|
|
if (nonOwner) {
|
|
return nonOwner;
|
|
}
|
|
const disabled = requireCommandFlagEnabled(params.cfg, {
|
|
label: "/plugins",
|
|
configKey: "plugins",
|
|
});
|
|
if (disabled) {
|
|
return disabled;
|
|
}
|
|
if (pluginsCommand.action === "error") {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `⚠️ ${pluginsCommand.message}` },
|
|
};
|
|
}
|
|
|
|
const loaded = await loadPluginCommandState(params.workspaceDir);
|
|
if (!loaded.ok) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `⚠️ ${loaded.error}` },
|
|
};
|
|
}
|
|
|
|
if (pluginsCommand.action === "list") {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: formatPluginsList(loaded.report) },
|
|
};
|
|
}
|
|
|
|
if (pluginsCommand.action === "inspect") {
|
|
if (!pluginsCommand.name) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: formatPluginsList(loaded.report) },
|
|
};
|
|
}
|
|
if (pluginsCommand.name.toLowerCase() === "all") {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: renderJsonBlock("🔌 Plugins", buildAllPluginInspectJson(loaded)),
|
|
},
|
|
};
|
|
}
|
|
const payload = buildPluginInspectJson({
|
|
id: pluginsCommand.name,
|
|
config: loaded.config,
|
|
report: loaded.report,
|
|
});
|
|
if (!payload) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
|
|
};
|
|
}
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, {
|
|
...payload.inspect,
|
|
compatibilityWarnings: payload.compatibilityWarnings,
|
|
install: payload.install,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
|
|
label: "/plugins write",
|
|
allowedScopes: ["operator.admin"],
|
|
missingText: "❌ /plugins install|enable|disable requires operator.admin for gateway clients.",
|
|
});
|
|
if (missingAdminScope) {
|
|
return missingAdminScope;
|
|
}
|
|
|
|
if (pluginsCommand.action === "install") {
|
|
const installed = await installPluginFromPluginsCommand({
|
|
raw: pluginsCommand.spec,
|
|
config: structuredClone(loaded.config),
|
|
});
|
|
if (!installed.ok) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `⚠️ ${installed.error}` },
|
|
};
|
|
}
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: `🔌 Installed plugin "${installed.pluginId}". Restart the gateway to load plugins.`,
|
|
},
|
|
};
|
|
}
|
|
|
|
const plugin = findPlugin(loaded.report, pluginsCommand.name);
|
|
if (!plugin) {
|
|
return {
|
|
shouldContinue: false,
|
|
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
|
|
};
|
|
}
|
|
|
|
const next = setPluginEnabledInConfig(
|
|
structuredClone(loaded.config),
|
|
plugin.id,
|
|
pluginsCommand.action === "enable",
|
|
);
|
|
const validated = validateConfigObjectWithPlugins(next);
|
|
if (!validated.ok) {
|
|
const issue = validated.issues[0];
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: `⚠️ Config invalid after /plugins ${pluginsCommand.action} (${issue.path}: ${issue.message}).`,
|
|
},
|
|
};
|
|
}
|
|
await writeConfigFile(validated.config);
|
|
|
|
return {
|
|
shouldContinue: false,
|
|
reply: {
|
|
text: `🔌 Plugin "${plugin.id}" ${pluginsCommand.action}d in ${loaded.path}. Restart the gateway to apply.`,
|
|
},
|
|
};
|
|
};
|