CLI: reduce channels add startup memory (#46784)

* CLI: lazy-load channel subcommand handlers

* Channels: defer add command dependencies

* CLI: skip status JSON plugin preload

* CLI: cover status JSON route preload

* Status: trim JSON security audit path

* Status: update JSON fast-path tests

* CLI: cover root help fast path

* CLI: fast-path root help

* Status: keep JSON security parity

* Status: restore JSON security tests

* CLI: document status plugin preload

* Channels: reuse Telegram account import
This commit is contained in:
Vincent Koc 2026-03-15 09:10:40 -07:00 committed by GitHub
parent 8d44b16b7c
commit 67b2d1b8e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 123 additions and 26 deletions

View File

@ -1,13 +1,4 @@
import type { Command } from "commander";
import {
channelsAddCommand,
channelsCapabilitiesCommand,
channelsListCommand,
channelsLogsCommand,
channelsRemoveCommand,
channelsResolveCommand,
channelsStatusCommand,
} from "../commands/channels.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
@ -96,6 +87,7 @@ export function registerChannelsCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runChannelsCommand(async () => {
const { channelsListCommand } = await import("../commands/channels.js");
await channelsListCommand(opts, defaultRuntime);
});
});
@ -108,6 +100,7 @@ export function registerChannelsCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runChannelsCommand(async () => {
const { channelsStatusCommand } = await import("../commands/channels.js");
await channelsStatusCommand(opts, defaultRuntime);
});
});
@ -122,6 +115,7 @@ export function registerChannelsCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runChannelsCommand(async () => {
const { channelsCapabilitiesCommand } = await import("../commands/channels.js");
await channelsCapabilitiesCommand(opts, defaultRuntime);
});
});
@ -136,6 +130,7 @@ export function registerChannelsCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (entries, opts) => {
await runChannelsCommand(async () => {
const { channelsResolveCommand } = await import("../commands/channels.js");
await channelsResolveCommand(
{
channel: opts.channel as string | undefined,
@ -157,6 +152,7 @@ export function registerChannelsCli(program: Command) {
.option("--json", "Output JSON", false)
.action(async (opts) => {
await runChannelsCommand(async () => {
const { channelsLogsCommand } = await import("../commands/channels.js");
await channelsLogsCommand(opts, defaultRuntime);
});
});
@ -200,6 +196,7 @@ export function registerChannelsCli(program: Command) {
.option("--use-env", "Use env token (default account only)", false)
.action(async (opts, command) => {
await runChannelsCommand(async () => {
const { channelsAddCommand } = await import("../commands/channels.js");
const hasFlags = hasExplicitOptions(command, optionNamesAdd);
await channelsAddCommand(opts, defaultRuntime, { hasFlags });
});
@ -213,6 +210,7 @@ export function registerChannelsCli(program: Command) {
.option("--delete", "Delete config entries (no prompt)", false)
.action(async (opts, command) => {
await runChannelsCommand(async () => {
const { channelsRemoveCommand } = await import("../commands/channels.js");
const hasFlags = hasExplicitOptions(command, optionNamesRemove);
await channelsRemoveCommand(opts, defaultRuntime, { hasFlags });
});

View File

@ -235,6 +235,10 @@ function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescript
return names;
}
export function getCoreCliCommandDescriptors(): ReadonlyArray<CoreCliCommandDescriptor> {
return coreEntries.flatMap((entry) => entry.commands);
}
export function getCoreCliCommandNames(): string[] {
return collectCoreCliCommandNames();
}

View File

@ -0,0 +1,29 @@
import { Command } from "commander";
import { VERSION } from "../../version.js";
import { getCoreCliCommandDescriptors } from "./command-registry.js";
import { configureProgramHelp } from "./help.js";
import { getSubCliEntries } from "./register.subclis.js";
function buildRootHelpProgram(): Command {
const program = new Command();
configureProgramHelp(program, {
programVersion: VERSION,
channelOptions: [],
messageChannelOptions: "",
agentChannelOptions: "",
});
for (const command of getCoreCliCommandDescriptors()) {
program.command(command.name).description(command.description);
}
for (const command of getSubCliEntries()) {
program.command(command.name).description(command.description);
}
return program;
}
export function outputRootHelp(): void {
const program = buildRootHelpProgram();
program.outputHelp();
}

View File

@ -32,7 +32,7 @@ describe("program routes", () => {
await expect(route?.run(argv)).resolves.toBe(false);
}
it("matches status route and always loads plugins for security parity", () => {
it("matches status route and always preloads plugins", () => {
const route = expectRoute(["status"]);
expect(route?.loadPlugins).toBe(true);
});

View File

@ -7,6 +7,8 @@ const normalizeEnvMock = vi.hoisted(() => vi.fn());
const ensurePathMock = vi.hoisted(() => vi.fn());
const assertRuntimeMock = vi.hoisted(() => vi.fn());
const closeAllMemorySearchManagersMock = vi.hoisted(() => vi.fn(async () => {}));
const outputRootHelpMock = vi.hoisted(() => vi.fn());
const buildProgramMock = vi.hoisted(() => vi.fn());
vi.mock("./route.js", () => ({
tryRouteCli: tryRouteCliMock,
@ -32,6 +34,14 @@ vi.mock("../memory/search-manager.js", () => ({
closeAllMemorySearchManagers: closeAllMemorySearchManagersMock,
}));
vi.mock("./program/root-help.js", () => ({
outputRootHelp: outputRootHelpMock,
}));
vi.mock("./program.js", () => ({
buildProgram: buildProgramMock,
}));
const { runCli } = await import("./run-main.js");
describe("runCli exit behavior", () => {
@ -52,4 +62,19 @@ describe("runCli exit behavior", () => {
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
it("renders root help without building the full program", async () => {
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`unexpected process.exit(${String(code)})`);
}) as typeof process.exit);
await runCli(["node", "openclaw", "--help"]);
expect(tryRouteCliMock).not.toHaveBeenCalled();
expect(outputRootHelpMock).toHaveBeenCalledTimes(1);
expect(buildProgramMock).not.toHaveBeenCalled();
expect(closeAllMemorySearchManagersMock).toHaveBeenCalledTimes(1);
expect(exitSpy).not.toHaveBeenCalled();
exitSpy.mockRestore();
});
});

View File

@ -4,6 +4,7 @@ import {
shouldEnsureCliPath,
shouldRegisterPrimarySubcommand,
shouldSkipPluginCommandRegistration,
shouldUseRootHelpFastPath,
} from "./run-main.js";
describe("rewriteUpdateFlagArgv", () => {
@ -126,3 +127,12 @@ describe("shouldEnsureCliPath", () => {
expect(shouldEnsureCliPath(["node", "openclaw", "acp", "-v"])).toBe(true);
});
});
describe("shouldUseRootHelpFastPath", () => {
it("uses the fast path for root help only", () => {
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help"])).toBe(true);
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--profile", "work", "-h"])).toBe(true);
expect(shouldUseRootHelpFastPath(["node", "openclaw", "status", "--help"])).toBe(false);
expect(shouldUseRootHelpFastPath(["node", "openclaw", "--help", "status"])).toBe(false);
});
});

View File

@ -8,7 +8,12 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
import { assertSupportedRuntime } from "../infra/runtime-guard.js";
import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js";
import { enableConsoleCapture } from "../logging.js";
import { getCommandPathWithRootOptions, getPrimaryCommand, hasHelpOrVersion } from "./argv.js";
import {
getCommandPathWithRootOptions,
getPrimaryCommand,
hasHelpOrVersion,
isRootHelpInvocation,
} from "./argv.js";
import { applyCliProfileEnv, parseCliProfileArgs } from "./profile.js";
import { tryRouteCli } from "./route.js";
import { normalizeWindowsArgv } from "./windows-argv.js";
@ -71,6 +76,10 @@ export function shouldEnsureCliPath(argv: string[]): boolean {
return true;
}
export function shouldUseRootHelpFastPath(argv: string[]): boolean {
return isRootHelpInvocation(argv);
}
export async function runCli(argv: string[] = process.argv) {
let normalizedArgv = normalizeWindowsArgv(argv);
const parsedProfile = parseCliProfileArgs(normalizedArgv);
@ -92,6 +101,12 @@ export async function runCli(argv: string[] = process.argv) {
assertSupportedRuntime();
try {
if (shouldUseRootHelpFastPath(normalizedArgv)) {
const { outputRootHelp } = await import("./program/root-help.js");
outputRootHelp();
return;
}
if (await tryRouteCli(normalizedArgv)) {
return;
}

View File

@ -1,5 +1,3 @@
import { resolveTelegramAccount } from "../../../extensions/telegram/src/accounts.js";
import { deleteTelegramUpdateOffset } from "../../../extensions/telegram/src/update-offset-store.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
@ -11,13 +9,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-ke
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
import { buildAgentSummaries } from "../agents.config.js";
import { setupChannels } from "../onboard-channels.js";
import type { ChannelChoice } from "../onboard-types.js";
import {
ensureOnboardingPluginInstalled,
reloadOnboardingPluginRegistry,
} from "../onboarding/plugin-install.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
@ -56,6 +48,10 @@ export async function channelsAddCommand(
const useWizard = shouldUseWizard(params);
if (useWizard) {
const [{ buildAgentSummaries }, { setupChannels }] = await Promise.all([
import("../agents.config.js"),
import("../onboard-channels.js"),
]);
const prompter = createClackPrompter();
let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {};
@ -176,6 +172,8 @@ export async function channelsAddCommand(
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
if (!channel && catalogEntry) {
const { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry } =
await import("../onboarding/plugin-install.js");
const prompter = createClackPrompter();
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
const result = await ensureOnboardingPluginInstalled({
@ -269,10 +267,20 @@ export async function channelsAddCommand(
return;
}
const previousTelegramToken =
channel === "telegram"
? resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim()
: "";
let previousTelegramToken = "";
let resolveTelegramAccount:
| ((
params: Parameters<
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
>[0],
) => ReturnType<
typeof import("../../../extensions/telegram/src/accounts.js").resolveTelegramAccount
>)
| undefined;
if (channel === "telegram") {
({ resolveTelegramAccount } = await import("../../../extensions/telegram/src/accounts.js"));
previousTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
}
if (accountId !== DEFAULT_ACCOUNT_ID) {
nextConfig = moveSingleAccountChannelSectionToDefaultAccount({
@ -288,7 +296,9 @@ export async function channelsAddCommand(
input,
});
if (channel === "telegram") {
if (channel === "telegram" && resolveTelegramAccount) {
const { deleteTelegramUpdateOffset } =
await import("../../../extensions/telegram/src/update-offset-store.js");
const nextTelegramToken = resolveTelegramAccount({ cfg: nextConfig, accountId }).token.trim();
if (previousTelegramToken !== nextTelegramToken) {
// Clear stale polling offsets after Telegram token rotation.

View File

@ -417,6 +417,12 @@ describe("statusCommand", () => {
expect(payload.securityAudit.summary.warn).toBe(1);
expect(payload.gatewayService.label).toBe("LaunchAgent");
expect(payload.nodeService.label).toBe("LaunchAgent");
expect(mocks.runSecurityAudit).toHaveBeenCalledWith(
expect.objectContaining({
includeFilesystem: true,
includeChannelSecurity: true,
}),
);
});
it("surfaces unknown usage when totalTokens is missing", async () => {
@ -505,8 +511,8 @@ describe("statusCommand", () => {
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0]));
expect(payload.gateway.error).toContain("gateway.auth.token");
expect(payload.gateway.error).toContain("SecretRef");
expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull();
expect(runtime.error).not.toHaveBeenCalled();
});
it("surfaces channel runtime errors from the gateway", async () => {