mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 19:00:42 +00:00
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:
@@ -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)" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user