fix(cli): exit 0 when invoking parent commands without a subcommand (#73077)

Several `openclaw <parent>` commands (channels, plugins, approvals, devices,
cron, mcp) were exiting with code 1 when invoked bare, while printing the
same help-style content that `<parent> --help` produces (which exits 0).
This broke `&&` chains and surfaced a misleading
`ELIFECYCLE Command failed with exit code 1.` line under pnpm.

Add a small `applyParentDefaultHelpAction(cmd)` helper in
`src/cli/program/parent-default-help.ts` that attaches a default action
which prints the parent's own help and sets `process.exitCode = 0`. The
helper is a no-op when the parent already has its own action (e.g.
`agents` defaulting to `agents list`), so existing intentional defaults
are preserved.

Apply it to the six core parents listed in #73077.
This commit is contained in:
hclsys
2026-04-28 08:38:42 +08:00
committed by Peter Steinberger
parent 482c74b724
commit ba80695bba
10 changed files with 90 additions and 0 deletions

View File

@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
- Channels/Telegram: centralize polling update tracking so accepted offsets remain durable across restarts, same-process handler failures can still retry, and slow offset writes cannot overwrite newer accepted watermarks. Refs #73115. Thanks @vdruts.
- Agents/models: classify empty, reasoning-only, and planning-only terminal agent runs before accepting a model fallback candidate, so invalid or incompatible models can advance to the next configured fallback instead of returning a 30-second terminal failure. Fixes #73115. Thanks @vdruts.
- Memory/LanceDB: let embedding config use provider-backed auth profiles, environment credentials, or provider config without a separate plugin `embedding.apiKey`, so OAuth-capable embedding providers can power auto-recall/capture. Fixes #68950. Thanks @malshaalan-ai.
- CLI/parents: invoking `openclaw <parent>` (channels, plugins, approvals, devices, cron, mcp) without a subcommand now prints the parent's help and exits `0`, matching `<parent> --help` and the existing `agents` / `sessions` defaults so shell `&&` chains and pnpm wrappers no longer surface a misleading `ELIFECYCLE Command failed with exit code 1.` line. A small shared helper attaches the default action only when the parent has no action of its own. Fixes #73077. Thanks @hclsys.
- Plugins/hooks: time out never-settling `agent_end` observation hooks after 30 seconds and log the plugin failure, so hung embedding endpoints no longer leave memory capture silently pending forever. Fixes #65544. Thanks @ghoc0099.
- Gateway/config: serve runtime config schemas from the current plugin metadata snapshot and generated bundled channel schema metadata instead of rebuilding plugin channel config modules on every `config.get`/`config.schema`, preventing idle plugin-discovery CPU churn after upgrades. Fixes #73088. Thanks @sleitor and @geovansb.
- Memory/LanceDB: call OpenAI-compatible embedding endpoints through the raw SDK transport without sending `encoding_format`, then normalize float-array or base64 responses so providers such as ZhiPu and DashScope no longer fail recall with wrong vector dimensions or rejected parameters. Fixes #63655. Thanks @kinthaiofficial.

View File

@@ -219,4 +219,9 @@ export function registerMemoryCli(program: Command) {
.action(async (opts: MemoryRemBackfillOptions) => {
await runMemoryRemBackfill(opts);
});
memory.action(() => {
memory.outputHelp();
process.exitCode = 0;
});
}

View File

@@ -9,6 +9,7 @@ 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";
type ChannelsCommandsModule = typeof import("../commands/channels.js");
@@ -237,4 +238,6 @@ export function registerChannelsCli(program: Command) {
);
}, "Channel logout failed");
});
applyParentDefaultHelpAction(channels);
}

View File

@@ -1,6 +1,7 @@
import type { Command } from "commander";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { applyParentDefaultHelpAction } from "../program/parent-default-help.js";
import {
registerCronAddCommand,
registerCronListCommand,
@@ -24,4 +25,6 @@ export function registerCronCli(program: Command) {
registerCronAddCommand(cron);
registerCronSimpleCommands(cron);
registerCronEditCommand(cron);
applyParentDefaultHelpAction(cron);
}

View File

@@ -25,6 +25,7 @@ import {
import { sanitizeForLog } from "../terminal/ansi.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
import { withProgress } from "./progress.js";
type DevicesRpcOpts = {
@@ -662,4 +663,6 @@ export function registerDevicesCli(program: Command) {
defaultRuntime.writeJson(result);
}),
);
applyParentDefaultHelpAction(devices);
}

