From abbd1b6b8a9066499459d0f3f75e3701595b51ec Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 23 Mar 2026 00:18:47 -0700 Subject: [PATCH] feat: add slash plugin installs --- docs/tools/plugin.md | 11 + docs/tools/slash-commands.md | 5 +- scripts/e2e/plugins-docker.sh | 64 ++++++ .../reply/commands-plugins.install.test.ts | 201 ++++++++++++++++++ src/auto-reply/reply/commands-plugins.test.ts | 47 +--- .../reply/commands-plugins.toggle.test.ts | 169 +++++++++++++++ src/auto-reply/reply/commands-plugins.ts | 173 ++++++++++++++- src/auto-reply/reply/plugins-commands.ts | 13 +- 8 files changed, 635 insertions(+), 48 deletions(-) create mode 100644 src/auto-reply/reply/commands-plugins.install.test.ts create mode 100644 src/auto-reply/reply/commands-plugins.toggle.test.ts diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 120fdc64e12..979cb9b698e 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -45,6 +45,17 @@ with OpenClaw), others are **external** (published on npm by the community). +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:`, or bare package spec (ClawHub first, then npm fallback). + ## Plugin types OpenClaw recognizes two plugin formats: diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 4dfb6fd74f7..beddb83a635 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -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 ` (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 ` accepts the same plugin specs as `openclaw plugins install`: local path/archive, npm package, or `clawhub:`. - 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) diff --git a/scripts/e2e/plugins-docker.sh b/scripts/e2e/plugins-docker.sh index 19222e59520..2feece5c880 100755 --- a/scripts/e2e/plugins-docker.sh +++ b/scripts/e2e/plugins-docker.sh @@ -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"); diff --git a/src/auto-reply/reply/commands-plugins.install.test.ts b/src/auto-reply/reply/commands-plugins.install.test.ts new file mode 100644 index 00000000000..fb14800a903 --- /dev/null +++ b/src/auto-reply/reply/commands-plugins.install.test.ts @@ -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( + "../../plugins/install.js", + ); + return { + ...actual, + installPluginFromPath: installPluginFromPathMock, + }; +}); + +vi.mock("../../plugins/clawhub.js", async () => { + const actual = await vi.importActual( + "../../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", + }), + ); + }); + }); +}); diff --git a/src/auto-reply/reply/commands-plugins.test.ts b/src/auto-reply/reply/commands-plugins.test.ts index 02e7fc948c6..92cf193292b 100644 --- a/src/auto-reply/reply/commands-plugins.test.ts +++ b/src/auto-reply/reply/commands-plugins.test.ts @@ -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(); diff --git a/src/auto-reply/reply/commands-plugins.toggle.test.ts b/src/auto-reply/reply/commands-plugins.toggle.test.ts new file mode 100644 index 00000000000..0f788da6ccf --- /dev/null +++ b/src/auto-reply/reply/commands-plugins.toggle.test.ts @@ -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("../../config/config.js"); + return { + ...actual, + readConfigFileSnapshot: readConfigFileSnapshotMock, + validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock, + writeConfigFile: writeConfigFileMock, + }; +}); + +vi.mock("../../plugins/status.js", async () => { + const actual = + await vi.importActual("../../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 }), + }), + }), + }), + ); + }); +}); diff --git a/src/auto-reply/reply/commands-plugins.ts b/src/auto-reply/reply/commands-plugins.ts index 483c64130ab..b8eeb299f9e 100644 --- a/src/auto-reply/reply/commands-plugins.ts +++ b/src/auto-reply/reply/commands-plugins.ts @@ -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 { diff --git a/src/auto-reply/reply/plugins-commands.ts b/src/auto-reply/reply/plugins-commands.ts index 95da9d8bc2b..5c6754da0f6 100644 --- a/src/auto-reply/reply/plugins-commands.ts +++ b/src/auto-reply/reply/plugins-commands.ts @@ -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 ", + }; + } + 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]", }; }