feat: add slash plugin installs

This commit is contained in:
Peter Steinberger
2026-03-23 00:18:47 -07:00
parent deecf68b59
commit abbd1b6b8a
8 changed files with 635 additions and 48 deletions

View File

@@ -0,0 +1,201 @@
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../config/home-env.test-harness.js";
import { handleCommands } from "./commands-core.js";
import { createCommandWorkspaceHarness } from "./commands-filesystem.test-support.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
const installPluginFromPathMock = vi.fn();
const installPluginFromClawHubMock = vi.fn();
const persistPluginInstallMock = vi.fn();
vi.mock("../../plugins/install.js", async () => {
const actual = await vi.importActual<typeof import("../../plugins/install.js")>(
"../../plugins/install.js",
);
return {
...actual,
installPluginFromPath: installPluginFromPathMock,
};
});
vi.mock("../../plugins/clawhub.js", async () => {
const actual = await vi.importActual<typeof import("../../plugins/clawhub.js")>(
"../../plugins/clawhub.js",
);
return {
...actual,
installPluginFromClawHub: installPluginFromClawHubMock,
};
});
vi.mock("../../cli/plugins-install-persist.js", () => ({
persistPluginInstall: persistPluginInstallMock,
}));
const workspaceHarness = createCommandWorkspaceHarness("openclaw-command-plugins-install-");
describe("handleCommands /plugins install", () => {
afterEach(async () => {
installPluginFromPathMock.mockReset();
installPluginFromClawHubMock.mockReset();
persistPluginInstallMock.mockReset();
await workspaceHarness.cleanupWorkspaces();
});
it("installs a plugin from a local path", async () => {
installPluginFromPathMock.mockResolvedValue({
ok: true,
pluginId: "path-install-plugin",
targetDir: "/tmp/path-install-plugin",
version: "0.0.1",
extensions: ["index.js"],
});
persistPluginInstallMock.mockResolvedValue({});
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
const pluginDir = path.join(workspaceDir, "fixtures", "path-install-plugin");
await fs.mkdir(pluginDir, { recursive: true });
const params = buildCommandTestParams(
`/plugins install ${pluginDir}`,
{
commands: {
text: true,
plugins: true,
},
},
undefined,
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Installed plugin "path-install-plugin"');
expect(installPluginFromPathMock).toHaveBeenCalledWith(
expect.objectContaining({
path: pluginDir,
}),
);
expect(persistPluginInstallMock).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "path-install-plugin",
install: expect.objectContaining({
source: "path",
sourcePath: pluginDir,
installPath: "/tmp/path-install-plugin",
version: "0.0.1",
}),
}),
);
});
});
it("installs from an explicit clawhub: spec", async () => {
installPluginFromClawHubMock.mockResolvedValue({
ok: true,
pluginId: "clawhub-demo",
targetDir: "/tmp/clawhub-demo",
version: "1.2.3",
extensions: ["index.js"],
packageName: "@openclaw/clawhub-demo",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "@openclaw/clawhub-demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
version: "1.2.3",
integrity: "sha512-demo",
resolvedAt: "2026-03-22T12:00:00.000Z",
},
});
persistPluginInstallMock.mockResolvedValue({});
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
const params = buildCommandTestParams(
"/plugins install clawhub:@openclaw/clawhub-demo@1.2.3",
{
commands: {
text: true,
plugins: true,
},
},
undefined,
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Installed plugin "clawhub-demo"');
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/clawhub-demo@1.2.3",
}),
);
expect(persistPluginInstallMock).toHaveBeenCalledWith(
expect.objectContaining({
pluginId: "clawhub-demo",
install: expect.objectContaining({
source: "clawhub",
spec: "clawhub:@openclaw/clawhub-demo@1.2.3",
installPath: "/tmp/clawhub-demo",
version: "1.2.3",
integrity: "sha512-demo",
clawhubPackage: "@openclaw/clawhub-demo",
clawhubChannel: "official",
}),
}),
);
});
});
it("treats /plugin add as an install alias", async () => {
installPluginFromClawHubMock.mockResolvedValue({
ok: true,
pluginId: "alias-demo",
targetDir: "/tmp/alias-demo",
version: "1.0.0",
extensions: ["index.js"],
packageName: "@openclaw/alias-demo",
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "@openclaw/alias-demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
version: "1.0.0",
integrity: "sha512-alias",
resolvedAt: "2026-03-23T12:00:00.000Z",
},
});
persistPluginInstallMock.mockResolvedValue({});
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
const params = buildCommandTestParams(
"/plugin add clawhub:@openclaw/alias-demo@1.0.0",
{
commands: {
text: true,
plugins: true,
},
},
undefined,
{ workspaceDir },
);
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Installed plugin "alias-demo"');
expect(installPluginFromClawHubMock).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:@openclaw/alias-demo@1.0.0",
}),
);
});
});
});

View File

