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

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