mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
feat: add slash plugin installs
This commit is contained in:
@@ -45,6 +45,17 @@ with OpenClaw), others are **external** (published on npm by the community).
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
If you prefer chat-native control, enable `commands.plugins: true` and use:
|
||||
|
||||
```text
|
||||
/plugin install clawhub:@openclaw/voice-call
|
||||
/plugin show voice-call
|
||||
/plugin enable voice-call
|
||||
```
|
||||
|
||||
The install path uses the same resolver as the CLI: local path/archive, explicit
|
||||
`clawhub:<pkg>`, or bare package spec (ClawHub first, then npm fallback).
|
||||
|
||||
## Plugin types
|
||||
|
||||
OpenClaw recognizes two plugin formats:
|
||||
|
||||
@@ -62,7 +62,7 @@ They run immediately, are stripped before the model sees the message, and the re
|
||||
- `commands.bashForegroundMs` (default `2000`) controls how long bash waits before switching to background mode (`0` backgrounds immediately).
|
||||
- `commands.config` (default `false`) enables `/config` (reads/writes `openclaw.json`).
|
||||
- `commands.mcp` (default `false`) enables `/mcp` (reads/writes OpenClaw-managed MCP config under `mcp.servers`).
|
||||
- `commands.plugins` (default `false`) enables `/plugins` (plugin discovery/status plus enable/disable toggles).
|
||||
- `commands.plugins` (default `false`) enables `/plugins` (plugin discovery/status plus install + enable/disable controls).
|
||||
- `commands.debug` (default `false`) enables `/debug` (runtime-only overrides).
|
||||
- `commands.allowFrom` (optional) sets a per-provider allowlist for command authorization. When configured, it is the
|
||||
only authorization source for commands and directives (channel allowlists/pairing and `commands.useAccessGroups`
|
||||
@@ -95,8 +95,9 @@ Text + native (when enabled):
|
||||
- `/tell <id|#> <message>` (alias for `/steer`)
|
||||
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
|
||||
- `/mcp show|get|set|unset` (manage OpenClaw MCP server config, owner-only; requires `commands.mcp: true`)
|
||||
- `/plugins list|show|get|enable|disable` (inspect discovered plugins and toggle enablement, owner-only for writes; requires `commands.plugins: true`)
|
||||
- `/plugins list|show|get|install|enable|disable` (inspect discovered plugins, install new ones, and toggle enablement; owner-only for writes; requires `commands.plugins: true`)
|
||||
- `/plugin` is an alias for `/plugins`.
|
||||
- `/plugin install <spec>` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, or `clawhub:<pkg>`.
|
||||
- Enable/disable writes still reply with a restart hint. On a watched foreground gateway, OpenClaw may perform that restart automatically right after the write.
|
||||
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
|
||||
- `/usage off|tokens|full|cost` (per-response usage footer or local cost summary)
|
||||
|
||||
@@ -503,6 +503,70 @@ gateway_log="/tmp/openclaw-plugin-command-e2e.log"
|
||||
start_gateway "$gateway_log"
|
||||
wait_for_gateway_health
|
||||
|
||||
echo "Testing /plugin install with auto-restart..."
|
||||
slash_install_dir="$(mktemp -d "/tmp/openclaw-plugin-slash-install.XXXXXX")"
|
||||
cat > "$slash_install_dir/package.json" <<'JSON'
|
||||
{
|
||||
"name": "@openclaw/slash-install-plugin",
|
||||
"version": "0.0.1",
|
||||
"openclaw": { "extensions": ["./index.js"] }
|
||||
}
|
||||
JSON
|
||||
cat > "$slash_install_dir/index.js" <<'JS'
|
||||
module.exports = {
|
||||
id: "slash-install-plugin",
|
||||
name: "Slash Install Plugin",
|
||||
register(api) {
|
||||
api.registerGatewayMethod("demo.slash.install", async () => ({ ok: true }));
|
||||
},
|
||||
};
|
||||
JS
|
||||
cat > "$slash_install_dir/openclaw.plugin.json" <<'JSON'
|
||||
{
|
||||
"id": "slash-install-plugin",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
JSON
|
||||
|
||||
run_gateway_chat_json \
|
||||
"plugin-e2e-install" \
|
||||
"/plugin install $slash_install_dir" \
|
||||
/tmp/plugin-command-install.json \
|
||||
30000
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install.json", "utf8"));
|
||||
const text = payload.text || "";
|
||||
if (!text.includes('Installed plugin "slash-install-plugin"')) {
|
||||
throw new Error(`expected install confirmation, got:\n${text}`);
|
||||
}
|
||||
if (!text.includes("Restart the gateway to load plugins.")) {
|
||||
throw new Error(`expected restart hint, got:\n${text}`);
|
||||
}
|
||||
console.log("ok");
|
||||
NODE
|
||||
|
||||
wait_for_gateway_health
|
||||
run_gateway_chat_json "plugin-e2e-install-show" "/plugin show slash-install-plugin" /tmp/plugin-command-install-show.json
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const payload = JSON.parse(fs.readFileSync("/tmp/plugin-command-install-show.json", "utf8"));
|
||||
const text = payload.text || "";
|
||||
if (!text.includes('"status": "loaded"')) {
|
||||
throw new Error(`expected loaded status after slash install, got:\n${text}`);
|
||||
}
|
||||
if (!text.includes('"enabled": true')) {
|
||||
throw new Error(`expected enabled status after slash install, got:\n${text}`);
|
||||
}
|
||||
if (!text.includes('"demo.slash.install"')) {
|
||||
throw new Error(`expected installed gateway method, got:\n${text}`);
|
||||
}
|
||||
console.log("ok");
|
||||
NODE
|
||||
|
||||
run_gateway_chat_json "plugin-e2e-list" "/plugin list" /tmp/plugin-command-list.json
|
||||
node - <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
|
||||
201
src/auto-reply/reply/commands-plugins.install.test.ts
Normal file
201
src/auto-reply/reply/commands-plugins.install.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
169
src/auto-reply/reply/commands-plugins.toggle.test.ts
Normal file
169
src/auto-reply/reply/commands-plugins.toggle.test.ts
Normal 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 }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]",
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user