diff --git a/scripts/check-cli-startup-memory.mjs b/scripts/check-cli-startup-memory.mjs index dbf666e1bfb..1b17e28ceea 100644 --- a/scripts/check-cli-startup-memory.mjs +++ b/scripts/check-cli-startup-memory.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -15,6 +15,21 @@ if (!isLinux && !isMac) { const repoRoot = process.cwd(); const tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-")); +const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir(); +const rssHookPath = path.join(tmpHome, "measure-rss.mjs"); +const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__="; + +writeFileSync( + rssHookPath, + [ + "process.on('exit', () => {", + " const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;", + ` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`, + "});", + "", + ].join("\n"), + "utf8", +); const DEFAULT_LIMITS_MB = { help: 500, @@ -26,13 +41,13 @@ const cases = [ { id: "help", label: "--help", - args: ["node", "openclaw.mjs", "--help"], + args: ["openclaw.mjs", "--help"], limitMb: Number(process.env.OPENCLAW_STARTUP_MEMORY_HELP_MB ?? DEFAULT_LIMITS_MB.help), }, { id: "statusJson", label: "status --json", - args: ["node", "openclaw.mjs", "status", "--json"], + args: ["openclaw.mjs", "status", "--json"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB ?? DEFAULT_LIMITS_MB.statusJson, ), @@ -40,7 +55,7 @@ const cases = [ { id: "gatewayStatus", label: "gateway status", - args: ["node", "openclaw.mjs", "gateway", "status"], + args: ["openclaw.mjs", "gateway", "status"], limitMb: Number( process.env.OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB ?? DEFAULT_LIMITS_MB.gatewayStatus, ), @@ -48,30 +63,44 @@ const cases = [ ]; function parseMaxRssMb(stderr) { - if (isLinux) { - const match = stderr.match(/^\s*Maximum resident set size \(kbytes\):\s*(\d+)\s*$/im); - if (!match) { - return null; - } - return Number(match[1]) / 1024; - } - const match = stderr.match(/^\s*(\d+)\s+maximum resident set size\s*$/im); + const match = stderr.match(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "m")); if (!match) { return null; } - return Number(match[1]) / (1024 * 1024); + return Number(match[1]) / 1024; } -function runCase(testCase) { +function buildBenchEnv() { const env = { - ...process.env, HOME: tmpHome, + USERPROFILE: tmpHome, XDG_CONFIG_HOME: path.join(tmpHome, ".config"), XDG_DATA_HOME: path.join(tmpHome, ".local", "share"), XDG_CACHE_HOME: path.join(tmpHome, ".cache"), + PATH: process.env.PATH ?? "", + TMPDIR: tmpDir, + TEMP: tmpDir, + TMP: tmpDir, + LANG: process.env.LANG ?? "C.UTF-8", + TERM: process.env.TERM ?? "dumb", }; - const timeArgs = isLinux ? ["-v", ...testCase.args] : ["-l", ...testCase.args]; - const result = spawnSync("/usr/bin/time", timeArgs, { + + if (process.env.LC_ALL) { + env.LC_ALL = process.env.LC_ALL; + } + if (process.env.CI) { + env.CI = process.env.CI; + } + if (process.env.NODE_DISABLE_COMPILE_CACHE) { + env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE; + } + + return env; +} + +function runCase(testCase) { + const env = buildBenchEnv(); + const result = spawnSync(process.execPath, ["--import", rssHookPath, ...testCase.args], { cwd: repoRoot, env, encoding: "utf8", diff --git a/src/cli/banner-config-lite.ts b/src/cli/banner-config-lite.ts new file mode 100644 index 00000000000..f402b7c61b9 --- /dev/null +++ b/src/cli/banner-config-lite.ts @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import JSON5 from "json5"; +import { resolveConfigPath } from "../config/paths.js"; +import type { TaglineMode } from "./tagline.js"; + +function parseTaglineMode(value: unknown): TaglineMode | undefined { + if (value === "random" || value === "default" || value === "off") { + return value; + } + return undefined; +} + +export function readCliBannerTaglineMode( + env: NodeJS.ProcessEnv = process.env, +): TaglineMode | undefined { + try { + const configPath = resolveConfigPath(env); + const raw = fs.readFileSync(configPath, "utf8"); + const parsed: { cli?: { banner?: { taglineMode?: unknown } } } = JSON5.parse(raw); + return parseTaglineMode(parsed.cli?.banner?.taglineMode); + } catch { + return undefined; + } +} diff --git a/src/cli/banner.test.ts b/src/cli/banner.test.ts index 93e47a750d2..722a574f49f 100644 --- a/src/cli/banner.test.ts +++ b/src/cli/banner.test.ts @@ -1,9 +1,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -const loadConfigMock = vi.fn(); +const readCliBannerTaglineModeMock = vi.fn(); -vi.mock("../config/config.js", () => ({ - loadConfig: loadConfigMock, +vi.mock("./banner-config-lite.js", () => ({ + readCliBannerTaglineMode: readCliBannerTaglineModeMock, })); let formatCliBannerLine: typeof import("./banner.js").formatCliBannerLine; @@ -13,15 +13,13 @@ beforeAll(async () => { }); beforeEach(() => { - loadConfigMock.mockReset(); - loadConfigMock.mockReturnValue({}); + readCliBannerTaglineModeMock.mockReset(); + readCliBannerTaglineModeMock.mockReturnValue(undefined); }); describe("formatCliBannerLine", () => { it("hides tagline text when cli.banner.taglineMode is off", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -32,9 +30,7 @@ describe("formatCliBannerLine", () => { }); it("uses default tagline when cli.banner.taglineMode is default", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "default" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("default"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", @@ -45,9 +41,7 @@ describe("formatCliBannerLine", () => { }); it("prefers explicit tagline mode over config", () => { - loadConfigMock.mockReturnValue({ - cli: { banner: { taglineMode: "off" } }, - }); + readCliBannerTaglineModeMock.mockReturnValue("off"); const line = formatCliBannerLine("2026.3.7", { commit: "abc1234", diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 07bc16abfa0..17487d58904 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -1,8 +1,8 @@ -import { loadConfig } from "../config/config.js"; import { resolveCommitHash } from "../infra/git-commit.js"; import { visibleWidth } from "../terminal/ansi.js"; import { isRich, theme } from "../terminal/theme.js"; import { hasRootVersionAlias } from "./argv.js"; +import { readCliBannerTaglineMode } from "./banner-config-lite.js"; import { pickTagline, type TaglineMode, type TaglineOptions } from "./tagline.js"; type BannerOptions = TaglineOptions & { @@ -48,12 +48,7 @@ function resolveTaglineMode(options: BannerOptions): TaglineMode | undefined { if (explicit) { return explicit; } - try { - return parseTaglineMode(loadConfig().cli?.banner?.taglineMode); - } catch { - // Fall back to default random behavior when config is missing/invalid. - return undefined; - } + return readCliBannerTaglineMode(options.env); } export function formatCliBannerLine(version: string, options: BannerOptions = {}): string { diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index ad468878aeb..4b39b1d94a9 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -3,8 +3,15 @@ import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommandByName } from "./command-tree.js"; import type { ProgramContext } from "./context.js"; +import { + type CoreCliCommandDescriptor, + getCoreCliCommandDescriptors, + getCoreCliCommandsWithSubcommands, +} from "./core-command-descriptors.js"; import { registerSubCliCommands } from "./register.subclis.js"; +export { getCoreCliCommandDescriptors, getCoreCliCommandsWithSubcommands }; + type CommandRegisterParams = { program: Command; ctx: ProgramContext; @@ -16,12 +23,6 @@ export type CommandRegistration = { register: (params: CommandRegisterParams) => void; }; -type CoreCliCommandDescriptor = { - name: string; - description: string; - hasSubcommands: boolean; -}; - type CoreCliEntry = { commands: CoreCliCommandDescriptor[]; register: (params: CommandRegisterParams) => Promise | void; @@ -217,34 +218,8 @@ const coreEntries: CoreCliEntry[] = [ }, ]; -function collectCoreCliCommandNames(predicate?: (command: CoreCliCommandDescriptor) => boolean) { - const seen = new Set(); - const names: string[] = []; - for (const entry of coreEntries) { - for (const command of entry.commands) { - if (predicate && !predicate(command)) { - continue; - } - if (seen.has(command.name)) { - continue; - } - seen.add(command.name); - names.push(command.name); - } - } - return names; -} - -export function getCoreCliCommandDescriptors(): ReadonlyArray { - return coreEntries.flatMap((entry) => entry.commands); -} - export function getCoreCliCommandNames(): string[] { - return collectCoreCliCommandNames(); -} - -export function getCoreCliCommandsWithSubcommands(): string[] { - return collectCoreCliCommandNames((command) => command.hasSubcommands); + return getCoreCliCommandDescriptors().map((command) => command.name); } function removeEntryCommands(program: Command, entry: CoreCliEntry) { diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts new file mode 100644 index 00000000000..6cad819a1dc --- /dev/null +++ b/src/cli/program/core-command-descriptors.ts @@ -0,0 +1,104 @@ +export type CoreCliCommandDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const CORE_CLI_COMMAND_DESCRIPTORS = [ + { + name: "setup", + description: "Initialize local config and agent workspace", + hasSubcommands: false, + }, + { + name: "onboard", + description: "Interactive onboarding wizard for gateway, workspace, and skills", + hasSubcommands: false, + }, + { + name: "configure", + description: "Interactive setup wizard for credentials, channels, gateway, and agent defaults", + hasSubcommands: false, + }, + { + name: "config", + description: + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", + hasSubcommands: true, + }, + { + name: "backup", + description: "Create and verify local backup archives for OpenClaw state", + hasSubcommands: true, + }, + { + name: "doctor", + description: "Health checks + quick fixes for the gateway and channels", + hasSubcommands: false, + }, + { + name: "dashboard", + description: "Open the Control UI with your current token", + hasSubcommands: false, + }, + { + name: "reset", + description: "Reset local config/state (keeps the CLI installed)", + hasSubcommands: false, + }, + { + name: "uninstall", + description: "Uninstall the gateway service + local data (CLI remains)", + hasSubcommands: false, + }, + { + name: "message", + description: "Send, read, and manage messages", + hasSubcommands: true, + }, + { + name: "memory", + description: "Search and reindex memory files", + hasSubcommands: true, + }, + { + name: "agent", + description: "Run one agent turn via the Gateway", + hasSubcommands: false, + }, + { + name: "agents", + description: "Manage isolated agents (workspaces, auth, routing)", + hasSubcommands: true, + }, + { + name: "status", + description: "Show channel health and recent session recipients", + hasSubcommands: false, + }, + { + name: "health", + description: "Fetch health from the running gateway", + hasSubcommands: false, + }, + { + name: "sessions", + description: "List stored conversation sessions", + hasSubcommands: true, + }, + { + name: "browser", + description: "Manage OpenClaw's dedicated browser (Chrome/Chromium)", + hasSubcommands: true, + }, +] as const satisfies ReadonlyArray; + +export function getCoreCliCommandDescriptors(): ReadonlyArray { + return CORE_CLI_COMMAND_DESCRIPTORS; +} + +export function getCoreCliCommandsWithSubcommands(): string[] { + return CORE_CLI_COMMAND_DESCRIPTORS.filter((command) => command.hasSubcommands).map( + (command) => command.name, + ); +} diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index c22ea7c8322..fc924cec9d3 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -7,9 +7,9 @@ import { hasFlag, hasRootVersionAlias } from "../argv.js"; import { formatCliBannerLine, hasEmittedCliBanner } from "../banner.js"; import { replaceCliName, resolveCliName } from "../cli-name.js"; import { CLI_LOG_LEVEL_VALUES, parseCliLogLevelOption } from "../log-level-option.js"; -import { getCoreCliCommandsWithSubcommands } from "./command-registry.js"; import type { ProgramContext } from "./context.js"; -import { getSubCliCommandsWithSubcommands } from "./register.subclis.js"; +import { getCoreCliCommandsWithSubcommands } from "./core-command-descriptors.js"; +import { getSubCliCommandsWithSubcommands } from "./subcli-descriptors.js"; const CLI_NAME = resolveCliName(); const CLI_NAME_PATTERN = escapeRegExp(CLI_NAME); diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index ad120cc0417..5ace8c10441 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -4,13 +4,17 @@ import { isTruthyEnvValue } from "../../infra/env.js"; import { getPrimaryCommand, hasHelpOrVersion } from "../argv.js"; import { reparseProgramFromActionArgs } from "./action-reparse.js"; import { removeCommand, removeCommandByName } from "./command-tree.js"; +import { + getSubCliCommandsWithSubcommands, + getSubCliEntries as getSubCliEntryDescriptors, + type SubCliDescriptor, +} from "./subcli-descriptors.js"; + +export { getSubCliCommandsWithSubcommands }; type SubCliRegistrar = (program: Command) => Promise | void; -type SubCliEntry = { - name: string; - description: string; - hasSubcommands: boolean; +type SubCliEntry = SubCliDescriptor & { register: SubCliRegistrar; }; @@ -309,12 +313,8 @@ const entries: SubCliEntry[] = [ }, ]; -export function getSubCliEntries(): SubCliEntry[] { - return entries; -} - -export function getSubCliCommandsWithSubcommands(): string[] { - return entries.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +export function getSubCliEntries(): ReadonlyArray { + return getSubCliEntryDescriptors(); } export async function registerSubCliByName(program: Command, name: string): Promise { diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index b80302e9818..500dbe3b039 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -1,8 +1,8 @@ import { Command } from "commander"; import { VERSION } from "../../version.js"; -import { getCoreCliCommandDescriptors } from "./command-registry.js"; +import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; -import { getSubCliEntries } from "./register.subclis.js"; +import { getSubCliEntries } from "./subcli-descriptors.js"; function buildRootHelpProgram(): Command { const program = new Command(); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts new file mode 100644 index 00000000000..4011e706b2b --- /dev/null +++ b/src/cli/program/subcli-descriptors.ts @@ -0,0 +1,144 @@ +export type SubCliDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export const SUB_CLI_DESCRIPTORS = [ + { name: "acp", description: "Agent Control Protocol tools", hasSubcommands: true }, + { + name: "gateway", + description: "Run, inspect, and query the WebSocket Gateway", + hasSubcommands: true, + }, + { name: "daemon", description: "Gateway service (legacy alias)", hasSubcommands: true }, + { name: "logs", description: "Tail gateway file logs via RPC", hasSubcommands: false }, + { + name: "system", + description: "System events, heartbeat, and presence", + hasSubcommands: true, + }, + { + name: "models", + description: "Discover, scan, and configure models", + hasSubcommands: true, + }, + { + name: "approvals", + description: "Manage exec approvals (gateway or node host)", + hasSubcommands: true, + }, + { + name: "nodes", + description: "Manage gateway-owned node pairing and node commands", + hasSubcommands: true, + }, + { + name: "devices", + description: "Device pairing + token management", + hasSubcommands: true, + }, + { + name: "node", + description: "Run and manage the headless node host service", + hasSubcommands: true, + }, + { + name: "sandbox", + description: "Manage sandbox containers for agent isolation", + hasSubcommands: true, + }, + { + name: "tui", + description: "Open a terminal UI connected to the Gateway", + hasSubcommands: false, + }, + { + name: "cron", + description: "Manage cron jobs via the Gateway scheduler", + hasSubcommands: true, + }, + { + name: "dns", + description: "DNS helpers for wide-area discovery (Tailscale + CoreDNS)", + hasSubcommands: true, + }, + { + name: "docs", + description: "Search the live OpenClaw docs", + hasSubcommands: false, + }, + { + name: "hooks", + description: "Manage internal agent hooks", + hasSubcommands: true, + }, + { + name: "webhooks", + description: "Webhook helpers and integrations", + hasSubcommands: true, + }, + { + name: "qr", + description: "Generate iOS pairing QR/setup code", + hasSubcommands: false, + }, + { + name: "clawbot", + description: "Legacy clawbot command aliases", + hasSubcommands: true, + }, + { + name: "pairing", + description: "Secure DM pairing (approve inbound requests)", + hasSubcommands: true, + }, + { + name: "plugins", + description: "Manage OpenClaw plugins and extensions", + hasSubcommands: true, + }, + { + name: "channels", + description: "Manage connected chat channels (Telegram, Discord, etc.)", + hasSubcommands: true, + }, + { + name: "directory", + description: "Lookup contact and group IDs (self, peers, groups) for supported chat channels", + hasSubcommands: true, + }, + { + name: "security", + description: "Security tools and local config audits", + hasSubcommands: true, + }, + { + name: "secrets", + description: "Secrets runtime reload controls", + hasSubcommands: true, + }, + { + name: "skills", + description: "List and inspect available skills", + hasSubcommands: true, + }, + { + name: "update", + description: "Update OpenClaw and inspect update channel status", + hasSubcommands: true, + }, + { + name: "completion", + description: "Generate shell completion script", + hasSubcommands: false, + }, +] as const satisfies ReadonlyArray; + +export function getSubCliEntries(): ReadonlyArray { + return SUB_CLI_DESCRIPTORS; +} + +export function getSubCliCommandsWithSubcommands(): string[] { + return SUB_CLI_DESCRIPTORS.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); +}