View File

@@ -23,6 +23,7 @@ import { isRich, theme } from "../terminal/theme.js";
import { callGatewayFromCli } from "./gateway-rpc.js";
import { nodesCallOpts, resolveNodeId } from "./nodes-cli/rpc.js";
import type { NodesRpcOpts } from "./nodes-cli/types.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
type ExecApprovalsSnapshot = {
path: string;
@@ -616,4 +617,6 @@ export function registerExecApprovalsCli(program: Command) {
return true;
},
});
applyParentDefaultHelpAction(approvals);
}

View File

@@ -12,6 +12,7 @@ import {
normalizeStringifiedOptionalString,
} from "../shared/string-coerce.js";
import { resolveGatewayAuthOptions } from "./gateway-secret-options.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
function fail(message: string): never {
defaultRuntime.error(message);
@@ -147,4 +148,6 @@ export function registerMcpCli(program: Command) {
}
defaultRuntime.log(`Removed MCP server "${name}" from ${result.path}.`);
});
applyParentDefaultHelpAction(mcp);
}

View File

@@ -13,6 +13,7 @@ import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatPluginLine } from "./plugins-list-format.js";
import { applyParentDefaultHelpAction } from "./program/parent-default-help.js";
export type PluginsListOptions = {
json?: boolean;
@@ -925,4 +926,6 @@ export function registerPluginsCli(program: Command) {
defaultRuntime.log(`${theme.command(plugin.name)}${suffix}${desc}`);
}
});
applyParentDefaultHelpAction(plugins);
}

View File

@@ -0,0 +1,44 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { applyParentDefaultHelpAction } from "./parent-default-help.js";
describe("applyParentDefaultHelpAction (#73077)", () => {
let originalExitCode: NodeJS.Process["exitCode"];
beforeEach(() => {
originalExitCode = process.exitCode;
process.exitCode = undefined;
});
afterEach(() => {
process.exitCode = originalExitCode;
});
function buildParent(): Command {
const program = new Command();
program.exitOverride();
const parent = program.command("parent").description("test parent");
parent.exitOverride();
parent.command("list").action(() => {});
parent.command("status").action(() => {});
return parent;
}
it("invokes parent help and exits 0 when invoked without subcommand", async () => {
const parent = buildParent();
const helpSpy = vi.spyOn(parent, "outputHelp").mockImplementation(() => {});
applyParentDefaultHelpAction(parent);
await parent.parent!.parseAsync(["node", "test", "parent"]);
expect(helpSpy).toHaveBeenCalledTimes(1);
expect(process.exitCode).toBe(0);
});
it("still routes through subcommand actions when one is invoked", async () => {
const parent = buildParent();
const listAction = vi.fn();
parent.commands.find((c) => c.name() === "list")!.action(listAction);
const helpSpy = vi.spyOn(parent, "outputHelp").mockImplementation(() => {});
applyParentDefaultHelpAction(parent);
await parent.parent!.parseAsync(["node", "test", "parent", "list"]);
expect(listAction).toHaveBeenCalledTimes(1);
expect(helpSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,22 @@
import type { Command } from "commander";
/**
* Wire a parent command so that invoking it without a subcommand prints the
* parent's own help and exits with status `0`.
*
* Commander's default behavior for a parent with subcommands is to print help
* and set `process.exitCode = 1`, which differs from `<parent> --help` (which
* exits 0). That asymmetry breaks shell `&&` chains and surfaces a misleading
* `ELIFECYCLE Command failed with exit code 1.` line for users running through
* pnpm. See #73077.
*
* Apply this helper only to parent commands that do not have their own default
* action. Commander does not expose a public "has action handler" API, so
* callers keep that ownership explicit instead of probing private internals.
*/
export function applyParentDefaultHelpAction(parent: Command): void {
parent.action(() => {
parent.outputHelp();
process.exitCode = 0;
});
}