Skip to content

Commit f39f56a

Browse files
authored
perf(cli): cache stable subcommand help (#84786)
Serve stable doctor, gateway, models, and plugins parent help from startup metadata while preserving strict argv validation and version precedence. Verification: - pnpm test src/cli/run-main.test.ts src/cli/run-main.exit.test.ts test/scripts/write-cli-startup-metadata.test.ts -- --reporter=default - pnpm check:changed - GitHub required checks passed
1 parent 2000227 commit f39f56a

8 files changed

Lines changed: 311 additions & 5 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai
3030
- CLI/perf: keep `setup --help`, `onboard --help`, and `configure --help` out of the full wizard runtime while preserving the existing help output. (#84488) Thanks @frankekn.
3131
- CLI/perf: keep `agents --help` out of agents action/runtime imports so help, completion, and command discovery paths avoid loading the full agents runtime. (#84483) Thanks @frankekn.
3232
- CLI/perf: keep `secrets --help` and `nodes --help` on the precomputed help path so parent help avoids loading action-heavy command runtime modules. (#84818) Thanks @frankekn.
33+
- CLI/perf: serve `doctor`, `gateway`, `models`, and `plugins` parent help from startup metadata so common subcommand help avoids full CLI program construction. (#84786) Thanks @frankekn.
3334

3435
## 2026.5.20
3536

scripts/write-cli-startup-metadata.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const extensionsDir = path.join(rootDir, "extensions");
2828
const ROOT_HELP_RENDER_TIMEOUT_MS = 120_000;
2929
const BROWSER_HELP_RENDER_TIMEOUT_MS = 120_000;
3030
const COMMAND_HELP_RENDER_TIMEOUT_MS = 120_000;
31+
const PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS = ["doctor", "gateway", "models", "plugins"] as const;
3132
const CORE_CHANNEL_ORDER = [
3233
"telegram",
3334
"whatsapp",
@@ -50,6 +51,8 @@ type BundledChannelCatalog = {
5051
signature: string;
5152
};
5253

54+
type PrecomputedSubcommandHelpCommand = (typeof PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS)[number];
55+
type PrecomputedSubcommandHelpText = Record<PrecomputedSubcommandHelpCommand, string>;
5356
type RootHelpRenderContext = Pick<RootHelpRenderOptions, "config" | "env">;
5457

5558
function resolveRootHelpBundleIdentity(
@@ -143,6 +146,30 @@ function resolveNodesHelpSourceSignature(sourceRootDir: string = rootDir): strin
143146
return hash.digest("hex");
144147
}
145148

149+
function resolveSubcommandHelpSourceSignature(sourceRootDir: string = rootDir): string {
150+
const hash = createHash("sha1");
151+
updateHashFromFiles(
152+
hash,
153+
[
154+
path.join(sourceRootDir, "src/cli/program/help.ts"),
155+
path.join(sourceRootDir, "src/cli/program/context.ts"),
156+
path.join(sourceRootDir, "src/cli/banner.ts"),
157+
path.join(sourceRootDir, "src/cli/help-format.ts"),
158+
path.join(sourceRootDir, "src/cli/daemon-cli/register-service-commands.ts"),
159+
path.join(sourceRootDir, "src/cli/program/register.maintenance.ts"),
160+
path.join(sourceRootDir, "src/cli/gateway-cli.ts"),
161+
path.join(sourceRootDir, "src/cli/gateway-cli/register.ts"),
162+
path.join(sourceRootDir, "src/cli/gateway-cli/run-command.ts"),
163+
path.join(sourceRootDir, "src/cli/models-cli.ts"),
164+
path.join(sourceRootDir, "src/cli/plugins-cli.ts"),
165+
path.join(sourceRootDir, "src/terminal/links.ts"),
166+
path.join(sourceRootDir, "src/terminal/theme.ts"),
167+
],
168+
sourceRootDir,
169+
);
170+
return hash.digest("hex");
171+
}
172+
146173
export function readBundledChannelCatalog(
147174
extensionsDirOverride: string = extensionsDir,
148175
): BundledChannelCatalog {
@@ -362,7 +389,7 @@ function renderSourceBrowserHelpText(
362389
}
363390

364391
function renderSourceCommandHelpText(
365-
command: "nodes" | "secrets",
392+
command: "nodes" | "secrets" | PrecomputedSubcommandHelpCommand,
366393
renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(),
367394
): string {
368395
const result = spawnSync(
@@ -403,6 +430,16 @@ function renderSourceNodesHelpText(
403430
return renderSourceCommandHelpText("nodes", renderContext);
404431
}
405432

433+
function renderSourceSubcommandHelpTextRecord(
434+
renderContext: RootHelpRenderContext = createIsolatedRootHelpRenderContext(),
435+
): PrecomputedSubcommandHelpText {
436+
const entries = PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS.map((commandName) => [
437+
commandName,
438+
renderSourceCommandHelpText(commandName, renderContext),
439+
]);
440+
return Object.fromEntries(entries) as PrecomputedSubcommandHelpText;
441+
}
442+
406443
export async function writeCliStartupMetadata(options?: {
407444
distDir?: string;
408445
outputPath?: string;
@@ -413,6 +450,7 @@ export async function writeCliStartupMetadata(options?: {
413450
renderSourceBrowserHelpText?: typeof renderSourceBrowserHelpText;
414451
renderSourceSecretsHelpText?: typeof renderSourceSecretsHelpText;
415452
renderSourceNodesHelpText?: typeof renderSourceNodesHelpText;
453+
renderSourceSubcommandHelpTextRecord?: typeof renderSourceSubcommandHelpTextRecord;
416454
}): Promise<void> {
417455
const resolvedDistDir = options?.distDir ?? distDir;
418456
const resolvedOutputPath = options?.outputPath ?? outputPath;
@@ -423,6 +461,7 @@ export async function writeCliStartupMetadata(options?: {
423461
const browserHelpSourceSignature = resolveBrowserHelpSourceSignature(resolvedSourceRootDir);
424462
const secretsHelpSourceSignature = resolveSecretsHelpSourceSignature(resolvedSourceRootDir);
425463
const nodesHelpSourceSignature = resolveNodesHelpSourceSignature(resolvedSourceRootDir);
464+
const subcommandHelpSourceSignature = resolveSubcommandHelpSourceSignature(resolvedSourceRootDir);
426465
const bundledPluginsDir = path.join(resolvedDistDir, "extensions");
427466
const renderContext = createIsolatedRootHelpRenderContext(
428467
existsSync(bundledPluginsDir) ? bundledPluginsDir : resolvedExtensionsDir,
@@ -435,24 +474,28 @@ export async function writeCliStartupMetadata(options?: {
435474
browserHelpSourceSignature?: unknown;
436475
secretsHelpSourceSignature?: unknown;
437476
nodesHelpSourceSignature?: unknown;
477+
subcommandHelpSourceSignature?: unknown;
438478
channelCatalogSignature?: unknown;
439479
browserHelpText?: unknown;
440480
secretsHelpText?: unknown;
441481
nodesHelpText?: unknown;
482+
subcommandHelpText?: unknown;
442483
};
443484
if (
444485
bundleIdentity &&
445486
existing.rootHelpBundleSignature === bundleIdentity.signature &&
446487
existing.browserHelpSourceSignature === browserHelpSourceSignature &&
447488
existing.secretsHelpSourceSignature === secretsHelpSourceSignature &&
448489
existing.nodesHelpSourceSignature === nodesHelpSourceSignature &&
490+
existing.subcommandHelpSourceSignature === subcommandHelpSourceSignature &&
449491
existing.channelCatalogSignature === channelCatalog.signature &&
450492
typeof existing.browserHelpText === "string" &&
451493
existing.browserHelpText.length > 0 &&
452494
typeof existing.secretsHelpText === "string" &&
453495
existing.secretsHelpText.length > 0 &&
454496
typeof existing.nodesHelpText === "string" &&
455-
existing.nodesHelpText.length > 0
497+
existing.nodesHelpText.length > 0 &&
498+
hasAllPrecomputedSubcommandHelpText(existing.subcommandHelpText)
456499
) {
457500
return;
458501
}
@@ -478,6 +521,9 @@ export async function writeCliStartupMetadata(options?: {
478521
const nodesHelpText = (options?.renderSourceNodesHelpText ?? renderSourceNodesHelpText)(
479522
renderContext,
480523
);
524+
const subcommandHelpText = (
525+
options?.renderSourceSubcommandHelpTextRecord ?? renderSourceSubcommandHelpTextRecord
526+
)(renderContext);
481527

482528
mkdirSync(resolvedDistDir, { recursive: true });
483529
writeFileSync(
@@ -491,9 +537,11 @@ export async function writeCliStartupMetadata(options?: {
491537
browserHelpSourceSignature,
492538
secretsHelpSourceSignature,
493539
nodesHelpSourceSignature,
540+
subcommandHelpSourceSignature,
494541
browserHelpText,
495542
secretsHelpText,
496543
nodesHelpText,
544+
subcommandHelpText,
497545
rootHelpText,
498546
},
499547
null,
@@ -503,6 +551,16 @@ export async function writeCliStartupMetadata(options?: {
503551
);
504552
}
505553

554+
function hasAllPrecomputedSubcommandHelpText(value: unknown): boolean {
555+
if (typeof value !== "object" || value === null) {
556+
return false;
557+
}
558+
const record = value as Partial<Record<PrecomputedSubcommandHelpCommand, unknown>>;
559+
return PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS.every(
560+
(commandName) => typeof record[commandName] === "string" && record[commandName].length > 0,
561+
);
562+
}
563+
506564
if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) {
507565
await writeCliStartupMetadata();
508566
process.exit(0);

src/cli/root-help-metadata.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { readCliStartupMetadata } from "./startup-metadata.js";
22

3+
export type PrecomputedSubcommandHelpName = "doctor" | "gateway" | "models" | "plugins";
4+
35
let precomputedRootHelpText: string | null | undefined;
46
let precomputedBrowserHelpText: string | null | undefined;
57
let precomputedSecretsHelpText: string | null | undefined;
68
let precomputedNodesHelpText: string | null | undefined;
9+
let precomputedSubcommandHelpText:
10+
| Partial<Record<PrecomputedSubcommandHelpName, string | null>>
11+
| undefined;
712

813
type PrecomputedHelpTextKey =
914
| "rootHelpText"
@@ -59,6 +64,31 @@ export function loadPrecomputedNodesHelpText(): string | null {
5964
});
6065
}
6166

67+
export function loadPrecomputedSubcommandHelpText(commandName: string): string | null {
68+
if (!isPrecomputedSubcommandHelpName(commandName)) {
69+
return null;
70+
}
71+
const cache = precomputedSubcommandHelpText?.[commandName];
72+
if (cache !== undefined) {
73+
return cache;
74+
}
75+
try {
76+
const parsed = readCliStartupMetadata(import.meta.url);
77+
const subcommandHelpText = parsed?.subcommandHelpText;
78+
if (isSubcommandHelpTextRecord(subcommandHelpText)) {
79+
const value = subcommandHelpText[commandName];
80+
if (typeof value === "string" && value.length > 0) {
81+
setPrecomputedSubcommandHelpText(commandName, value);
82+
return value;
83+
}
84+
}
85+
} catch {
86+
// Fall back to live help rendering.
87+
}
88+
setPrecomputedSubcommandHelpText(commandName, null);
89+
return null;
90+
}
91+
6292
export function outputPrecomputedRootHelpText(): boolean {
6393
const rootHelpText = loadPrecomputedRootHelpText();
6494
if (!rootHelpText) {
@@ -95,12 +125,49 @@ export function outputPrecomputedNodesHelpText(): boolean {
95125
return true;
96126
}
97127

128+
export function outputPrecomputedSubcommandHelpText(commandName: string): boolean {
129+
const helpText = loadPrecomputedSubcommandHelpText(commandName);
130+
if (!helpText) {
131+
return false;
132+
}
133+
process.stdout.write(helpText);
134+
return true;
135+
}
136+
137+
function isPrecomputedSubcommandHelpName(
138+
commandName: string,
139+
): commandName is PrecomputedSubcommandHelpName {
140+
return (
141+
commandName === "doctor" ||
142+
commandName === "gateway" ||
143+
commandName === "models" ||
144+
commandName === "plugins"
145+
);
146+
}
147+
148+
function isSubcommandHelpTextRecord(
149+
value: unknown,
150+
): value is Partial<Record<PrecomputedSubcommandHelpName, unknown>> {
151+
return typeof value === "object" && value !== null;
152+
}
153+
154+
function setPrecomputedSubcommandHelpText(
155+
commandName: PrecomputedSubcommandHelpName,
156+
value: string | null,
157+
): void {
158+
precomputedSubcommandHelpText = {
159+
...precomputedSubcommandHelpText,
160+
[commandName]: value,
161+
};
162+
}
163+
98164
export const testing = {
99165
resetPrecomputedRootHelpTextForTests(): void {
100166
precomputedRootHelpText = undefined;
101167
precomputedBrowserHelpText = undefined;
102168
precomputedSecretsHelpText = undefined;
103169
precomputedNodesHelpText = undefined;
170+
precomputedSubcommandHelpText = undefined;
104171
},
105172
};
106173
export { testing as __testing };

src/cli/run-main-policy.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OpenClawConfig } from "../config/types.openclaw.js";
2+
import { consumeRootOptionToken } from "../infra/cli-root-options.js";
23
import {
34
resolveManifestCommandAliasOwnerInRegistry,
45
resolveManifestToolOwnerInRegistry,
@@ -22,11 +23,56 @@ import { getSubCliParentDefaultHelpCommands } from "./program/subcli-descriptors
2223

2324
const ROOT_HELP_ALIASES = new Set(["tools"]);
2425
const SETUP_ONBOARD_CONFIGURE_HELP_COMMANDS = new Set(["setup", "onboard", "configure"]);
26+
const PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS = new Set(["doctor", "gateway", "models", "plugins"]);
27+
const HELP_FLAGS = new Set(["-h", "--help"]);
28+
const VERSION_FLAGS = new Set(["-V", "--version"]);
2529
const BARE_PARENT_DEFAULT_HELP_COMMANDS = new Set([
2630
...getCoreCliParentDefaultHelpCommands(),
2731
...getSubCliParentDefaultHelpCommands(),
2832
]);
2933

34+
function hasHelpFlag(argv: string[]): boolean {
35+
return hasFlag(argv, "-h") || hasFlag(argv, "--help");
36+
}
37+
38+
function resolveStrictPrecomputedSubcommandHelpCommand(argv: string[]): string | null {
39+
const args = argv.slice(2);
40+
let commandName: string | null = null;
41+
let sawHelp = false;
42+
43+
for (let index = 0; index < args.length; index += 1) {
44+
const arg = args[index];
45+
if (!arg || arg === "--") {
46+
return null;
47+
}
48+
if (VERSION_FLAGS.has(arg)) {
49+
return null;
50+
}
51+
if (!commandName) {
52+
const consumed = consumeRootOptionToken(args, index);
53+
if (consumed > 0) {
54+
index += consumed - 1;
55+
continue;
56+
}
57+
if (arg.startsWith("-")) {
58+
return null;
59+
}
60+
if (!PRECOMPUTED_SUBCOMMAND_HELP_COMMANDS.has(arg)) {
61+
return null;
62+
}
63+
commandName = arg;
64+
continue;
65+
}
66+
if (HELP_FLAGS.has(arg)) {
67+
sawHelp = true;
68+
continue;
69+
}
70+
return null;
71+
}
72+
73+
return commandName && sawHelp ? commandName : null;
74+
}
75+
3076
function isBareParentDefaultHelpArgv(argv: string[]): boolean {
3177
const invocation = resolveCliArgvInvocation(argv);
3278
const [primary, extra] = invocation.commandPath;
@@ -86,7 +132,7 @@ export function shouldUseBrowserHelpFastPath(
86132
return (
87133
invocation.commandPath.length === 1 &&
88134
invocation.commandPath[0] === "browser" &&
89-
(hasFlag(argv, "--help") || hasFlag(argv, "-h"))
135+
hasHelpFlag(argv)
90136
);
91137
}
92138

@@ -101,7 +147,7 @@ export function shouldUseSecretsHelpFastPath(
101147
return (
102148
invocation.commandPath.length === 1 &&
103149
invocation.commandPath[0] === "secrets" &&
104-
(hasFlag(argv, "--help") || hasFlag(argv, "-h"))
150+
hasHelpFlag(argv)
105151
);
106152
}
107153

@@ -116,7 +162,7 @@ export function shouldUseNodesHelpFastPath(
116162
return (
117163
invocation.commandPath.length === 1 &&
118164
invocation.commandPath[0] === "nodes" &&
119-
(hasFlag(argv, "--help") || hasFlag(argv, "-h"))
165+
hasHelpFlag(argv)
120166
);
121167
}
122168

@@ -135,6 +181,16 @@ export function shouldUseSetupOnboardConfigureHelpFastPath(
135181
);
136182
}
137183

184+
export function resolvePrecomputedSubcommandHelpFastPath(
185+
argv: string[],
186+
env: NodeJS.ProcessEnv = process.env,
187+
): string | null {
188+
if (env.OPENCLAW_DISABLE_CLI_STARTUP_HELP_FAST_PATH === "1") {
189+
return null;
190+
}
191+
return resolveStrictPrecomputedSubcommandHelpCommand(argv);
192+
}
193+
138194
export function shouldStartCrestodianForBareRoot(argv: string[]): boolean {
139195
const invocation = resolveCliArgvInvocation(argv);
140196
return invocation.commandPath.length === 0 && !invocation.hasHelpOrVersion;

0 commit comments

Comments
 (0)