@@ -23,6 +23,9 @@ async function createClaudeBundlePlugin(params: { workspaceDir: string; pluginId
function buildCfg(): OpenClawConfig {
return {
plugins: {
enabled: true,
},
commands: {
text: true,
plugins: true,
@@ -81,50 +84,6 @@ describe("handleCommands /plugins", () => {
});
});
it("enables and disables a discovered plugin", async () => {
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" });
const enableParams = buildCommandTestParams(
"/plugins enable superpowers",
buildCfg(),
undefined,
{
workspaceDir,
},
);
enableParams.command.senderIsOwner = true;
const enableResult = await handleCommands(enableParams);
expect(enableResult.reply?.text).toContain('Plugin "superpowers" enabled');
const showEnabledParams = buildCommandTestParams(
"/plugins show superpowers",
buildCfg(),
undefined,
{
workspaceDir,
},
);
showEnabledParams.command.senderIsOwner = true;
const showEnabledResult = await handleCommands(showEnabledParams);
expect(showEnabledResult.reply?.text).toContain('"status": "loaded"');
expect(showEnabledResult.reply?.text).toContain('"enabled": true');
const disableParams = buildCommandTestParams(
"/plugins disable superpowers",
buildCfg(),
undefined,
{
workspaceDir,
},
);
disableParams.command.senderIsOwner = true;
const disableResult = await handleCommands(disableParams);
expect(disableResult.reply?.text).toContain('Plugin "superpowers" disabled');
});
});
it("rejects internal writes without operator.admin", async () => {
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();

View File

@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const {
readConfigFileSnapshotMock,
validateConfigObjectWithPluginsMock,
writeConfigFileMock,
buildPluginStatusReportMock,
} = vi.hoisted(() => ({
readConfigFileSnapshotMock: vi.fn(),
validateConfigObjectWithPluginsMock: vi.fn(),
writeConfigFileMock: vi.fn(),
buildPluginStatusReportMock: vi.fn(),
}));
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
vi.mock("../../plugins/status.js", async () => {
const actual =
await vi.importActual<typeof import("../../plugins/status.js")>("../../plugins/status.js");
return {
...actual,
buildPluginStatusReport: buildPluginStatusReportMock,
};
});
import { handleCommands } from "./commands-core.js";
import { buildCommandTestParams } from "./commands.test-harness.js";
function buildCfg() {
return {
plugins: {
enabled: true,
},
commands: {
text: true,
plugins: true,
},
};
}
describe("handleCommands /plugins toggle", () => {
afterEach(() => {
readConfigFileSnapshotMock.mockReset();
validateConfigObjectWithPluginsMock.mockReset();
writeConfigFileMock.mockReset();
buildPluginStatusReportMock.mockReset();
});
it("enables a discovered plugin", async () => {
const config = buildCfg();
readConfigFileSnapshotMock.mockResolvedValue({
valid: true,
path: "/tmp/openclaw.json",
resolved: config,
});
buildPluginStatusReportMock.mockReturnValue({
workspaceDir: "/tmp/workspace",
plugins: [
{
id: "superpowers",
name: "superpowers",
format: "bundle",
source: "/tmp/workspace/.openclaw/extensions/superpowers",
origin: "workspace",
enabled: false,
status: "disabled",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
});
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
writeConfigFileMock.mockResolvedValue(undefined);
const params = buildCommandTestParams("/plugins enable superpowers", buildCfg());
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Plugin "superpowers" enabled');
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
superpowers: expect.objectContaining({ enabled: true }),
}),
}),
}),
);
});
it("disables a discovered plugin", async () => {
const config = buildCfg();
readConfigFileSnapshotMock.mockResolvedValue({
valid: true,
path: "/tmp/openclaw.json",
resolved: config,
});
buildPluginStatusReportMock.mockReturnValue({
workspaceDir: "/tmp/workspace",
plugins: [
{
id: "superpowers",
name: "superpowers",
format: "bundle",
source: "/tmp/workspace/.openclaw/extensions/superpowers",
origin: "workspace",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: [],
speechProviderIds: [],
mediaUnderstandingProviderIds: [],
imageGenerationProviderIds: [],
webSearchProviderIds: [],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [],
});
validateConfigObjectWithPluginsMock.mockImplementation((next) => ({ ok: true, config: next }));
writeConfigFileMock.mockResolvedValue(undefined);
const params = buildCommandTestParams("/plugins disable superpowers", buildCfg());
params.command.senderIsOwner = true;
const result = await handleCommands(params);
expect(result.reply?.text).toContain('Plugin "superpowers" disabled');
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
plugins: expect.objectContaining({
entries: expect.objectContaining({
superpowers: expect.objectContaining({ enabled: false }),
}),
}),
}),
);
});
});

View File

@@ -1,3 +1,12 @@
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,
@@ -5,6 +14,11 @@ import {
} 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,
@@ -14,6 +28,7 @@ import {
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,
@@ -121,6 +136,142 @@ function findPlugin(report: PluginStatusReport, rawName: string): PluginRecord |
);
}
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;
@@ -226,6 +377,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
reply: {
text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, {
...payload.inspect,
compatibilityWarnings: payload.compatibilityWarnings,
install: payload.install,
}),
},
@@ -235,12 +387,31 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
const missingAdminScope = requireGatewayClientScopeForInternalChannel(params, {
label: "/plugins write",
allowedScopes: ["operator.admin"],
missingText: "❌ /plugins enable|disable requires operator.admin for gateway clients.",
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 {

View File

@@ -1,6 +1,7 @@
export type PluginsCommand =
| { action: "list" }
| { action: "inspect"; name?: string }
| { action: "install"; spec: string }
| { action: "enable"; name: string }
| { action: "disable"; name: string }
| { action: "error"; message: string };
@@ -33,6 +34,16 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null {
return { action: "inspect", name: name || undefined };
}
if (action === "install" || action === "add") {
if (!name) {
return {
action: "error",
message: "Usage: /plugins install <path|archive|npm-spec|clawhub:pkg>",
};
}
return { action: "install", spec: name };
}
if (action === "enable" || action === "disable") {
if (!name) {
return {
@@ -45,6 +56,6 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null {
return {
action: "error",
message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]",
message: "Usage: /plugins list|inspect|show|get|install|enable|disable [plugin]",
};
}