diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 8a1b8eb3f53..3015ed1d42a 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -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 }); }); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 3e2338f3475..ad468878aeb 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -235,6 +235,10 @@ function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescript return names; } +export function getCoreCliCommandDescriptors(): ReadonlyArray { + return coreEntries.flatMap((entry) => entry.commands); +} + export function getCoreCliCommandNames(): string[] { return collectCoreCliCommandNames(); } diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts new file mode 100644 index 00000000000..b80302e9818 --- /dev/null +++ b/src/cli/program/root-help.ts @@ -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(); +} diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 61be251097e..e7958a684a5 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -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); }); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 3e56c1ce794..6af996ed820 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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(); + }); }); diff --git a/src/cli/run-main.test.ts b/src/cli/run-main.test.ts index 495a23684d1..63259259134 100644 --- a/src/cli/run-main.test.ts +++ b/src/cli/run-main.test.ts @@ -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); + }); +}); diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index c0673ddf2af..188448a64e4 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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; } diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 3cc2f305870..52a358f4946 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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> = {}; @@ -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. diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index e307ffa3694..c40693302ac 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -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 () => {