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
This commit is contained in:
Vincent Koc
2026-05-05 16:02:39 -07:00
committed by GitHub
parent 1e1903487f
commit fdddb413ef
16 changed files with 319 additions and 48 deletions

View File

@@ -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.

View File

@@ -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 <url>", 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 <url>");
});
it("normalizes Windows launcher argv before channel-specific add option gating", async () => {
listBundledPackageChannelMetadataMock.mockReturnValueOnce([
{
id: "matrix",
cliAddOptions: [{ flags: "--homeserver <url>", 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 <url>");
});
});

View File

@@ -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<ChannelsCommandsModule>(
() => import("../commands/channels.js"),
);
const bundledPackageChannelMetadataLoader =
createLazyImportLoader<BundledPackageChannelMetadataModule>(
() => import("../plugins/bundled-package-channel-metadata.js"),
);
function loadChannelsCommands(): Promise<ChannelsCommandsModule> {
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<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;
@@ -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 <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (default when omitted)")
.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("--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),
).action(async (opts, command) => {
const addCommand = channels
.command("add")
.description("Add or update a channel account")
.option("--channel <name>", `Channel (${channelNames})`)
.option("--account <id>", "Account id (default when omitted)")
.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("--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);
}
addCommand.action(async (opts, command) => {
await runChannelsCommand(async () => {
const { channelsAddCommand } = await loadChannelsCommands();
const hasFlags = hasExplicitOptions(command, getOptionNames(command));

View File

@@ -60,7 +60,7 @@ async function registerSubcommandsForCompletion(program: Command): Promise<void>
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)}`,

View File

@@ -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"]),

View File

@@ -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)" });

View File

@@ -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();

View File

@@ -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<string | undefined> = [];
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);
});

View File

@@ -1,5 +1,21 @@
import type { Command } from "commander";
const parentDefaultHelpCommands = new WeakSet<Command>();
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);
}

View File

@@ -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"],

View File

@@ -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);

View File

@@ -25,7 +25,15 @@ import {
export { getSubCliCommandsWithSubcommands };
type SubCliRegistrar = (program: Command) => Promise<void> | void;
export type SubCliRegistrationContext = {
purpose?: "runtime" | "completion";
};
type SubCliRegistrar = (
program: Command,
argv: string[],
context: SubCliRegistrationContext,
) => Promise<void> | void;
function shouldRegisterGatewayRunOnly(name: string, argv: string[]): boolean {
if (name !== "gateway") {
@@ -216,12 +224,16 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
);
},
},
...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<SubCliRegistrar>[] = [
]),
];
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<boolean> {
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)),

View File

@@ -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<typeof import("./private-qa-cli.js")>("./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"]],

View File

@@ -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> | void;
type SubCliRegistrar = (
program: Command,
argv: string[],
context: SubCliRegistrationContext,
) => Promise<void> | void;
const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
...defineImportedProgramCommandGroupSpecs([
@@ -38,8 +43,17 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
]),
];
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<SubCliDescriptor> {
@@ -50,17 +64,18 @@ export async function registerSubCliByName(
program: Command,
name: string,
argv: string[] = process.argv,
context: SubCliRegistrationContext = {},
): Promise<boolean> {
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)),

View File

@@ -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";
}

View File

@@ -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"]],