From 04b9f5fc989b824a56a2f97d0bfdacf6b48d99ee Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 06:14:20 +0100 Subject: [PATCH] fix(cli): avoid directory plugin reinstall prompts --- CHANGELOG.md | 1 + docs/cli/directory.md | 1 + src/cli/directory-cli.test.ts | 32 +++++++ .../channel-plugin-resolution.test.ts | 89 +++++++++++++++++++ .../channel-plugin-resolution.ts | 24 +++-- 5 files changed, 140 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5376b15b58..0707768eb60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- CLI/directory: report unsupported directory operations for installed channel plugins instead of prompting to reinstall the plugin when it lacks a directory adapter. Fixes #75770. Thanks @lawong888. - Feishu: preserve Feishu/Lark HTTP error bodies for message sends, media sends, and chat member lookups, so HTTP 400 failures include vendor code, message, log id, and troubleshooter details. Fixes #73860. Thanks @desksk. - Agents/transcripts: avoid reopening large Pi transcript files through the synchronous session manager for maintenance rewrites, persisted tool-result truncation, manual compaction boundary hardening, and queued compaction rotation. Thanks @mariozechner. - Web search/Exa: accept `plugins.entries.exa.config.webSearch.baseUrl`, normalize it to the Exa `/search` endpoint, and partition cached results by endpoint. Fixes #54928 and supersedes #54939. Thanks @mrpl327 and @lyfuci. diff --git a/docs/cli/directory.md b/docs/cli/directory.md index 5bb7f8d2da0..073a698e0d2 100644 --- a/docs/cli/directory.md +++ b/docs/cli/directory.md @@ -20,6 +20,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m - `directory` is meant to help you find IDs you can paste into other commands (especially `openclaw message send --target ...`). - For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory. +- Installed channel plugins can still omit directory support; in that case the command reports the unsupported directory operation instead of reinstalling the plugin. - Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting. ## Using results with `message send` diff --git a/src/cli/directory-cli.test.ts b/src/cli/directory-cli.test.ts index 4cdad04c76f..0a7a7b196a8 100644 --- a/src/cli/directory-cli.test.ts +++ b/src/cli/directory-cli.test.ts @@ -233,4 +233,36 @@ describe("registerDirectoryCli", () => { JSON.stringify([{ id: "channel:config", kind: "group" }], null, 2), ); }); + + it("reports unsupported directory capability instead of continuing setup for installed plugins", async () => { + mocks.resolveInstallableChannelPlugin.mockResolvedValue({ + cfg: { channels: { "openclaw-weixin": {} } }, + channelId: "openclaw-weixin", + plugin: { + id: "openclaw-weixin", + }, + configChanged: false, + pluginInstalled: false, + }); + + const program = new Command().name("openclaw"); + registerDirectoryCli(program); + + await expect( + program.parseAsync(["directory", "peers", "list", "--channel", "openclaw-weixin"], { + from: "user", + }), + ).rejects.toThrow("exit:1"); + + expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + rawChannel: "openclaw-weixin", + allowInstall: true, + }), + ); + expect(mocks.replaceConfigFile).not.toHaveBeenCalled(); + expect(runtimeState.defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Channel openclaw-weixin does not support directory peers"), + ); + }); }); diff --git a/src/commands/channel-setup/channel-plugin-resolution.test.ts b/src/commands/channel-setup/channel-plugin-resolution.test.ts index f853795fc7d..8bf90421e71 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.test.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.test.ts @@ -156,4 +156,93 @@ describe("resolveInstallableChannelPlugin", () => { }), ); }); + + it("returns an existing plugin that lacks the requested capability without reinstalling", async () => { + const catalogEntry = createCatalogEntry({ + id: "openclaw-weixin", + pluginId: "@tencent-weixin/openclaw-weixin", + origin: "bundled", + }); + const installedPlugin = createPlugin("openclaw-weixin"); + + mocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + mocks.getChannelPlugin.mockReturnValue(installedPlugin); + + const result = await resolveInstallableChannelPlugin({ + cfg: { plugins: { enabled: true } }, + runtime: {} as never, + rawChannel: "openclaw-weixin", + allowInstall: true, + supports: (plugin) => Boolean(plugin.directory), + }); + + expect(result.plugin).toBe(installedPlugin); + expect(result.pluginInstalled).toBe(false); + expect(result.supportsRequestedCapability).toBe(false); + expect(mocks.ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); + }); + + it("returns a scoped installed plugin that lacks the requested capability without reinstalling", async () => { + const catalogEntry = createCatalogEntry({ + id: "openclaw-weixin", + pluginId: "@tencent-weixin/openclaw-weixin", + origin: "bundled", + }); + const scopedPlugin = createPlugin("openclaw-weixin"); + + mocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [{ plugin: scopedPlugin }], + channelSetups: [], + }); + + const result = await resolveInstallableChannelPlugin({ + cfg: { plugins: { enabled: true } }, + runtime: {} as never, + rawChannel: "openclaw-weixin", + allowInstall: true, + supports: (plugin) => Boolean(plugin.directory), + }); + + expect(result.plugin).toBe(scopedPlugin); + expect(result.pluginInstalled).toBe(false); + expect(result.supportsRequestedCapability).toBe(false); + expect(mocks.ensureChannelSetupPluginInstalled).not.toHaveBeenCalled(); + }); + + it("still offers install when only a setup fallback lacks the requested capability", async () => { + const catalogEntry = createCatalogEntry({ + id: "demo-directory", + pluginId: "@demo/directory", + origin: "bundled", + }); + const setupOnlyPlugin = createPlugin("demo-directory"); + + mocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({ + channels: [], + channelSetups: [{ plugin: setupOnlyPlugin }], + }); + mocks.ensureChannelSetupPluginInstalled.mockResolvedValueOnce({ + cfg: { plugins: { entries: { "@demo/directory": { enabled: true } } } }, + installed: true, + pluginId: "@demo/directory", + status: "installed", + }); + + const result = await resolveInstallableChannelPlugin({ + cfg: { plugins: { enabled: true } }, + runtime: {} as never, + rawChannel: "demo-directory", + allowInstall: true, + supports: (plugin) => Boolean(plugin.directory), + }); + + expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: catalogEntry, + }), + ); + expect(result.pluginInstalled).toBe(true); + }); }); diff --git a/src/commands/channel-setup/channel-plugin-resolution.ts b/src/commands/channel-setup/channel-plugin-resolution.ts index 2ab4c6d734b..34196378683 100644 --- a/src/commands/channel-setup/channel-plugin-resolution.ts +++ b/src/commands/channel-setup/channel-plugin-resolution.ts @@ -32,6 +32,7 @@ type ResolveInstallableChannelPluginResult = { catalogEntry?: ChannelPluginCatalogEntry; configChanged: boolean; pluginInstalled: boolean; + supportsRequestedCapability?: boolean; }; function resolveWorkspaceDir(cfg: OpenClawConfig) { @@ -76,17 +77,21 @@ export function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nu function findScopedChannelPlugin( snapshot: ChannelPluginSnapshot, channelId: ChannelId, + supports: (plugin: ChannelPlugin) => boolean, ): ChannelPlugin | undefined { - return ( - snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ?? - snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin - ); + const runtimePlugin = snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin; + if (runtimePlugin) { + return runtimePlugin; + } + const setupPlugin = snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin; + return setupPlugin && supports(setupPlugin) ? setupPlugin : undefined; } function loadScopedChannelPlugin(params: { cfg: OpenClawConfig; runtime: RuntimeEnv; channelId: ChannelId; + supports: (plugin: ChannelPlugin) => boolean; pluginId?: string; workspaceDir?: string; }): ChannelPlugin | undefined { @@ -97,7 +102,7 @@ function loadScopedChannelPlugin(params: { ...(params.pluginId ? { pluginId: params.pluginId } : {}), workspaceDir: params.workspaceDir, }); - return findScopedChannelPlugin(snapshot, params.channelId); + return findScopedChannelPlugin(snapshot, params.channelId, params.supports); } export async function resolveInstallableChannelPlugin(params: { @@ -136,7 +141,7 @@ export async function resolveInstallableChannelPlugin(params: { } const existing = getChannelPlugin(channelId); - if (existing && supports(existing)) { + if (existing) { return { cfg: nextCfg, channelId, @@ -144,6 +149,7 @@ export async function resolveInstallableChannelPlugin(params: { catalogEntry, configChanged: false, pluginInstalled: false, + supportsRequestedCapability: supports(existing), }; } @@ -153,10 +159,11 @@ export async function resolveInstallableChannelPlugin(params: { cfg: nextCfg, runtime: params.runtime, channelId, + supports, pluginId: resolvedPluginId, workspaceDir, }); - if (scoped && supports(scoped)) { + if (scoped) { return { cfg: nextCfg, channelId, @@ -164,6 +171,7 @@ export async function resolveInstallableChannelPlugin(params: { catalogEntry, configChanged: false, pluginInstalled: false, + supportsRequestedCapability: supports(scoped), }; } @@ -182,6 +190,7 @@ export async function resolveInstallableChannelPlugin(params: { cfg: nextCfg, runtime: params.runtime, channelId, + supports, pluginId: installedPluginId, workspaceDir: resolveWorkspaceDir(nextCfg), }) @@ -196,6 +205,7 @@ export async function resolveInstallableChannelPlugin(params: { : catalogEntry, configChanged: nextCfg !== params.cfg, pluginInstalled: installResult.installed, + supportsRequestedCapability: installedPlugin ? supports(installedPlugin) : undefined, }; } }