refactor(channels): move channel-flavored setup flags into plugin manifests (#112239)

* refactor(channels): move channel-flavored setup flags into plugin manifests

* refactor(channels): normalize manifest cliAddOptions formatting, drop changelog entry

* fix(channels): dedupe channels add options by commander switch identity

* fix(channels): let the selected channel's cliAddOptions win switch dedupe
This commit is contained in:
Peter Steinberger
2026-07-21 02:50:19 -07:00
committed by GitHub
parent f53e9f6e1c
commit d725632ee7
27 changed files with 824 additions and 118 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ openclaw channels remove --channel telegram --delete
`channels remove` only operates on installed/configured channel plugins. Use `channels add` first for installable catalog channels. Without `--delete` it asks to disable the account and keeps its config; `--delete` removes the config entries without prompting.
For runtime-backed channel plugins, `channels remove` also asks the running Gateway to stop the selected account before it updates config, so disabling or deleting an account does not leave the old listener active until restart.
Non-interactive add flags shared across channels: `--account <id>`, `--name <name>`, `--token`, `--token-file`, `--bot-token`, `--app-token`, `--secret`, `--secret-file`, `--password`, `--cli-path`, `--url`, `--base-url`, `--http-url`, `--auth-dir`, and `--use-env` (env-backed auth, default account only, where supported). Channel-specific flags include:
Core-owned non-interactive add flags are `--account <id>`, `--name <name>`, `--token`, `--token-file`, and `--use-env` (env-backed auth, default account only, where supported). Channel plugins contribute their own setup flags, including `--bot-token`, `--app-token`, `--secret`, `--secret-file`, `--password`, `--cli-path`, `--url`, `--base-url`, `--workspace`, `--http-url`, and `--auth-dir`. Channel-specific flags include:
| Channel | Flags |
| ----------- | ---------------------------------------------------------------------------------------------------- |
+22 -19
View File
@@ -1186,28 +1186,31 @@ Some pre-runtime plugin metadata intentionally lives in `package.json` under the
Important examples:
| Field | What it means |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. |
| `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.approvalFlags` | Closed approval behavior flags available before runtime load. `native` means the channel owns native approval UI and same-turn resolution. |
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
| `openclaw.install.clawhubSpec` / `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
| Field | What it means |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `openclaw.extensions` | Declares native plugin entrypoints. Must stay inside the plugin package directory. |
| `openclaw.runtimeExtensions` | Declares built JavaScript runtime entrypoints for installed packages. Must stay inside the plugin package directory. |
| `openclaw.setupEntry` | Lightweight setup-only entrypoint used during onboarding, deferred channel startup, and read-only channel status/SecretRef discovery. Must stay inside the plugin package directory. |
| `openclaw.runtimeSetupEntry` | Declares the built JavaScript setup entrypoint for installed packages. Requires `setupEntry`, must exist, and must stay inside the plugin package directory. |
| `openclaw.channel` | Cheap channel catalog metadata like labels, docs paths, aliases, and selection copy. |
| `openclaw.channel.approvalFlags` | Closed approval behavior flags available before runtime load. `native` means the channel owns native approval UI and same-turn resolution. |
| `openclaw.channel.commands` | Static native command and native skill auto-default metadata used by config, audit, and command-list surfaces before channel runtime loads. |
| `openclaw.channel.cliAddOptions` | Plugin-owned `openclaw channels add` options. Each entry declares `flags`, `description`, optional `defaultValue`, and optional `valueType` (`int` or `list`) for generic input coercion. |
| `openclaw.channel.configuredState` | Lightweight configured-state checker metadata that can answer "does env-only setup already exist?" without loading the full channel runtime. |
| `openclaw.channel.persistedAuthState` | Lightweight persisted-auth checker metadata that can answer "is anything already signed in?" without loading the full channel runtime. |
| `openclaw.install.clawhubSpec` / `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. |
| `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. |
| `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22` or `>=2026.5.1-beta.1`. |
| `openclaw.compat.pluginApi` | Minimum OpenClaw plugin API range required by this package, using a semver floor like `>=2026.5.27`. |
| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. |
| `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. |
| `openclaw.install.requiredPlatformPackages` | npm package aliases that must materialize when their lockfile platform constraints match the current host. |
| `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-runtime channel surfaces load before listen, then defers the full configured channel plugin until post-listen activation. |
Manifest metadata decides which provider/channel/setup choices appear in onboarding before runtime loads. `package.json#openclaw.install` tells onboarding how to fetch or enable that plugin when the user picks one of those choices. Do not move install hints into `openclaw.plugin.json`.
For `openclaw.channel.cliAddOptions`, use Commander's long-option syntax, such as `--initial-sync-limit <n>`. Set `valueType: "int"` to parse a non-negative integer or `valueType: "list"` to split comma-, semicolon-, or newline-delimited input into strings before the plugin setup adapter receives it. Omit `valueType` to pass the parsed Commander value through unchanged.
`openclaw.install.minHostVersion` is enforced during install and manifest registry loading for non-bundled plugin sources. Invalid values are rejected; newer-but-valid values skip external plugins on older hosts. Bundled source plugins are assumed to be co-versioned with the host checkout.
`openclaw.install.requiredPlatformPackages` is for npm packages that expose required native binaries through optional, platform-specific aliases. List the bare npm package name for every supported platform alias. During npm install, OpenClaw verifies only the declared alias whose lockfile constraints match the current host. If npm reports success but omits that alias, OpenClaw retries once with a fresh cache and rolls back the install if the alias is still missing.
+4
View File
@@ -53,6 +53,10 @@
"nativeSkillsAutoEnabled": false
},
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--code <code>",
"description": "ClickClack one-time setup code or setup URL"
+4
View File
@@ -32,6 +32,10 @@
],
"systemImage": "message.fill",
"cliAddOptions": [
{
"flags": "--cli-path <path>",
"description": "Channel CLI path"
},
{
"flags": "--db-path <path>",
"description": "iMessage database path"
+7 -1
View File
@@ -37,7 +37,13 @@
"aliases": [
"internet-relay-chat"
],
"systemImage": "network"
"systemImage": "network",
"cliAddOptions": [
{
"flags": "--password <password>",
"description": "Channel password or login secret"
}
]
},
"compat": {
"pluginApi": ">=2026.7.2"
+7 -1
View File
@@ -46,7 +46,13 @@
"blurb": "LINE Messaging API webhook bot.",
"systemImage": "message",
"order": 75,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
}
]
},
"install": {
"npmSpec": "@openclaw/line",
+6 -1
View File
@@ -83,13 +83,18 @@
"flags": "--access-token <token>",
"description": "Matrix access token"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--device-name <name>",
"description": "Matrix device name"
},
{
"flags": "--initial-sync-limit <n>",
"description": "Matrix initial sync limit"
"description": "Matrix initial sync limit",
"valueType": "int"
}
],
"persistedAuthState": {
+11 -1
View File
@@ -43,7 +43,17 @@
"docsPath": "/channels/mattermost",
"docsLabel": "mattermost",
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
"order": 65
"order": 65,
"cliAddOptions": [
{
"flags": "--bot-token <token>",
"description": "Bot token"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/mattermost",
+23 -1
View File
@@ -44,7 +44,29 @@
"nc"
],
"order": 65,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--secret <secret>",
"description": "Channel shared secret"
},
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--url <url>",
"description": "Channel setup URL"
}
]
},
"install": {
"npmSpec": "@openclaw/nextcloud-talk",
@@ -31,6 +31,10 @@ type NextcloudSetupInput = ChannelSetupInput & {
};
type NextcloudTalkSection = NonNullable<CoreConfig["channels"]>["nextcloud-talk"];
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function addWildcardAllowFrom(allowFrom?: Array<string | number> | null): string[] {
return mergeAllowFromEntries(allowFrom, ["*"]);
}
@@ -204,6 +208,12 @@ export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
prepareAccountConfigInput: ({ input }) => ({
...input,
baseUrl: input.baseUrl ?? readOptionalString(input.url),
secret: input.secret ?? readOptionalString(input.token) ?? readOptionalString(input.password),
secretFile: input.secretFile ?? readOptionalString(input.tokenFile),
}),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
@@ -300,6 +300,41 @@ describe("nextcloud talk setup", () => {
});
});
it("normalizes legacy CLI aliases before applying account config", async () => {
const prepareInput = nextcloudTalkSetupAdapter.prepareAccountConfigInput;
expect(prepareInput).toBeTypeOf("function");
if (!prepareInput) {
throw new Error("Expected Nextcloud Talk setup prepareAccountConfigInput");
}
const prepared = await prepareInput({
cfg: {},
accountId: DEFAULT_ACCOUNT_ID,
input: {
url: "https://cloud.example.com",
token: "bot-secret",
tokenFile: "/tmp/bot-secret",
},
runtime: {} as never,
});
expect(prepared).toEqual({
url: "https://cloud.example.com",
token: "bot-secret",
tokenFile: "/tmp/bot-secret",
baseUrl: "https://cloud.example.com",
secret: "bot-secret",
secretFile: "/tmp/bot-secret",
});
const passwordPrepared = await prepareInput({
cfg: {},
accountId: DEFAULT_ACCOUNT_ID,
input: { password: "legacy-secret" },
runtime: {} as never,
});
expect(passwordPrepared).toMatchObject({ secret: "legacy-secret" });
});
it("clears stored bot secret fields when switching the default account to env", () => {
type ApplyAccountConfigContext = Parameters<
typeof nextcloudTalkSetupAdapter.applyAccountConfig
+7 -1
View File
@@ -45,7 +45,13 @@
"configured": false,
"setup": false,
"docs": false
}
},
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
}
]
}
}
}
+8
View File
@@ -26,6 +26,14 @@
"systemImage": "antenna.radiowaves.left.and.right",
"markdownCapable": true,
"cliAddOptions": [
{
"flags": "--cli-path <path>",
"description": "Channel CLI path"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
},
{
"flags": "--signal-number <e164>",
"description": "Signal account number (E.164)"
+11 -1
View File
@@ -56,7 +56,17 @@
"commands": {
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
}
},
"cliAddOptions": [
{
"flags": "--bot-token <token>",
"description": "Bot token"
},
{
"flags": "--app-token <token>",
"description": "App token"
}
]
},
"install": {
"npmSpec": "@openclaw/slack",
+7 -1
View File
@@ -34,7 +34,13 @@
"docsPath": "/channels/synology-chat",
"docsLabel": "synology-chat",
"blurb": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.",
"order": 90
"order": 90,
"cliAddOptions": [
{
"flags": "--url <url>",
"description": "Channel setup URL"
}
]
},
"install": {
"npmSpec": "@openclaw/synology-chat",
+8 -2
View File
@@ -41,6 +41,10 @@
"order": 90,
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--url <url>",
"description": "Channel setup URL"
},
{
"flags": "--ship <ship>",
"description": "Tlon ship name (~sampel-palnet)"
@@ -51,11 +55,13 @@
},
{
"flags": "--group-channels <list>",
"description": "Tlon group channels (comma-separated)"
"description": "Tlon group channels (comma-separated)",
"valueType": "list"
},
{
"flags": "--dm-allowlist <list>",
"description": "Tlon DM allowlist (comma-separated ships)"
"description": "Tlon DM allowlist (comma-separated ships)",
"valueType": "list"
},
{
"flags": "--auto-discover-channels",
@@ -184,7 +184,21 @@
"commands": {
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
}
},
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--code <code>",
"description": "ClickClack one-time setup code or setup URL"
},
{
"flags": "--workspace <workspace>",
"description": "ClickClack workspace id, slug, or name"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/clickclack",
@@ -324,7 +338,13 @@
"IRC_NICKSERV_PASSWORD",
"IRC_NICKSERV_REGISTER_EMAIL"
],
"systemImage": "network"
"systemImage": "network",
"cliAddOptions": [
{
"flags": "--password <password>",
"description": "Channel password or login secret"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/irc",
@@ -351,7 +371,13 @@
"blurb": "LINE Messaging API webhook bot.",
"systemImage": "message",
"order": 75,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
}
]
},
"install": {
"npmSpec": "@openclaw/line",
@@ -377,7 +403,17 @@
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_URL"
],
"order": 65
"order": 65,
"cliAddOptions": [
{
"flags": "--bot-token <token>",
"description": "Bot token"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/mattermost",
@@ -423,13 +459,18 @@
"flags": "--access-token <token>",
"description": "Matrix access token"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--device-name <name>",
"description": "Matrix device name"
},
{
"flags": "--initial-sync-limit <n>",
"description": "Matrix initial sync limit"
"description": "Matrix initial sync limit",
"valueType": "int"
}
]
},
@@ -480,7 +521,29 @@
"blurb": "Self-hosted chat via Nextcloud Talk webhook bots.",
"aliases": ["nc-talk", "nc"],
"order": 65,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--secret <secret>",
"description": "Channel shared secret"
},
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--url <url>",
"description": "Channel setup URL"
}
]
},
"install": {
"npmSpec": "@openclaw/nextcloud-talk",
@@ -503,7 +566,17 @@
"docsLabel": "nostr",
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
"order": 55,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--private-key <key>",
"description": "Nostr private key (nsec... or hex)"
},
{
"flags": "--relay-urls <list>",
"description": "Nostr relay URLs (comma-separated)"
}
]
},
"install": {
"npmSpec": "@openclaw/nostr",
@@ -552,6 +625,14 @@
"systemImage": "antenna.radiowaves.left.and.right",
"markdownCapable": true,
"cliAddOptions": [
{
"flags": "--cli-path <path>",
"description": "Channel CLI path"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
},
{
"flags": "--signal-number <e164>",
"description": "Signal account number (E.164)"
@@ -590,7 +671,17 @@
"docsLabel": "slack",
"blurb": "supported (Socket Mode).",
"systemImage": "number",
"markdownCapable": true
"markdownCapable": true,
"cliAddOptions": [
{
"flags": "--bot-token <token>",
"description": "Bot token"
},
{
"flags": "--app-token <token>",
"description": "App token"
}
]
},
"channelConfigs": {
"slack": {
@@ -659,7 +750,13 @@
"docsPath": "/channels/synology-chat",
"docsLabel": "synology-chat",
"blurb": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.",
"order": 90
"order": 90,
"cliAddOptions": [
{
"flags": "--url <url>",
"description": "Channel setup URL"
}
]
},
"install": {
"npmSpec": "@openclaw/synology-chat",
@@ -732,7 +829,39 @@
"docsLabel": "tlon",
"blurb": "decentralized messaging on Urbit; install the plugin to enable.",
"order": 90,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--url <url>",
"description": "Channel setup URL"
},
{
"flags": "--ship <ship>",
"description": "Tlon ship name (~sampel-palnet)"
},
{
"flags": "--code <code>",
"description": "Tlon login code"
},
{
"flags": "--group-channels <list>",
"description": "Tlon group channels (comma-separated)",
"valueType": "list"
},
{
"flags": "--dm-allowlist <list>",
"description": "Tlon DM allowlist (comma-separated ships)",
"valueType": "list"
},
{
"flags": "--auto-discover-channels",
"description": "Tlon auto-discover group channels"
},
{
"flags": "--no-auto-discover-channels",
"description": "Disable Tlon auto-discovery"
}
]
},
"install": {
"npmSpec": "@openclaw/tlon",
@@ -776,7 +905,13 @@
"docsPath": "/channels/whatsapp",
"docsLabel": "whatsapp",
"blurb": "works with your own number; recommend a separate phone + eSIM.",
"systemImage": "message"
"systemImage": "message",
"cliAddOptions": [
{
"flags": "--auth-dir <path>",
"description": "WhatsApp auth directory override"
}
]
},
"install": {
"clawhubSpec": "clawhub:@openclaw/whatsapp",
+2
View File
@@ -52,6 +52,7 @@ export type ChannelPluginCatalogEntry = {
pluginId?: string;
origin?: PluginOrigin;
trustedSourceLinkedOfficialInstall?: boolean;
channel?: PluginPackageChannel;
meta: ChannelMeta;
install: ChannelPluginCatalogInstall;
installSource?: PluginInstallSourceInfo;
@@ -398,6 +399,7 @@ function buildCatalogEntryFromManifest(params: {
...(params.trustedSourceLinkedOfficialInstall
? { trustedSourceLinkedOfficialInstall: true }
: {}),
channel: params.channel,
meta,
install,
installSource: describePluginInstallSource(install, {
+86
View File
@@ -0,0 +1,86 @@
// Resolves plugin-owned `channels add` CLI options from bundled and catalog metadata.
import { Option } from "commander";
import { listBundledPackageChannelMetadata } from "../../plugins/bundled-package-channel-metadata.js";
import type {
PluginPackageChannel,
PluginPackageChannelCliOption,
} from "../../plugins/manifest.js";
import { listRawChannelPluginCatalogEntries } from "./catalog.js";
export type ChannelSetupCliOptionValueMetadata = {
longFlag: string;
valueType: NonNullable<PluginPackageChannelCliOption["valueType"]>;
};
// Commander rejects a second option whose long/short switch matches an existing
// one even when the value placeholder differs, so dedupe by switch identity or
// one plugin's `--url <server>` next to another's `--url <url>` would throw and
// break `channels add` registration entirely.
export function channelCliOptionSwitchKey(flags: string): string {
const option = new Option(flags);
return option.long ?? option.short ?? option.flags;
}
function compareChannels(left: PluginPackageChannel, right: PluginPackageChannel): number {
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
return leftOrder === rightOrder
? (left.id ?? "").localeCompare(right.id ?? "")
: leftOrder - rightOrder;
}
function matchesChannel(channel: PluginPackageChannel, normalizedChannelId: string): boolean {
return (
channel.id?.toLowerCase() === normalizedChannelId ||
Boolean(channel.aliases?.some((alias) => alias.toLowerCase() === normalizedChannelId))
);
}
export function resolveChannelSetupCliOptionMetadata(channelId?: string) {
const bundledChannels = listBundledPackageChannelMetadata().toSorted(compareChannels);
const catalogChannels = listRawChannelPluginCatalogEntries({
excludeWorkspace: true,
excludeOrigins: ["bundled"],
})
.flatMap((entry) => (entry.channel ? [entry.channel] : []))
.toSorted(compareChannels);
const orderedChannels = [...bundledChannels, ...catalogChannels];
const normalizedChannelId = channelId?.trim().toLowerCase();
// The selected channel's declarations win switch-identity dedupe so another
// channel's same-named option cannot control parsing arity or defaults for
// this invocation.
const channels = normalizedChannelId
? [
...orderedChannels.filter((channel) => matchesChannel(channel, normalizedChannelId)),
...orderedChannels.filter((channel) => !matchesChannel(channel, normalizedChannelId)),
]
: orderedChannels;
const seenSwitches = new Set<string>();
const options = channels
.flatMap((channel) => channel.cliAddOptions ?? [])
.filter((option) => {
const key = channelCliOptionSwitchKey(option.flags);
if (seenSwitches.has(key)) {
return false;
}
seenSwitches.add(key);
return true;
});
const valueMetadataByAttributeName = new Map<string, ChannelSetupCliOptionValueMetadata>();
for (const channel of normalizedChannelId
? channels.filter((candidate) => matchesChannel(candidate, normalizedChannelId))
: []) {
for (const option of channel.cliAddOptions ?? []) {
if (!option.valueType) {
continue;
}
const commanderOption = new Option(option.flags);
valueMetadataByAttributeName.set(commanderOption.attributeName(), {
longFlag: commanderOption.long ?? option.flags,
valueType: option.valueType,
});
}
}
return { options, valueMetadataByAttributeName };
}
+149
View File
@@ -1,6 +1,7 @@
// Channels CLI tests cover channel command registration and option parsing.
import { Command } from "commander";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { PluginPackageChannel } from "../plugins/manifest.js";
import { mockProcessPlatform } from "../test-utils/vitest-spies.js";
import { registerChannelsCli } from "./channels-cli.js";
@@ -8,6 +9,9 @@ import { registerChannelsCli } from "./channels-cli.js";
const listBundledPackageChannelMetadataMock = vi.hoisted(() =>
vi.fn<() => readonly PluginPackageChannel[]>(() => []),
);
const listRawChannelPluginCatalogEntriesMock = vi.hoisted(() =>
vi.fn<() => ChannelPluginCatalogEntry[]>(() => []),
);
const channelsAddCommandMock = vi.hoisted(() => vi.fn(async () => undefined));
const runtimeMock = vi.hoisted(() => ({
log: vi.fn(),
@@ -19,6 +23,10 @@ vi.mock("../plugins/bundled-package-channel-metadata.js", () => ({
listBundledPackageChannelMetadata: listBundledPackageChannelMetadataMock,
}));
vi.mock("../channels/plugins/catalog.js", () => ({
listRawChannelPluginCatalogEntries: listRawChannelPluginCatalogEntriesMock,
}));
vi.mock("../commands/channels.js", () => ({
channelsAddCommand: channelsAddCommandMock,
}));
@@ -59,11 +67,13 @@ describe("registerChannelsCli", () => {
await registerChannelsCli(new Command().name("openclaw"));
expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled();
expect(listRawChannelPluginCatalogEntriesMock).not.toHaveBeenCalled();
process.argv = ["node", "openclaw", "channels", "add", "--help"];
await registerChannelsCli(new Command().name("openclaw"));
expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1);
expect(listRawChannelPluginCatalogEntriesMock).toHaveBeenCalledTimes(1);
});
it("registers dead-letter inspection and resubmission commands", async () => {
@@ -99,6 +109,145 @@ describe("registerChannelsCli", () => {
expect(getChannelAddOptionFlags(program)).toContain("--workspace <workspace>");
});
it("registers setup options from a non-bundled catalog channel", async () => {
listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([
{
id: "installed-chat",
pluginId: "installed-chat",
origin: "global",
channel: {
id: "installed-chat",
label: "Installed Chat",
cliAddOptions: [{ flags: "--installed-key <key>", description: "Installed chat key" }],
},
meta: {
id: "installed-chat",
label: "Installed Chat",
selectionLabel: "Installed Chat",
docsPath: "/channels/installed-chat",
blurb: "Installed test channel.",
},
install: { npmSpec: "@openclaw/installed-chat" },
},
]);
const program = new Command().name("openclaw");
await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]);
expect(getChannelAddOptionFlags(program)).toContain("--installed-key <key>");
});
it("dedupes options by switch identity across differing value placeholders", async () => {
listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([
{
id: "chat-a",
pluginId: "chat-a",
origin: "global",
channel: {
id: "chat-a",
label: "Chat A",
cliAddOptions: [
{ flags: "--url <url>", description: "Chat A URL" },
{ flags: "--token <payload>", description: "Chat A token" },
],
},
meta: {
id: "chat-a",
label: "Chat A",
selectionLabel: "Chat A",
docsPath: "/channels/chat-a",
blurb: "Chat A test channel.",
},
install: { npmSpec: "@openclaw/chat-a" },
},
{
id: "chat-b",
pluginId: "chat-b",
origin: "global",
channel: {
id: "chat-b",
label: "Chat B",
cliAddOptions: [{ flags: "--url <server>", description: "Chat B server URL" }],
},
meta: {
id: "chat-b",
label: "Chat B",
selectionLabel: "Chat B",
docsPath: "/channels/chat-b",
blurb: "Chat B test channel.",
},
install: { npmSpec: "@openclaw/chat-b" },
},
]);
const program = new Command().name("openclaw");
// Commander throws on conflicting switches; registration must survive a
// plugin redeclaring `--url` with a different placeholder or the static
// `--token` with a different value name.
await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]);
const flags = getChannelAddOptionFlags(program);
expect(flags).toContain("--url <url>");
expect(flags).not.toContain("--url <server>");
expect(flags).toContain("--token <token>");
expect(flags).not.toContain("--token <payload>");
});
it("prefers the selected channel's declaration for a shared switch", async () => {
listRawChannelPluginCatalogEntriesMock.mockReturnValueOnce([
{
id: "chat-a",
pluginId: "chat-a",
origin: "global",
channel: {
id: "chat-a",
label: "Chat A",
cliAddOptions: [{ flags: "--url <url>", description: "Chat A URL" }],
},
meta: {
id: "chat-a",
label: "Chat A",
selectionLabel: "Chat A",
docsPath: "/channels/chat-a",
blurb: "Chat A test channel.",
},
install: { npmSpec: "@openclaw/chat-a" },
},
{
id: "chat-b",
pluginId: "chat-b",
origin: "global",
channel: {
id: "chat-b",
label: "Chat B",
cliAddOptions: [{ flags: "--url <server>", description: "Chat B server URL" }],
},
meta: {
id: "chat-b",
label: "Chat B",
selectionLabel: "Chat B",
docsPath: "/channels/chat-b",
blurb: "Chat B test channel.",
},
install: { npmSpec: "@openclaw/chat-b" },
},
]);
const program = new Command().name("openclaw");
await registerChannelsCli(program, [
"node",
"openclaw",
"channels",
"add",
"--channel",
"chat-b",
]);
const flags = getChannelAddOptionFlags(program);
expect(flags).toContain("--url <server>");
expect(flags).not.toContain("--url <url>");
});
it("uses caller argv instead of raw process argv for channel-specific add options", async () => {
process.argv = ["node", "openclaw", "channels"];
+48 -39
View File
@@ -15,8 +15,7 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
type ChannelsCommandsModule = typeof import("../commands/channels.js");
type BundledPackageChannelMetadataModule =
typeof import("../plugins/bundled-package-channel-metadata.js");
type ChannelSetupCliOptionsModule = typeof import("../channels/plugins/cli-add-options.js");
const optionNamesRemove = ["channel", "account", "delete"] as const;
const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["channel"]);
@@ -28,10 +27,9 @@ type RegisterChannelsCliOptions = {
const channelsCommandsLoader = createLazyImportLoader<ChannelsCommandsModule>(
() => import("../commands/channels.js"),
);
const bundledPackageChannelMetadataLoader =
createLazyImportLoader<BundledPackageChannelMetadataModule>(
() => import("../plugins/bundled-package-channel-metadata.js"),
);
const channelSetupCliOptionsLoader = createLazyImportLoader<ChannelSetupCliOptionsModule>(
() => import("../channels/plugins/cli-add-options.js"),
);
function loadChannelsCommands(): Promise<ChannelsCommandsModule> {
return channelsCommandsLoader.load();
@@ -74,27 +72,49 @@ function shouldRegisterChannelSetupOptions(
return commandPath[0] === "channels" && commandPath[1] === "add";
}
async function addChannelSetupOptions(command: Command): Promise<Command> {
const { listBundledPackageChannelMetadata } = await bundledPackageChannelMetadataLoader.load();
const seenFlags = new Set(command.options.map((option) => option.flags));
const channels = listBundledPackageChannelMetadata().toSorted((left, right) => {
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
return leftOrder === rightOrder
? (left.id ?? "").localeCompare(right.id ?? "")
: leftOrder - rightOrder;
});
for (const channel of channels) {
for (const option of channel.cliAddOptions ?? []) {
if (seenFlags.has(option.flags)) {
continue;
}
seenFlags.add(option.flags);
if (option.defaultValue !== undefined) {
command.option(option.flags, option.description, option.defaultValue);
} else {
command.option(option.flags, option.description);
}
// Best-effort pre-parse sniff of the selected channel so its option
// declarations win registration. Misses only the unusual `add --flag value
// <channel>` shape, which falls back to first-declaration ordering.
function resolveChannelsAddArgvChannel(argv: string[]): string | undefined {
const tokens = normalizeWindowsArgv(argv);
const addIndex = tokens.indexOf("add");
if (addIndex === -1) {
return undefined;
}
const rest = tokens.slice(addIndex + 1);
const channelFlagIndex = rest.indexOf("--channel");
if (channelFlagIndex !== -1) {
const value = rest[channelFlagIndex + 1];
return value && !value.startsWith("-") ? value : undefined;
}
const inline = rest.find((token) => token.startsWith("--channel="));
if (inline) {
return inline.slice("--channel=".length) || undefined;
}
const positional = rest[0];
return positional && !positional.startsWith("-") ? positional : undefined;
}
async function addChannelSetupOptions(command: Command, channelId?: string): Promise<Command> {
const { channelCliOptionSwitchKey, resolveChannelSetupCliOptionMetadata } =
await channelSetupCliOptionsLoader.load();
// Seed with switch identities, not raw flags strings: Commander throws on a
// matching switch with a different placeholder (e.g. plugin `--token <payload>`
// vs the static `--token <token>`).
const seenSwitches = new Set(
command.options.map((option) => option.long ?? option.short ?? option.flags),
);
const { options } = resolveChannelSetupCliOptionMetadata(channelId);
for (const option of options) {
const key = channelCliOptionSwitchKey(option.flags);
if (seenSwitches.has(key)) {
continue;
}
seenSwitches.add(key);
if (option.defaultValue !== undefined) {
command.option(option.flags, option.description, option.defaultValue);
} else {
command.option(option.flags, option.description);
}
}
return command;
@@ -263,21 +283,10 @@ export async function registerChannelsCli(
.option("--name <name>", "Display name for this account")
.option("--token <token>", "Channel token or credential payload")
.option("--token-file <path>", "Read channel token or credential payload from file")
.option("--secret <secret>", "Channel shared secret")
.option("--secret-file <path>", "Read channel shared secret from file")
.option("--bot-token <token>", "Bot token")
.option("--app-token <token>", "App token")
.option("--password <password>", "Channel password or login secret")
.option("--cli-path <path>", "Channel CLI path")
.option("--url <url>", "Channel setup URL")
.option("--base-url <url>", "Channel base URL")
.option("--workspace <workspace>", "Channel workspace id, slug, or name")
.option("--http-url <url>", "Channel HTTP service URL")
.option("--auth-dir <path>", "Channel auth directory override")
.option("--use-env", "Use env-backed credentials when supported", false);
if (shouldRegisterChannelSetupOptions(argv, options)) {
await addChannelSetupOptions(addCommand);
await addChannelSetupOptions(addCommand, resolveChannelsAddArgvChannel(argv));
}
addCommand.action(async (channelArg: string | undefined, opts, command) => {
+141 -1
View File
@@ -5,6 +5,7 @@ import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { ChannelPlugin } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import type { PluginPackageChannelCliOption } from "../plugins/manifest.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -111,6 +112,27 @@ vi.mock("./onboard-channels.js", async () => {
const runtime = createTestRuntime();
function createSetupOptionCatalogEntry(
id: string,
label: string,
cliAddOptions: readonly PluginPackageChannelCliOption[],
): ChannelPluginCatalogEntry {
return {
id,
pluginId: id,
origin: "global",
channel: { id, label, cliAddOptions },
meta: {
id,
label,
selectionLabel: label,
docsPath: `/channels/${id}`,
blurb: `${label} test channel.`,
},
install: { npmSpec: `@openclaw/${id}` },
};
}
type MockCallSource = {
mock: {
calls: ArrayLike<ReadonlyArray<unknown>>;
@@ -351,6 +373,12 @@ type SignalAfterAccountConfigWritten = NonNullable<
type ApplyAccountConfigParams = Parameters<
NonNullable<NonNullable<ChannelPlugin["setup"]>["applyAccountConfig"]>
>[0];
type ResolveAccountIdParams = Parameters<
NonNullable<NonNullable<ChannelPlugin["setup"]>["resolveAccountId"]>
>[0];
type PrepareAccountConfigInputParams = Parameters<
NonNullable<NonNullable<ChannelPlugin["setup"]>["prepareAccountConfigInput"]>
>[0];
function createSignalPlugin(
afterAccountConfigWritten: SignalAfterAccountConfigWritten,
@@ -604,7 +632,16 @@ describe("channelsAddCommand", () => {
id: "nextcloud-talk",
label: "Nextcloud Talk",
}),
setup: { applyAccountConfig },
setup: {
resolveAccountId: ({ accountId }: ResolveAccountIdParams) => accountId ?? "default",
prepareAccountConfigInput: ({ input }: PrepareAccountConfigInputParams) => ({
...input,
baseUrl: input.baseUrl ?? input.url,
secret: input.secret ?? input.token ?? input.password,
secretFile: input.secretFile ?? input.tokenFile,
}),
applyAccountConfig,
},
},
source: "test",
},
@@ -1047,6 +1084,15 @@ describe("channelsAddCommand", () => {
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
setup: { applyAccountConfig },
};
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
createSetupOptionCatalogEntry("matrix", "Matrix", [
{
flags: "--initial-sync-limit <n>",
description: "Matrix initial sync limit",
valueType: "int",
},
]),
]);
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry([{ pluginId: "matrix", plugin, source: "test" }]));
@@ -1065,6 +1111,100 @@ describe("channelsAddCommand", () => {
expect(configMocks.writeConfigFile).not.toHaveBeenCalled();
});
it("coerces list-valued channel setup options from delimited strings", async () => {
const applyAccountConfig = vi.fn(({ cfg, input }: ApplyAccountConfigParams) => ({
...cfg,
channels: {
...cfg.channels,
tlon: {
enabled: true,
groupChannels: input.groupChannels,
dmAllowlist: input.dmAllowlist,
},
},
}));
const plugin = {
...createChannelTestPluginBase({ id: "tlon", label: "Tlon" }),
setup: { applyAccountConfig },
};
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
createSetupOptionCatalogEntry("tlon", "Tlon", [
{
flags: "--group-channels <list>",
description: "Tlon group channels",
valueType: "list",
},
{
flags: "--dm-allowlist <list>",
description: "Tlon DM allowlist",
valueType: "list",
},
]),
]);
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry([{ pluginId: "tlon", plugin, source: "test" }]));
await channelsAddCommand(
{
channel: "tlon",
groupChannels: "chat/~host/general, chat/~host/random",
dmAllowlist: "~zod;~nec",
},
runtime,
{ hasFlags: true },
);
expect(writtenChannel("tlon")).toEqual({
enabled: true,
groupChannels: ["chat/~host/general", "chat/~host/random"],
dmAllowlist: ["~zod", "~nec"],
});
});
it("does not apply another channel's coercion to a shared flag", async () => {
const applyAccountConfig = vi.fn(({ cfg, input }: ApplyAccountConfigParams) => ({
...cfg,
channels: {
...cfg.channels,
matrix: {
enabled: true,
sharedValue: requireRecord(input, "shared input").sharedValue,
},
},
}));
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
createSetupOptionCatalogEntry("matrix", "Matrix", [
{ flags: "--shared-value <value>", description: "Matrix shared value" },
]),
createSetupOptionCatalogEntry("tlon", "Tlon", [
{
flags: "--shared-value <value>",
description: "Tlon shared values",
valueType: "list",
},
]),
]);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "matrix",
plugin: {
...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }),
setup: { applyAccountConfig },
},
source: "test",
},
]),
);
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
await channelsAddCommand({ channel: "matrix", sharedValue: "one,two" }, runtime, {
hasFlags: true,
});
expect(writtenChannel("matrix").sharedValue).toBe("one,two");
});
it("falls back from untrusted workspace catalog shadows when adding by alias", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
setActivePluginRegistry(createTestRegistry());
+21 -35
View File
@@ -2,6 +2,7 @@
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js";
import { resolveChannelSetupCliOptionMetadata } from "../../channels/plugins/cli-add-options.js";
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
@@ -49,7 +50,6 @@ export type ChannelsAddOptions = {
} & Record<string, unknown>;
const CHANNEL_ADD_CONTROL_OPTION_KEYS = new Set(["channel", "account"]);
const NEXTCLOUD_TALK_CLI_ALIASES = new Set(["nextcloud-talk", "nc-talk", "nc"]);
async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) {
const trimmed = normalizeOptionalLowercaseString(raw);
@@ -78,47 +78,33 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul
});
}
function parseOptionalInt(value: unknown, flag: string): number | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === undefined) {
throw new Error(`${flag} must be a non-negative integer.`);
}
return parsed;
}
function parseOptionalDelimitedInput(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === "string");
}
return parseOptionalDelimitedEntries(typeof value === "string" ? value : undefined);
}
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
function buildChannelSetupInput(opts: ChannelsAddOptions): ChannelSetupInput {
const input: Record<string, unknown> = {};
const { valueMetadataByAttributeName } = resolveChannelSetupCliOptionMetadata(opts.channel);
for (const [key, value] of Object.entries(opts)) {
if (CHANNEL_ADD_CONTROL_OPTION_KEYS.has(key) || value === undefined) {
continue;
}
input[key] = value;
const metadata = valueMetadataByAttributeName.get(key);
if (metadata?.valueType !== "int") {
input[key] =
metadata?.valueType === "list"
? Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === "string")
: parseOptionalDelimitedEntries(typeof value === "string" ? value : undefined)
: value;
continue;
}
if (value === null || value === "") {
input[key] = undefined;
continue;
}
const parsed = parseStrictNonNegativeInteger(value);
if (parsed === undefined) {
throw new Error(`${metadata.longFlag} must be a non-negative integer.`);
}
input[key] = parsed;
}
const rawChannel = readOptionalString(opts.channel)?.trim().toLowerCase();
if (rawChannel && NEXTCLOUD_TALK_CLI_ALIASES.has(rawChannel)) {
input.baseUrl ??= readOptionalString(input.url);
input.secret ??= readOptionalString(input.token) ?? readOptionalString(input.password);
input.secretFile ??= readOptionalString(input.tokenFile);
}
input.initialSyncLimit = parseOptionalInt(opts.initialSyncLimit, "--initial-sync-limit");
input.groupChannels = parseOptionalDelimitedInput(opts.groupChannels);
input.dmAllowlist = parseOptionalDelimitedInput(opts.dmAllowlist);
return input as ChannelSetupInput;
}
@@ -677,6 +677,47 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => {
expect(registry.plugins[0]?.channelCatalogMeta).toBeUndefined();
});
it("normalizes persisted channel CLI option value types", () => {
const rootDir = makeTempDir();
writePlugin(rootDir, "installed", "installed-");
const index = createIndex(rootDir);
const registry = loadPluginManifestRegistryForInstalledIndex({
index: {
...index,
plugins: [
{
...index.plugins[0],
packageChannel: {
id: "installed",
cliAddOptions: [
{
flags: "--limit <n>",
description: "Limit",
valueType: "int",
},
{
flags: "--ignored <value>",
description: "Ignored",
valueType: "string",
},
],
},
},
],
} as unknown as InstalledPluginIndex,
env: {
OPENCLAW_VERSION: "2026.4.25",
VITEST: "true",
},
includeDisabled: true,
});
expect(registry.plugins[0]?.packageChannel?.cliAddOptions).toEqual([
{ flags: "--limit <n>", description: "Limit", valueType: "int" },
{ flags: "--ignored <value>", description: "Ignored" },
]);
});
it("round-trips bundle metadata through the persisted index before reconstruction", async () => {
const stateDir = makeTempDir();
const rootDir = makeTempDir();
+5 -1
View File
@@ -20,6 +20,7 @@ import {
type OpenClawPackageManifest,
type PackageManifest,
type PluginPackageChannel,
type PluginPackageChannelCliOption,
} from "./manifest.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
import { tracePluginLifecyclePhase } from "./plugin-lifecycle-trace.js";
@@ -406,7 +407,7 @@ function normalizePackageChannelCliOptions(
if (!Array.isArray(cliAddOptions)) {
return undefined;
}
const normalized = cliAddOptions.flatMap((option) => {
const normalized = cliAddOptions.flatMap<PluginPackageChannelCliOption>((option) => {
if (!isRecord(option)) {
return [];
}
@@ -419,11 +420,14 @@ function normalizePackageChannelCliOptions(
typeof option.defaultValue === "boolean" || typeof option.defaultValue === "string"
? option.defaultValue
: undefined;
const valueType =
option.valueType === "int" || option.valueType === "list" ? option.valueType : undefined;
return [
{
flags,
description,
...(defaultValue !== undefined ? { defaultValue } : {}),
...(valueType ? { valueType } : {}),
},
];
});
+2 -1
View File
@@ -2117,10 +2117,11 @@ export type PluginPackageChannelDoctorCapabilities = {
warnOnEmptyGroupSenderAllowlist?: boolean;
};
type PluginPackageChannelCliOption = {
export type PluginPackageChannelCliOption = {
flags: string;
description: string;
defaultValue?: boolean | string;
valueType?: "int" | "list";
};
export type PluginPackageInstall = {
+12
View File
@@ -195,6 +195,12 @@ describe("buildOfficialChannelCatalog", () => {
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
systemImage: "message",
cliAddOptions: [
{
flags: "--auth-dir <path>",
description: "WhatsApp auth directory override",
},
],
},
install: {
clawhubSpec: "clawhub:@openclaw/whatsapp",
@@ -326,6 +332,12 @@ describe("buildOfficialChannelCatalog", () => {
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
systemImage: "message",
cliAddOptions: [
{
flags: "--auth-dir <path>",
description: "WhatsApp auth directory override",
},
],
},
install: {
clawhubSpec: "clawhub:@openclaw/whatsapp",