From fdddb413ef2633374cd94dfeba5e9f3dcce50ec5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 5 May 2026 16:02:39 -0700 Subject: [PATCH] fix(cli): fast-path bare channels help (#77659) * fix(cli): fast-path bare channels help * fix(cli): normalize channels add argv gating * fix(cli): restore channel add completion flags --- CHANGELOG.md | 1 + src/cli/channels-cli.test.ts | 100 ++++++++++++++++++++ src/cli/channels-cli.ts | 79 +++++++++++----- src/cli/completion-cli.ts | 2 +- src/cli/completion-cli.write-state.test.ts | 8 +- src/cli/program/help.test.ts | 19 ++++ src/cli/program/help.ts | 2 +- src/cli/program/parent-default-help.test.ts | 18 +++- src/cli/program/parent-default-help.ts | 23 ++++- src/cli/program/preaction.test.ts | 14 +++ src/cli/program/preaction.ts | 16 +++- src/cli/program/register.subclis-core.ts | 38 ++++++-- src/cli/program/register.subclis.test.ts | 16 ++++ src/cli/program/register.subclis.ts | 27 ++++-- src/cli/run-main-policy.ts | 3 + src/cli/run-main.exit.test.ts | 1 + 16 files changed, 319 insertions(+), 48 deletions(-) create mode 100644 src/cli/channels-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 56087d37cd7..bc283323ade 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc. - Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc. - CLI/plugins: handle closed stdin during `plugins uninstall` confirmation prompt and exit 1 with actionable `--force` guidance instead of crashing with Node exit 13 unsettled top-level await. Fixes #73562. (#73566) Thanks @ai-hpc. +- CLI/channels: skip config, proxy, channel-option catalog, banner-config, and plugin startup bootstrap for the bare `openclaw channels` parent-help command, so it exits promptly after printing help instead of loading configured channel plugins. Thanks @vincentkoc. - CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc. - Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc. - Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting. diff --git a/src/cli/channels-cli.test.ts b/src/cli/channels-cli.test.ts new file mode 100644 index 00000000000..1225d94a6ba --- /dev/null +++ b/src/cli/channels-cli.test.ts @@ -0,0 +1,100 @@ +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PluginPackageChannel } from "../plugins/manifest.js"; +import { registerChannelsCli } from "./channels-cli.js"; + +const listBundledPackageChannelMetadataMock = vi.hoisted(() => + vi.fn<() => readonly PluginPackageChannel[]>(() => []), +); + +vi.mock("../plugins/bundled-package-channel-metadata.js", () => ({ + listBundledPackageChannelMetadata: listBundledPackageChannelMetadataMock, +})); + +function getChannelAddOptionFlags(program: Command): string[] { + const channels = program.commands.find((command) => command.name() === "channels"); + const add = channels?.commands.find((command) => command.name() === "add"); + return add?.options.map((option) => option.flags) ?? []; +} + +describe("registerChannelsCli", () => { + const originalArgv = [...process.argv]; + const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + + afterEach(() => { + process.argv = [...originalArgv]; + if (originalPlatform) { + Object.defineProperty(process, "platform", originalPlatform); + } + vi.clearAllMocks(); + }); + + it("loads channel-specific add options only for channels add invocations", async () => { + process.argv = ["node", "openclaw", "channels"]; + await registerChannelsCli(new Command().name("openclaw")); + + expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled(); + + process.argv = ["node", "openclaw", "channels", "add", "--help"]; + await registerChannelsCli(new Command().name("openclaw")); + + expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); + }); + + it("uses caller argv instead of raw process argv for channel-specific add options", async () => { + process.argv = ["node", "openclaw", "channels"]; + + await registerChannelsCli(new Command().name("openclaw"), [ + "node", + "openclaw", + "channels", + "add", + "--help", + ]); + + expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); + }); + + it("can force channel-specific add options for completion generation", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "matrix", + cliAddOptions: [{ flags: "--homeserver ", description: "Matrix homeserver URL" }], + }, + ]); + process.argv = ["node", "openclaw", "completion", "--write-state"]; + const program = new Command().name("openclaw"); + + await registerChannelsCli(program, process.argv, { includeSetupOptions: true }); + + expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); + expect(getChannelAddOptionFlags(program)).toContain("--homeserver "); + }); + + it("normalizes Windows launcher argv before channel-specific add option gating", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "matrix", + cliAddOptions: [{ flags: "--homeserver ", description: "Matrix homeserver URL" }], + }, + ]); + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.argv = [ + "C:\\Program Files\\nodejs\\node.exe", + "C:\\repo\\openclaw.js", + "C:\\Program Files\\nodejs\\node.exe", + "channels", + "add", + "--channel", + "matrix", + "--homeserver", + "https://matrix.example.org", + ]; + const program = new Command().name("openclaw"); + + await registerChannelsCli(program); + + expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); + expect(getChannelAddOptionFlags(program)).toContain("--homeserver "); + }); +}); diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index d1fe07a49af..4a448be9e34 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,24 +1,35 @@ import type { Command } from "commander"; import { danger } from "../globals.js"; -import { listBundledPackageChannelMetadata } from "../plugins/bundled-package-channel-metadata.js"; import { defaultRuntime } from "../runtime.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { formatCliChannelOptions } from "./channel-options.js"; import { runCommandWithRuntime } from "./cli-utils.js"; import { hasExplicitOptions } from "./command-options.js"; import { formatHelpExamples } from "./help-format.js"; 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"); const optionNamesRemove = ["channel", "account", "delete"] as const; +type RegisterChannelsCliOptions = { + includeSetupOptions?: boolean; +}; + const channelsCommandsLoader = createLazyImportLoader( () => import("../commands/channels.js"), ); +const bundledPackageChannelMetadataLoader = + createLazyImportLoader( + () => import("../plugins/bundled-package-channel-metadata.js"), + ); function loadChannelsCommands(): Promise { return channelsCommandsLoader.load(); @@ -39,7 +50,19 @@ function getOptionNames(command: Command): string[] { return command.options.map((option) => option.attributeName()); } -function addChannelSetupOptions(command: Command): Command { +function shouldRegisterChannelSetupOptions( + argv: string[] = process.argv, + options: RegisterChannelsCliOptions = {}, +): boolean { + if (options.includeSetupOptions) { + return true; + } + const { commandPath } = resolveCliArgvInvocation(normalizeWindowsArgv(argv)); + return commandPath[0] === "channels" && commandPath[1] === "add"; +} + +async function addChannelSetupOptions(command: Command): Promise { + 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; @@ -64,7 +87,11 @@ function addChannelSetupOptions(command: Command): Command { return command; } -export function registerChannelsCli(program: Command) { +export async function registerChannelsCli( + program: Command, + argv: string[] = process.argv, + options: RegisterChannelsCliOptions = {}, +) { const channelNames = formatCliChannelOptions(); const channels = program .command("channels") @@ -163,27 +190,31 @@ export function registerChannelsCli(program: Command) { }); }); - addChannelSetupOptions( - channels - .command("add") - .description("Add or update a channel account") - .option("--channel ", `Channel (${channelNames})`) - .option("--account ", "Account id (default when omitted)") - .option("--name ", "Display name for this account") - .option("--token ", "Channel token or credential payload") - .option("--token-file ", "Read channel token or credential payload from file") - .option("--secret ", "Channel shared secret") - .option("--secret-file ", "Read channel shared secret from file") - .option("--bot-token ", "Bot token") - .option("--app-token ", "App token") - .option("--password ", "Channel password or login secret") - .option("--cli-path ", "Channel CLI path") - .option("--url ", "Channel setup URL") - .option("--base-url ", "Channel base URL") - .option("--http-url ", "Channel HTTP service URL") - .option("--auth-dir ", "Channel auth directory override") - .option("--use-env", "Use env-backed credentials when supported", false), - ).action(async (opts, command) => { + const addCommand = channels + .command("add") + .description("Add or update a channel account") + .option("--channel ", `Channel (${channelNames})`) + .option("--account ", "Account id (default when omitted)") + .option("--name ", "Display name for this account") + .option("--token ", "Channel token or credential payload") + .option("--token-file ", "Read channel token or credential payload from file") + .option("--secret ", "Channel shared secret") + .option("--secret-file ", "Read channel shared secret from file") + .option("--bot-token ", "Bot token") + .option("--app-token ", "App token") + .option("--password ", "Channel password or login secret") + .option("--cli-path ", "Channel CLI path") + .option("--url ", "Channel setup URL") + .option("--base-url ", "Channel base URL") + .option("--http-url ", "Channel HTTP service URL") + .option("--auth-dir ", "Channel auth directory override") + .option("--use-env", "Use env-backed credentials when supported", false); + + if (shouldRegisterChannelSetupOptions(argv, options)) { + await addChannelSetupOptions(addCommand); + } + + addCommand.action(async (opts, command) => { await runChannelsCommand(async () => { const { channelsAddCommand } = await loadChannelsCommands(); const hasFlags = hasExplicitOptions(command, getOptionNames(command)); diff --git a/src/cli/completion-cli.ts b/src/cli/completion-cli.ts index e2f1980340e..9202f00b635 100644 --- a/src/cli/completion-cli.ts +++ b/src/cli/completion-cli.ts @@ -60,7 +60,7 @@ async function registerSubcommandsForCompletion(program: Command): Promise continue; } try { - await registerSubCliByName(program, entry.name); + await registerSubCliByName(program, entry.name, process.argv, { purpose: "completion" }); } catch (error) { writeCompletionRegistrationWarning( `skipping subcommand \`${entry.name}\` while building completion cache: ${error instanceof Error ? error.message : String(error)}`, diff --git a/src/cli/completion-cli.write-state.test.ts b/src/cli/completion-cli.write-state.test.ts index d899b1c5e44..6278afc51f7 100644 --- a/src/cli/completion-cli.write-state.test.ts +++ b/src/cli/completion-cli.write-state.test.ts @@ -97,7 +97,9 @@ describe("completion-cli write-state", () => { expect(await fs.readdir(cacheDir)).toEqual( expect.arrayContaining(["openclaw.bash", "openclaw.fish", "openclaw.ps1", "openclaw.zsh"]), ); - expect(registerSubCliByNameMock).toHaveBeenCalledWith(program, "qa"); + expect(registerSubCliByNameMock).toHaveBeenCalledWith(program, "qa", expect.any(Array), { + purpose: "completion", + }); expect(registerPluginCliCommandsFromValidatedConfigMock).toHaveBeenCalledTimes(1); expect(stderrWrites).toHaveBeenCalledWith( expect.stringContaining("skipping subcommand `qa` while building completion cache"), @@ -126,7 +128,9 @@ describe("completion-cli write-state", () => { await program.parseAsync(["completion", "--write-state"], { from: "user" }); - expect(registerSubCliByNameMock).toHaveBeenCalledWith(program, "qa"); + expect(registerSubCliByNameMock).toHaveBeenCalledWith(program, "qa", expect.any(Array), { + purpose: "completion", + }); expect(registerPluginCliCommandsFromValidatedConfigMock).not.toHaveBeenCalled(); expect(await fs.readdir(path.join(stateDir, "completions"))).toEqual( expect.arrayContaining(["openclaw.bash", "openclaw.fish", "openclaw.ps1", "openclaw.zsh"]), diff --git a/src/cli/program/help.test.ts b/src/cli/program/help.test.ts index 38a24080153..9a8e55e1614 100644 --- a/src/cli/program/help.test.ts +++ b/src/cli/program/help.test.ts @@ -56,16 +56,24 @@ const testProgramContext: ProgramContext = { describe("configureProgramHelp", () => { let originalArgv: string[]; + let originalSuppressHelpBanner: string | undefined; beforeEach(() => { vi.clearAllMocks(); originalArgv = [...process.argv]; + originalSuppressHelpBanner = process.env.OPENCLAW_SUPPRESS_HELP_BANNER; hasEmittedCliBannerMock.mockReturnValue(false); resolveCommitHashMock.mockReturnValue("abc1234"); + delete process.env.OPENCLAW_SUPPRESS_HELP_BANNER; }); afterEach(() => { process.argv = originalArgv; + if (originalSuppressHelpBanner === undefined) { + delete process.env.OPENCLAW_SUPPRESS_HELP_BANNER; + } else { + process.env.OPENCLAW_SUPPRESS_HELP_BANNER = originalSuppressHelpBanner; + } }); function makeProgramWithCommands() { @@ -131,6 +139,17 @@ describe("configureProgramHelp", () => { expect(help).toContain("https://docs.openclaw.ai/cli"); }); + it("suppresses banner formatting when parent default help requests it", () => { + process.argv = ["node", "openclaw", "channels"]; + process.env.OPENCLAW_SUPPRESS_HELP_BANNER = "1"; + const program = makeProgramWithCommands(); + configureProgramHelp(program, testProgramContext); + + const help = captureHelpOutput(program); + expect(help).not.toContain("BANNER-LINE"); + expect(formatCliBannerLineMock).not.toHaveBeenCalled(); + }); + it("prints version and exits immediately when version flags are present", () => { process.argv = ["node", "openclaw", "--version"]; expectVersionExit({ expectedVersion: "OpenClaw 9.9.9-test (abc1234)" }); diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 6513b177960..0d40458f335 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -122,7 +122,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) { } program.addHelpText("beforeAll", () => { - if (hasEmittedCliBanner()) { + if (hasEmittedCliBanner() || process.env.OPENCLAW_SUPPRESS_HELP_BANNER === "1") { return ""; } const rich = isRich(); diff --git a/src/cli/program/parent-default-help.test.ts b/src/cli/program/parent-default-help.test.ts index 707856fb2da..dde514ab6dd 100644 --- a/src/cli/program/parent-default-help.test.ts +++ b/src/cli/program/parent-default-help.test.ts @@ -1,15 +1,22 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { applyParentDefaultHelpAction } from "./parent-default-help.js"; +import { applyParentDefaultHelpAction, isParentDefaultHelpAction } from "./parent-default-help.js"; describe("applyParentDefaultHelpAction (#73077)", () => { let originalExitCode: NodeJS.Process["exitCode"]; + let originalSuppressHelpBanner: string | undefined; beforeEach(() => { originalExitCode = process.exitCode; + originalSuppressHelpBanner = process.env.OPENCLAW_SUPPRESS_HELP_BANNER; process.exitCode = undefined; }); afterEach(() => { process.exitCode = originalExitCode; + if (originalSuppressHelpBanner === undefined) { + delete process.env.OPENCLAW_SUPPRESS_HELP_BANNER; + } else { + process.env.OPENCLAW_SUPPRESS_HELP_BANNER = originalSuppressHelpBanner; + } }); function buildParent(): Command { @@ -24,10 +31,17 @@ describe("applyParentDefaultHelpAction (#73077)", () => { it("invokes parent help and exits 0 when invoked without subcommand", async () => { const parent = buildParent(); - const helpSpy = vi.spyOn(parent, "outputHelp").mockImplementation(() => {}); + const suppressHelpBannerValues: Array = []; + const helpSpy = vi.spyOn(parent, "outputHelp").mockImplementation(() => { + suppressHelpBannerValues.push(process.env.OPENCLAW_SUPPRESS_HELP_BANNER); + }); + expect(isParentDefaultHelpAction(parent)).toBe(false); applyParentDefaultHelpAction(parent); + expect(isParentDefaultHelpAction(parent)).toBe(true); await parent.parent!.parseAsync(["node", "test", "parent"]); expect(helpSpy).toHaveBeenCalledTimes(1); + expect(suppressHelpBannerValues).toEqual(["1"]); + expect(process.env.OPENCLAW_SUPPRESS_HELP_BANNER).toBeUndefined(); expect(process.exitCode).toBe(0); }); diff --git a/src/cli/program/parent-default-help.ts b/src/cli/program/parent-default-help.ts index ba3e3b9885d..b501ae7a560 100644 --- a/src/cli/program/parent-default-help.ts +++ b/src/cli/program/parent-default-help.ts @@ -1,5 +1,21 @@ import type { Command } from "commander"; +const parentDefaultHelpCommands = new WeakSet(); + +function outputParentHelpWithoutStartupBanner(parent: Command): void { + const previous = process.env.OPENCLAW_SUPPRESS_HELP_BANNER; + process.env.OPENCLAW_SUPPRESS_HELP_BANNER = "1"; + try { + parent.outputHelp(); + } finally { + if (previous === undefined) { + delete process.env.OPENCLAW_SUPPRESS_HELP_BANNER; + } else { + process.env.OPENCLAW_SUPPRESS_HELP_BANNER = previous; + } + } +} + /** * Wire a parent command so that invoking it without a subcommand prints the * parent's own help and exits with status `0`. @@ -15,8 +31,13 @@ import type { Command } from "commander"; * callers keep that ownership explicit instead of probing private internals. */ export function applyParentDefaultHelpAction(parent: Command): void { + parentDefaultHelpCommands.add(parent); parent.action(() => { - parent.outputHelp(); + outputParentHelpWithoutStartupBanner(parent); process.exitCode = 0; }); } + +export function isParentDefaultHelpAction(parent: Command): boolean { + return parentDefaultHelpCommands.has(parent); +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 0f038cddd30..af8575153d6 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -3,6 +3,7 @@ import { repoInstallSpec } from "openclaw/plugin-sdk/test-fixtures"; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { loggingState } from "../../logging/state.js"; import { setCommandJsonMode } from "./json-mode.js"; +import { applyParentDefaultHelpAction } from "./parent-default-help.js"; const DISCORD_REPO_INSTALL_SPEC = repoInstallSpec("discord"); @@ -149,6 +150,7 @@ describe("registerPreActionHooks", () => { .command("send") .option("--json") .action(() => {}); + applyParentDefaultHelpAction(channels); program .command("plugins") .command("install") @@ -289,6 +291,18 @@ describe("registerPreActionHooks", () => { expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); + it("skips startup bootstrap for parent default help actions", async () => { + await runPreAction({ + parseArgv: ["channels"], + processArgv: ["node", "openclaw", "channels"], + }); + + expect(emitCliBannerMock).not.toHaveBeenCalled(); + expect(setVerboseMock).not.toHaveBeenCalled(); + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); + it("lets configure own config validation and plugin loading", async () => { await runPreAction({ parseArgv: ["configure"], diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 7ad621a1db6..89161ca042b 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; import type { LogLevel } from "../../logging/levels.js"; import { defaultRuntime } from "../../runtime.js"; +import { resolveCliArgvInvocation } from "../argv-invocation.js"; import { getVerboseFlag, isHelpOrVersionInvocation } from "../argv.js"; import { resolveCliName } from "../cli-name.js"; import { @@ -15,6 +16,7 @@ import { resolvePluginInstallPreactionRequest, } from "../plugin-install-config-policy.js"; import { isCommandJsonOutputMode } from "./json-mode.js"; +import { isParentDefaultHelpAction } from "./parent-default-help.js"; function setProcessTitleForCommand(actionCommand: Command) { let current: Command = actionCommand; @@ -61,11 +63,23 @@ function getCliLogLevel(actionCommand: Command): LogLevel | undefined { return typeof logLevel === "string" ? (logLevel as LogLevel) : undefined; } +function isBareParentDefaultHelpInvocation(actionCommand: Command, argv: string[]): boolean { + if (!isParentDefaultHelpAction(actionCommand)) { + return false; + } + const { commandPath } = resolveCliArgvInvocation(argv); + const [primary, extra] = commandPath; + if (extra !== undefined || !primary) { + return false; + } + return primary === actionCommand.name() || actionCommand.aliases().includes(primary); +} + export function registerPreActionHooks(program: Command, programVersion: string) { program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); const argv = process.argv; - if (isHelpOrVersionInvocation(argv)) { + if (isHelpOrVersionInvocation(argv) || isBareParentDefaultHelpInvocation(actionCommand, argv)) { return; } const jsonOutputMode = isCommandJsonOutputMode(actionCommand, argv); diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 0883e8288e5..04c64af93c4 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -25,7 +25,15 @@ import { export { getSubCliCommandsWithSubcommands }; -type SubCliRegistrar = (program: Command) => Promise | void; +export type SubCliRegistrationContext = { + purpose?: "runtime" | "completion"; +}; + +type SubCliRegistrar = ( + program: Command, + argv: string[], + context: SubCliRegistrationContext, +) => Promise | void; function shouldRegisterGatewayRunOnly(name: string, argv: string[]): boolean { if (name !== "gateway") { @@ -216,12 +224,16 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ ); }, }, - ...defineImportedProgramCommandGroupSpecs([ - { - commandNames: ["channels"], - loadModule: () => import("../channels-cli.js"), - exportName: "registerChannelsCli", + { + commandNames: ["channels"], + register: async (program, argv, context) => { + const mod = await import("../channels-cli.js"); + await mod.registerChannelsCli(program, argv, { + includeSetupOptions: context.purpose === "completion", + }); }, + }, + ...defineImportedProgramCommandGroupSpecs([ { commandNames: ["directory"], loadModule: () => import("../directory-cli.js"), @@ -250,13 +262,18 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ ]), ]; -function resolveSubCliCommandGroups(): CommandGroupEntry[] { +function resolveSubCliCommandGroups( + argv: string[], + context: SubCliRegistrationContext = {}, +): CommandGroupEntry[] { const descriptors = getSubCliEntryDescriptors(); const descriptorNames = new Set(descriptors.map((descriptor) => descriptor.name)); return buildCommandGroupEntries( descriptors, entrySpecs.filter((spec) => spec.commandNames.every((name) => descriptorNames.has(name))), - (register) => register, + (register) => async (program) => { + await register(program, argv, context); + }, ); } @@ -268,17 +285,18 @@ export async function registerSubCliByName( program: Command, name: string, argv: string[] = process.argv, + context: SubCliRegistrationContext = {}, ): Promise { if (shouldRegisterGatewayRunOnly(name, argv)) { await registerGatewayRunOnly(program); return true; } - return registerCommandGroupByName(program, resolveSubCliCommandGroups(), name); + return registerCommandGroupByName(program, resolveSubCliCommandGroups(argv, context), name); } export function registerSubCliCommands(program: Command, argv: string[] = process.argv) { const { primary } = resolveCliArgvInvocation(argv); - registerCommandGroups(program, resolveSubCliCommandGroups(), { + registerCommandGroups(program, resolveSubCliCommandGroups(argv), { eager: shouldEagerRegisterSubcommands(), primary, registerPrimaryOnly: Boolean(primary && shouldRegisterPrimarySubcommandOnly(argv)), diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index e0b4316e02e..601975fa741 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -47,6 +47,9 @@ const { registerPluginsCli, registerPluginCliCommandsFromValidatedConfig } = vi. }), registerPluginCliCommandsFromValidatedConfig: vi.fn(async () => null), })); +const { registerChannelsCli } = vi.hoisted(() => ({ + registerChannelsCli: vi.fn(async () => undefined), +})); const { addGatewayRunCommand, gatewayRunAction, registerGatewayCli } = vi.hoisted(() => { const runAction = vi.fn(); return { @@ -69,6 +72,7 @@ vi.mock("../gateway-cli/run.js", () => ({ addGatewayRunCommand })); vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); vi.mock("../capability-cli.js", () => ({ registerCapabilityCli })); vi.mock("../plugins-cli.js", () => ({ registerPluginsCli })); +vi.mock("../channels-cli.js", () => ({ registerChannelsCli })); vi.mock("../../plugins/cli.js", () => ({ registerPluginCliCommandsFromValidatedConfig })); vi.mock("./private-qa-cli.js", async () => { const actual = await vi.importActual("./private-qa-cli.js"); @@ -110,6 +114,7 @@ describe("registerSubCliCommands", () => { inferAction.mockClear(); registerPluginsCli.mockClear(); registerPluginCliCommandsFromValidatedConfig.mockClear(); + registerChannelsCli.mockClear(); addGatewayRunCommand.mockClear(); gatewayRunAction.mockClear(); registerGatewayCli.mockClear(); @@ -218,6 +223,17 @@ describe("registerSubCliCommands", () => { expect(registerGatewayCli).toHaveBeenCalledTimes(1); }); + it("passes completion context to channel registration", async () => { + const argv = ["node", "openclaw", "completion", "--write-state"]; + const program = new Command().name("openclaw"); + + await registerSubCliByName(program, "channels", argv, { purpose: "completion" }); + + expect(registerChannelsCli).toHaveBeenCalledWith(program, argv, { + includeSetupOptions: true, + }); + }); + it.each([ ["plugins update", ["plugins", "update", "lossless-claw"]], ["plugins update --all", ["plugins", "update", "--all"]], diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index dedd1fc3a9e..943994da57a 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -17,6 +17,7 @@ import { import { registerSubCliByName as registerSubCliByNameCore, registerSubCliCommands as registerSubCliCommandsCore, + type SubCliRegistrationContext, } from "./register.subclis-core.js"; import { getSubCliCommandsWithSubcommands, @@ -26,7 +27,11 @@ import { export { getSubCliCommandsWithSubcommands }; -type SubCliRegistrar = (program: Command) => Promise | void; +type SubCliRegistrar = ( + program: Command, + argv: string[], + context: SubCliRegistrationContext, +) => Promise | void; const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ ...defineImportedProgramCommandGroupSpecs([ @@ -38,8 +43,17 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ ]), ]; -function resolveSubCliCommandGroups(): CommandGroupEntry[] { - return buildCommandGroupEntries(getSubCliEntryDescriptors(), entrySpecs, (register) => register); +function resolveSubCliCommandGroups( + argv: string[], + context: SubCliRegistrationContext = {}, +): CommandGroupEntry[] { + return buildCommandGroupEntries( + getSubCliEntryDescriptors(), + entrySpecs, + (register) => async (program) => { + await register(program, argv, context); + }, + ); } export function getSubCliEntries(): ReadonlyArray { @@ -50,17 +64,18 @@ export async function registerSubCliByName( program: Command, name: string, argv: string[] = process.argv, + context: SubCliRegistrationContext = {}, ): Promise { - if (await registerSubCliByNameCore(program, name, argv)) { + if (await registerSubCliByNameCore(program, name, argv, context)) { return true; } - return registerCommandGroupByName(program, resolveSubCliCommandGroups(), name); + return registerCommandGroupByName(program, resolveSubCliCommandGroups(argv, context), name); } export function registerSubCliCommands(program: Command, argv: string[] = process.argv) { registerSubCliCommandsCore(program, argv); const { primary } = resolveCliArgvInvocation(argv); - registerCommandGroups(program, resolveSubCliCommandGroups(), { + registerCommandGroups(program, resolveSubCliCommandGroups(argv), { eager: shouldEagerRegisterSubcommands(), primary, registerPrimaryOnly: Boolean(primary && shouldRegisterPrimarySubcommandOnly(argv)), diff --git a/src/cli/run-main-policy.ts b/src/cli/run-main-policy.ts index 5782648aefb..039ee9c1461 100644 --- a/src/cli/run-main-policy.ts +++ b/src/cli/run-main-policy.ts @@ -89,6 +89,9 @@ export function shouldStartProxyForCli(argv: string[]): boolean { if (invocation.hasHelpOrVersion || !primary) { return false; } + if (invocation.commandPath.length === 1 && primary === "channels") { + return false; + } return resolveCliNetworkProxyPolicy(policyArgv) === "default"; } diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 6f8ab7e672b..fe064b3f997 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -392,6 +392,7 @@ describe("runCli exit behavior", () => { ["chat control-plane", ["node", "openclaw", "chat"]], ["terminal control-plane", ["node", "openclaw", "terminal"]], ["config", ["node", "openclaw", "config", "get", "proxy.enabled"]], + ["channels parent help", ["node", "openclaw", "channels"]], ["completion", ["node", "openclaw", "completion", "zsh"]], ["debug proxy cli", ["node", "openclaw", "proxy", "start"]], ["agents list", ["node", "openclaw", "agents", "list"]],