diff --git a/src/cli/command-bootstrap.test.ts b/src/cli/command-bootstrap.test.ts new file mode 100644 index 00000000000..40d3d4e0fd0 --- /dev/null +++ b/src/cli/command-bootstrap.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ensureConfigReadyMock = vi.hoisted(() => vi.fn(async () => {})); +const ensureCliPluginRegistryLoadedMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("./program/config-guard.js", () => ({ + ensureConfigReady: ensureConfigReadyMock, +})); + +vi.mock("./plugin-registry-loader.js", () => ({ + ensureCliPluginRegistryLoaded: ensureCliPluginRegistryLoadedMock, + resolvePluginRegistryScopeForCommandPath: vi.fn((commandPath: string[]) => + commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all", + ), +})); + +describe("ensureCliCommandBootstrap", () => { + let ensureCliCommandBootstrap: typeof import("./command-bootstrap.js").ensureCliCommandBootstrap; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + ({ ensureCliCommandBootstrap } = await import("./command-bootstrap.js")); + }); + + it("runs config guard and plugin loading with shared options", async () => { + const runtime = {} as never; + + await ensureCliCommandBootstrap({ + runtime, + commandPath: ["agents", "list"], + suppressDoctorStdout: true, + allowInvalid: true, + loadPlugins: true, + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime, + commandPath: ["agents", "list"], + allowInvalid: true, + suppressDoctorStdout: true, + }); + expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "all", + routeLogsToStderr: true, + }); + }); + + it("skips config guard without skipping plugin loading", async () => { + await ensureCliCommandBootstrap({ + runtime: {} as never, + commandPath: ["status"], + suppressDoctorStdout: true, + skipConfigGuard: true, + loadPlugins: true, + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensureCliPluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "channels", + routeLogsToStderr: true, + }); + }); + + it("does nothing extra when plugin loading is disabled", async () => { + await ensureCliCommandBootstrap({ + runtime: {} as never, + commandPath: ["config", "validate"], + skipConfigGuard: true, + loadPlugins: false, + }); + + expect(ensureConfigReadyMock).not.toHaveBeenCalled(); + expect(ensureCliPluginRegistryLoadedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/command-bootstrap.ts b/src/cli/command-bootstrap.ts new file mode 100644 index 00000000000..8ce9e7cfd66 --- /dev/null +++ b/src/cli/command-bootstrap.ts @@ -0,0 +1,38 @@ +import type { RuntimeEnv } from "../runtime.js"; +import { + ensureCliPluginRegistryLoaded, + resolvePluginRegistryScopeForCommandPath, +} from "./plugin-registry-loader.js"; + +let configGuardModulePromise: Promise | undefined; + +function loadConfigGuardModule() { + configGuardModulePromise ??= import("./program/config-guard.js"); + return configGuardModulePromise; +} + +export async function ensureCliCommandBootstrap(params: { + runtime: RuntimeEnv; + commandPath: string[]; + suppressDoctorStdout?: boolean; + skipConfigGuard?: boolean; + allowInvalid?: boolean; + loadPlugins?: boolean; +}) { + if (!params.skipConfigGuard) { + const { ensureConfigReady } = await loadConfigGuardModule(); + await ensureConfigReady({ + runtime: params.runtime, + commandPath: params.commandPath, + ...(params.allowInvalid ? { allowInvalid: true } : {}), + ...(params.suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), + }); + } + if (!params.loadPlugins) { + return; + } + await ensureCliPluginRegistryLoaded({ + scope: resolvePluginRegistryScopeForCommandPath(params.commandPath), + routeLogsToStderr: params.suppressDoctorStdout, + }); +} diff --git a/src/cli/command-execution-startup.test.ts b/src/cli/command-execution-startup.test.ts new file mode 100644 index 00000000000..f0d1dfb00ea --- /dev/null +++ b/src/cli/command-execution-startup.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const emitCliBannerMock = vi.hoisted(() => vi.fn()); +const routeLogsToStderrMock = vi.hoisted(() => vi.fn()); +const ensureCliCommandBootstrapMock = vi.hoisted(() => vi.fn(async () => {})); + +vi.mock("./banner.js", () => ({ + emitCliBanner: emitCliBannerMock, +})); + +vi.mock("../logging/console.js", () => ({ + routeLogsToStderr: routeLogsToStderrMock, +})); + +vi.mock("./command-bootstrap.js", () => ({ + ensureCliCommandBootstrap: ensureCliCommandBootstrapMock, +})); + +describe("command-execution-startup", () => { + let mod: typeof import("./command-execution-startup.js"); + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + mod = await import("./command-execution-startup.js"); + }); + + it("resolves startup context from argv and mode", () => { + expect( + mod.resolveCliExecutionStartupContext({ + argv: ["node", "openclaw", "status", "--json"], + jsonOutputMode: true, + routeMode: true, + }), + ).toEqual({ + commandPath: ["status"], + startupPolicy: { + suppressDoctorStdout: true, + hideBanner: false, + skipConfigGuard: true, + loadPlugins: false, + }, + }); + }); + + it("routes logs to stderr and emits banner only when allowed", async () => { + await mod.applyCliExecutionStartupPresentation({ + startupPolicy: { + suppressDoctorStdout: true, + hideBanner: false, + skipConfigGuard: false, + loadPlugins: true, + }, + version: "1.2.3", + argv: ["node", "openclaw", "status"], + }); + + expect(routeLogsToStderrMock).toHaveBeenCalledTimes(1); + expect(emitCliBannerMock).toHaveBeenCalledWith("1.2.3", { + argv: ["node", "openclaw", "status"], + }); + + await mod.applyCliExecutionStartupPresentation({ + startupPolicy: { + suppressDoctorStdout: false, + hideBanner: true, + skipConfigGuard: false, + loadPlugins: true, + }, + version: "1.2.3", + showBanner: true, + }); + + expect(emitCliBannerMock).toHaveBeenCalledTimes(1); + }); + + it("forwards startup policy into bootstrap defaults and overrides", async () => { + const statusRuntime = {} as never; + await mod.ensureCliExecutionBootstrap({ + runtime: statusRuntime, + commandPath: ["status"], + startupPolicy: { + suppressDoctorStdout: true, + hideBanner: false, + skipConfigGuard: true, + loadPlugins: false, + }, + }); + + expect(ensureCliCommandBootstrapMock).toHaveBeenCalledWith({ + runtime: statusRuntime, + commandPath: ["status"], + suppressDoctorStdout: true, + allowInvalid: undefined, + loadPlugins: false, + skipConfigGuard: true, + }); + + const messageRuntime = {} as never; + await mod.ensureCliExecutionBootstrap({ + runtime: messageRuntime, + commandPath: ["message", "send"], + startupPolicy: { + suppressDoctorStdout: false, + hideBanner: false, + skipConfigGuard: false, + loadPlugins: false, + }, + allowInvalid: true, + loadPlugins: true, + }); + + expect(ensureCliCommandBootstrapMock).toHaveBeenLastCalledWith({ + runtime: messageRuntime, + commandPath: ["message", "send"], + suppressDoctorStdout: false, + allowInvalid: true, + loadPlugins: true, + skipConfigGuard: false, + }); + }); +}); diff --git a/src/cli/command-execution-startup.ts b/src/cli/command-execution-startup.ts new file mode 100644 index 00000000000..a9846fb4a90 --- /dev/null +++ b/src/cli/command-execution-startup.ts @@ -0,0 +1,64 @@ +import { routeLogsToStderr } from "../logging/console.js"; +import type { RuntimeInterface } from "../runtime.js"; +import { getCommandPathWithRootOptions } from "./argv.js"; +import { ensureCliCommandBootstrap } from "./command-bootstrap.js"; +import { resolveCliStartupPolicy } from "./command-startup-policy.js"; + +type CliStartupPolicy = ReturnType; + +export function resolveCliExecutionStartupContext(params: { + argv: string[]; + jsonOutputMode: boolean; + env?: NodeJS.ProcessEnv; + routeMode?: boolean; +}) { + const commandPath = getCommandPathWithRootOptions(params.argv, 2); + return { + commandPath, + startupPolicy: resolveCliStartupPolicy({ + commandPath, + jsonOutputMode: params.jsonOutputMode, + env: params.env, + routeMode: params.routeMode, + }), + }; +} + +export async function applyCliExecutionStartupPresentation(params: { + argv?: string[]; + routeLogsToStderrOnSuppress?: boolean; + startupPolicy: CliStartupPolicy; + showBanner?: boolean; + version?: string; +}) { + if (params.startupPolicy.suppressDoctorStdout && params.routeLogsToStderrOnSuppress !== false) { + routeLogsToStderr(); + } + if (params.startupPolicy.hideBanner || params.showBanner === false || !params.version) { + return; + } + const { emitCliBanner } = await import("./banner.js"); + if (params.argv) { + emitCliBanner(params.version, { argv: params.argv }); + return; + } + emitCliBanner(params.version); +} + +export async function ensureCliExecutionBootstrap(params: { + runtime: RuntimeInterface; + commandPath: string[]; + startupPolicy: CliStartupPolicy; + allowInvalid?: boolean; + loadPlugins?: boolean; + skipConfigGuard?: boolean; +}) { + await ensureCliCommandBootstrap({ + runtime: params.runtime, + commandPath: params.commandPath, + suppressDoctorStdout: params.startupPolicy.suppressDoctorStdout, + allowInvalid: params.allowInvalid, + loadPlugins: params.loadPlugins ?? params.startupPolicy.loadPlugins, + skipConfigGuard: params.skipConfigGuard ?? params.startupPolicy.skipConfigGuard, + }); +} diff --git a/src/cli/command-startup-policy.test.ts b/src/cli/command-startup-policy.test.ts new file mode 100644 index 00000000000..f75845d9f65 --- /dev/null +++ b/src/cli/command-startup-policy.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { + resolveCliStartupPolicy, + shouldBypassConfigGuardForCommandPath, + shouldEnsureCliPathForCommandPath, + shouldHideCliBannerForCommandPath, + shouldLoadPluginsForCommandPath, + shouldSkipRouteConfigGuardForCommandPath, +} from "./command-startup-policy.js"; + +describe("command-startup-policy", () => { + it("matches config guard bypass commands", () => { + expect(shouldBypassConfigGuardForCommandPath(["backup", "create"])).toBe(true); + expect(shouldBypassConfigGuardForCommandPath(["config", "validate"])).toBe(true); + expect(shouldBypassConfigGuardForCommandPath(["config", "schema"])).toBe(true); + expect(shouldBypassConfigGuardForCommandPath(["status"])).toBe(false); + }); + + it("matches route-first config guard skip policy", () => { + expect( + shouldSkipRouteConfigGuardForCommandPath({ + commandPath: ["status"], + suppressDoctorStdout: true, + }), + ).toBe(true); + expect( + shouldSkipRouteConfigGuardForCommandPath({ + commandPath: ["gateway", "status"], + suppressDoctorStdout: false, + }), + ).toBe(true); + expect( + shouldSkipRouteConfigGuardForCommandPath({ + commandPath: ["status"], + suppressDoctorStdout: false, + }), + ).toBe(false); + }); + + it("matches plugin preload policy", () => { + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["status"], + jsonOutputMode: false, + }), + ).toBe(true); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["status"], + jsonOutputMode: true, + }), + ).toBe(false); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["channels", "add"], + jsonOutputMode: false, + }), + ).toBe(false); + expect( + shouldLoadPluginsForCommandPath({ + commandPath: ["agents", "list"], + jsonOutputMode: false, + }), + ).toBe(true); + }); + + it("matches banner suppression policy", () => { + expect(shouldHideCliBannerForCommandPath(["update", "status"])).toBe(true); + expect(shouldHideCliBannerForCommandPath(["completion"])).toBe(true); + expect( + shouldHideCliBannerForCommandPath(["status"], { + ...process.env, + OPENCLAW_HIDE_BANNER: "1", + }), + ).toBe(true); + expect(shouldHideCliBannerForCommandPath(["status"], {})).toBe(false); + }); + + it("matches CLI PATH bootstrap policy", () => { + expect(shouldEnsureCliPathForCommandPath(["status"])).toBe(false); + expect(shouldEnsureCliPathForCommandPath(["sessions"])).toBe(false); + expect(shouldEnsureCliPathForCommandPath(["config", "get"])).toBe(false); + expect(shouldEnsureCliPathForCommandPath(["models", "status"])).toBe(false); + expect(shouldEnsureCliPathForCommandPath(["message", "send"])).toBe(true); + expect(shouldEnsureCliPathForCommandPath([])).toBe(true); + }); + + it("aggregates startup policy for commander and route-first callers", () => { + expect( + resolveCliStartupPolicy({ + commandPath: ["status"], + jsonOutputMode: true, + }), + ).toEqual({ + suppressDoctorStdout: true, + hideBanner: false, + skipConfigGuard: false, + loadPlugins: false, + }); + + expect( + resolveCliStartupPolicy({ + commandPath: ["status"], + jsonOutputMode: true, + routeMode: true, + }), + ).toEqual({ + suppressDoctorStdout: true, + hideBanner: false, + skipConfigGuard: true, + loadPlugins: false, + }); + }); +}); diff --git a/src/cli/command-startup-policy.ts b/src/cli/command-startup-policy.ts new file mode 100644 index 00000000000..5457bb23ccc --- /dev/null +++ b/src/cli/command-startup-policy.ts @@ -0,0 +1,101 @@ +import { isTruthyEnvValue } from "../infra/env.js"; + +const PLUGIN_REQUIRED_COMMANDS = new Set([ + "agent", + "message", + "channels", + "directory", + "agents", + "configure", + "status", + "health", +]); + +const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]); + +export function shouldBypassConfigGuardForCommandPath(commandPath: string[]): boolean { + const [primary, secondary] = commandPath; + if (!primary) { + return false; + } + if (CONFIG_GUARD_BYPASS_COMMANDS.has(primary)) { + return true; + } + return primary === "config" && (secondary === "validate" || secondary === "schema"); +} + +export function shouldSkipRouteConfigGuardForCommandPath(params: { + commandPath: string[]; + suppressDoctorStdout: boolean; +}): boolean { + return ( + (params.commandPath[0] === "status" && params.suppressDoctorStdout) || + (params.commandPath[0] === "gateway" && params.commandPath[1] === "status") + ); +} + +export function shouldLoadPluginsForCommandPath(params: { + commandPath: string[]; + jsonOutputMode: boolean; +}): boolean { + const [primary, secondary] = params.commandPath; + if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) { + return false; + } + if ((primary === "status" || primary === "health") && params.jsonOutputMode) { + return false; + } + return !(primary === "onboard" || (primary === "channels" && secondary === "add")); +} + +export function shouldHideCliBannerForCommandPath( + commandPath: string[], + env: NodeJS.ProcessEnv = process.env, +): boolean { + return ( + isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) || + commandPath[0] === "update" || + commandPath[0] === "completion" || + (commandPath[0] === "plugins" && commandPath[1] === "update") + ); +} + +export function shouldEnsureCliPathForCommandPath(commandPath: string[]): boolean { + const [primary, secondary] = commandPath; + if (!primary) { + return true; + } + if (primary === "status" || primary === "health" || primary === "sessions") { + return false; + } + if (primary === "config" && (secondary === "get" || secondary === "unset")) { + return false; + } + if (primary === "models" && (secondary === "list" || secondary === "status")) { + return false; + } + return true; +} + +export function resolveCliStartupPolicy(params: { + commandPath: string[]; + jsonOutputMode: boolean; + env?: NodeJS.ProcessEnv; + routeMode?: boolean; +}) { + const suppressDoctorStdout = params.jsonOutputMode; + return { + suppressDoctorStdout, + hideBanner: shouldHideCliBannerForCommandPath(params.commandPath, params.env), + skipConfigGuard: params.routeMode + ? shouldSkipRouteConfigGuardForCommandPath({ + commandPath: params.commandPath, + suppressDoctorStdout, + }) + : false, + loadPlugins: shouldLoadPluginsForCommandPath({ + commandPath: params.commandPath, + jsonOutputMode: params.jsonOutputMode, + }), + }; +} diff --git a/src/cli/plugin-registry-loader.test.ts b/src/cli/plugin-registry-loader.test.ts new file mode 100644 index 00000000000..4a1728dd9f3 --- /dev/null +++ b/src/cli/plugin-registry-loader.test.ts @@ -0,0 +1,66 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const ensurePluginRegistryLoadedMock = vi.hoisted(() => vi.fn()); + +vi.mock("./plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: ensurePluginRegistryLoadedMock, +})); + +describe("plugin-registry-loader", () => { + let originalForceStderr: boolean; + let ensureCliPluginRegistryLoaded: typeof import("./plugin-registry-loader.js").ensureCliPluginRegistryLoaded; + let resolvePluginRegistryScopeForCommandPath: typeof import("./plugin-registry-loader.js").resolvePluginRegistryScopeForCommandPath; + let loggingState: typeof import("../logging/state.js").loggingState; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + ({ ensureCliPluginRegistryLoaded, resolvePluginRegistryScopeForCommandPath } = + await import("./plugin-registry-loader.js")); + ({ loggingState } = await import("../logging/state.js")); + originalForceStderr = loggingState.forceConsoleToStderr; + loggingState.forceConsoleToStderr = false; + }); + + afterEach(() => { + loggingState.forceConsoleToStderr = originalForceStderr; + }); + + it("routes plugin load logs to stderr and restores state", async () => { + const captured: boolean[] = []; + ensurePluginRegistryLoadedMock.mockImplementation(() => { + captured.push(loggingState.forceConsoleToStderr); + }); + + await ensureCliPluginRegistryLoaded({ + scope: "configured-channels", + routeLogsToStderr: true, + }); + + expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ + scope: "configured-channels", + }); + expect(captured).toEqual([true]); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + + it("keeps stdout routing unchanged when stderr routing is not requested", async () => { + const captured: boolean[] = []; + ensurePluginRegistryLoadedMock.mockImplementation(() => { + captured.push(loggingState.forceConsoleToStderr); + }); + + await ensureCliPluginRegistryLoaded({ + scope: "all", + }); + + expect(captured).toEqual([false]); + expect(loggingState.forceConsoleToStderr).toBe(false); + }); + + it("maps command paths to plugin registry scopes", () => { + expect(resolvePluginRegistryScopeForCommandPath(["status"])).toBe("channels"); + expect(resolvePluginRegistryScopeForCommandPath(["health"])).toBe("channels"); + expect(resolvePluginRegistryScopeForCommandPath(["agents"])).toBe("all"); + }); +}); diff --git a/src/cli/plugin-registry-loader.ts b/src/cli/plugin-registry-loader.ts new file mode 100644 index 00000000000..ff5461f60a0 --- /dev/null +++ b/src/cli/plugin-registry-loader.ts @@ -0,0 +1,31 @@ +import { loggingState } from "../logging/state.js"; +import type { PluginRegistryScope } from "./plugin-registry.js"; + +let pluginRegistryModulePromise: Promise | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("./plugin-registry.js"); + return pluginRegistryModulePromise; +} + +export function resolvePluginRegistryScopeForCommandPath( + commandPath: string[], +): Exclude { + return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; +} + +export async function ensureCliPluginRegistryLoaded(params: { + scope: PluginRegistryScope; + routeLogsToStderr?: boolean; +}) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + const previousForceStderr = loggingState.forceConsoleToStderr; + if (params.routeLogsToStderr) { + loggingState.forceConsoleToStderr = true; + } + try { + ensurePluginRegistryLoaded({ scope: params.scope }); + } finally { + loggingState.forceConsoleToStderr = previousForceStderr; + } +} diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 8a6c7781195..867c172aa76 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -1,13 +1,15 @@ import type { Command } from "commander"; import { setVerbose } from "../../globals.js"; -import { isTruthyEnvValue } from "../../infra/env.js"; -import { routeLogsToStderr } from "../../logging/console.js"; import type { LogLevel } from "../../logging/levels.js"; -import { loggingState } from "../../logging/state.js"; import { defaultRuntime } from "../../runtime.js"; -import { getCommandPathWithRootOptions, getVerboseFlag, hasHelpOrVersion } from "../argv.js"; -import { emitCliBanner } from "../banner.js"; +import { getVerboseFlag, hasHelpOrVersion } from "../argv.js"; import { resolveCliName } from "../cli-name.js"; +import { + applyCliExecutionStartupPresentation, + ensureCliExecutionBootstrap, + resolveCliExecutionStartupContext, +} from "../command-execution-startup.js"; +import { shouldBypassConfigGuardForCommandPath } from "../command-startup-policy.js"; import { resolvePluginInstallInvalidConfigPolicy, resolvePluginInstallPreactionRequest, @@ -27,63 +29,6 @@ function setProcessTitleForCommand(actionCommand: Command) { process.title = `${cliName}-${name}`; } -// Commands that need plugins loaded before execution. -const PLUGIN_REQUIRED_COMMANDS = new Set([ - "agent", - "message", - "channels", - "directory", - "agents", - "configure", - "status", - "health", -]); -const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]); -let configGuardModulePromise: Promise | undefined; -let pluginRegistryModulePromise: Promise | undefined; - -function shouldBypassConfigGuard(commandPath: string[]): boolean { - const [primary, secondary] = commandPath; - if (!primary) { - return false; - } - if (CONFIG_GUARD_BYPASS_COMMANDS.has(primary)) { - return true; - } - if (primary === "config" && (secondary === "validate" || secondary === "schema")) { - return true; - } - return false; -} - -function loadConfigGuardModule() { - configGuardModulePromise ??= import("./config-guard.js"); - return configGuardModulePromise; -} - -function loadPluginRegistryModule() { - pluginRegistryModulePromise ??= import("../plugin-registry.js"); - return pluginRegistryModulePromise; -} - -function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { - return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; -} - -function shouldLoadPluginsForCommand(commandPath: string[], jsonOutputMode: boolean): boolean { - const [primary, secondary] = commandPath; - if (!primary || !PLUGIN_REQUIRED_COMMANDS.has(primary)) { - return false; - } - if ((primary === "status" || primary === "health") && jsonOutputMode) { - return false; - } - // Setup wizard and channels add should stay manifest-first and load selected plugins on demand. - if (primary === "onboard" || (primary === "channels" && secondary === "add")) { - return false; - } - return true; -} function shouldAllowInvalidConfigForAction(actionCommand: Command, commandPath: string[]): boolean { return ( resolvePluginInstallInvalidConfigPolicy( @@ -123,19 +68,16 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (hasHelpOrVersion(argv)) { return; } - const commandPath = getCommandPathWithRootOptions(argv, 2); const jsonOutputMode = isCommandJsonOutputMode(actionCommand, argv); - if (jsonOutputMode) { - routeLogsToStderr(); - } - const hideBanner = - isTruthyEnvValue(process.env.OPENCLAW_HIDE_BANNER) || - commandPath[0] === "update" || - commandPath[0] === "completion" || - (commandPath[0] === "plugins" && commandPath[1] === "update"); - if (!hideBanner) { - emitCliBanner(programVersion); - } + const { commandPath, startupPolicy } = resolveCliExecutionStartupContext({ + argv, + jsonOutputMode, + env: process.env, + }); + await applyCliExecutionStartupPresentation({ + startupPolicy, + version: programVersion, + }); const verbose = getVerboseFlag(argv, { includeDebug: true }); setVerbose(verbose); const cliLogLevel = getCliLogLevel(actionCommand); @@ -145,31 +87,14 @@ export function registerPreActionHooks(program: Command, programVersion: string) if (!verbose) { process.env.NODE_NO_WARNINGS ??= "1"; } - if (shouldBypassConfigGuard(commandPath)) { + if (shouldBypassConfigGuardForCommandPath(commandPath)) { return; } - const allowInvalid = shouldAllowInvalidConfigForAction(actionCommand, commandPath); - const { ensureConfigReady } = await loadConfigGuardModule(); - await ensureConfigReady({ + await ensureCliExecutionBootstrap({ runtime: defaultRuntime, commandPath, - ...(allowInvalid ? { allowInvalid: true } : {}), - ...(jsonOutputMode ? { suppressDoctorStdout: true } : {}), + startupPolicy, + allowInvalid: shouldAllowInvalidConfigForAction(actionCommand, commandPath), }); - // Load plugins for commands that need channel access. - // When --json output is active, temporarily route logs to stderr so plugin - // registration messages don't corrupt the JSON payload on stdout. - if (shouldLoadPluginsForCommand(commandPath, jsonOutputMode)) { - const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - const prev = loggingState.forceConsoleToStderr; - if (jsonOutputMode) { - loggingState.forceConsoleToStderr = true; - } - try { - ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); - } finally { - loggingState.forceConsoleToStderr = prev; - } - } }); } diff --git a/src/cli/program/route-args.test.ts b/src/cli/program/route-args.test.ts new file mode 100644 index 00000000000..544c2ab0d3f --- /dev/null +++ b/src/cli/program/route-args.test.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; +import { + parseAgentsListRouteArgs, + parseConfigGetRouteArgs, + parseConfigUnsetRouteArgs, + parseGatewayStatusRouteArgs, + parseHealthRouteArgs, + parseModelsListRouteArgs, + parseModelsStatusRouteArgs, + parseSessionsRouteArgs, + parseStatusRouteArgs, +} from "./route-args.js"; + +describe("route-args", () => { + it("parses health and status route args", () => { + expect( + parseHealthRouteArgs(["node", "openclaw", "health", "--json", "--timeout", "5000"]), + ).toEqual({ + json: true, + verbose: false, + timeoutMs: 5000, + }); + expect( + parseStatusRouteArgs([ + "node", + "openclaw", + "status", + "--json", + "--deep", + "--all", + "--usage", + "--timeout", + "5000", + ]), + ).toEqual({ + json: true, + deep: true, + all: true, + usage: true, + verbose: false, + timeoutMs: 5000, + }); + expect(parseStatusRouteArgs(["node", "openclaw", "status", "--timeout"])).toBeNull(); + }); + + it("parses gateway status route args and rejects probe-only ssh flags", () => { + expect( + parseGatewayStatusRouteArgs([ + "node", + "openclaw", + "gateway", + "status", + "--url", + "ws://127.0.0.1:18789", + "--token", + "abc", + "--password", + "def", + "--timeout", + "5000", + "--deep", + "--require-rpc", + "--json", + ]), + ).toEqual({ + rpc: { + url: "ws://127.0.0.1:18789", + token: "abc", + password: "def", + timeout: "5000", + }, + probe: true, + requireRpc: true, + deep: true, + json: true, + }); + expect( + parseGatewayStatusRouteArgs(["node", "openclaw", "gateway", "status", "--ssh", "host"]), + ).toBeNull(); + expect( + parseGatewayStatusRouteArgs(["node", "openclaw", "gateway", "status", "--ssh-auto"]), + ).toBeNull(); + }); + + it("parses sessions and agents list route args", () => { + expect( + parseSessionsRouteArgs([ + "node", + "openclaw", + "sessions", + "--json", + "--all-agents", + "--agent", + "default", + "--store", + "sqlite", + "--active", + "true", + ]), + ).toEqual({ + json: true, + allAgents: true, + agent: "default", + store: "sqlite", + active: "true", + }); + expect(parseSessionsRouteArgs(["node", "openclaw", "sessions", "--agent"])).toBeNull(); + expect( + parseAgentsListRouteArgs(["node", "openclaw", "agents", "list", "--json", "--bindings"]), + ).toEqual({ + json: true, + bindings: true, + }); + }); + + it("parses config routes", () => { + expect( + parseConfigGetRouteArgs([ + "node", + "openclaw", + "--log-level", + "debug", + "config", + "get", + "update.channel", + "--json", + ]), + ).toEqual({ + path: "update.channel", + json: true, + }); + expect( + parseConfigUnsetRouteArgs([ + "node", + "openclaw", + "config", + "unset", + "--profile", + "work", + "update.channel", + ]), + ).toEqual({ + path: "update.channel", + }); + expect(parseConfigGetRouteArgs(["node", "openclaw", "config", "get", "--json"])).toBeNull(); + }); + + it("parses models list and models status route args", () => { + expect( + parseModelsListRouteArgs([ + "node", + "openclaw", + "models", + "list", + "--provider", + "openai", + "--all", + "--local", + "--json", + "--plain", + ]), + ).toEqual({ + provider: "openai", + all: true, + local: true, + json: true, + plain: true, + }); + expect( + parseModelsStatusRouteArgs([ + "node", + "openclaw", + "models", + "status", + "--probe-provider", + "openai", + "--probe-timeout", + "5000", + "--probe-concurrency", + "2", + "--probe-max-tokens", + "64", + "--probe-profile", + "fast", + "--probe-profile", + "safe", + "--agent", + "default", + "--json", + "--plain", + "--check", + "--probe", + ]), + ).toEqual({ + probeProvider: "openai", + probeTimeout: "5000", + probeConcurrency: "2", + probeMaxTokens: "64", + probeProfile: ["fast", "safe"], + agent: "default", + json: true, + plain: true, + check: true, + probe: true, + }); + expect( + parseModelsStatusRouteArgs(["node", "openclaw", "models", "status", "--probe-profile"]), + ).toBeNull(); + }); +}); diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts new file mode 100644 index 00000000000..6f914030213 --- /dev/null +++ b/src/cli/program/route-args.ts @@ -0,0 +1,244 @@ +import { isValueToken } from "../../infra/cli-root-options.js"; +import { + getCommandPositionalsWithRootOptions, + getFlagValue, + getPositiveIntFlagValue, + getVerboseFlag, + hasFlag, +} from "../argv.js"; + +type OptionalFlagParse = { + ok: boolean; + value?: string; +}; + +function parseOptionalFlagValue(argv: string[], name: string): OptionalFlagParse { + const value = getFlagValue(argv, name); + if (value === null) { + return { ok: false }; + } + return { ok: true, value }; +} + +function parseRepeatedFlagValues(argv: string[], name: string): string[] | null { + const values: string[] = []; + const args = argv.slice(2); + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (!arg || arg === "--") { + break; + } + if (arg === name) { + const next = args[i + 1]; + if (!isValueToken(next)) { + return null; + } + values.push(next); + i += 1; + continue; + } + if (arg.startsWith(`${name}=`)) { + const value = arg.slice(name.length + 1).trim(); + if (!value) { + return null; + } + values.push(value); + } + } + return values; +} + +function parseSinglePositional( + argv: string[], + params: { + commandPath: string[]; + booleanFlags?: string[]; + }, +): string | null { + const positionals = getCommandPositionalsWithRootOptions(argv, params); + if (!positionals || positionals.length !== 1) { + return null; + } + return positionals[0] ?? null; +} + +export function parseHealthRouteArgs(argv: string[]) { + const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); + if (timeoutMs === null) { + return null; + } + return { + json: hasFlag(argv, "--json"), + verbose: getVerboseFlag(argv, { includeDebug: true }), + timeoutMs, + }; +} + +export function parseStatusRouteArgs(argv: string[]) { + const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); + if (timeoutMs === null) { + return null; + } + return { + json: hasFlag(argv, "--json"), + deep: hasFlag(argv, "--deep"), + all: hasFlag(argv, "--all"), + usage: hasFlag(argv, "--usage"), + verbose: getVerboseFlag(argv, { includeDebug: true }), + timeoutMs, + }; +} + +export function parseGatewayStatusRouteArgs(argv: string[]) { + const url = parseOptionalFlagValue(argv, "--url"); + if (!url.ok) { + return null; + } + const token = parseOptionalFlagValue(argv, "--token"); + if (!token.ok) { + return null; + } + const password = parseOptionalFlagValue(argv, "--password"); + if (!password.ok) { + return null; + } + const timeout = parseOptionalFlagValue(argv, "--timeout"); + if (!timeout.ok) { + return null; + } + const ssh = parseOptionalFlagValue(argv, "--ssh"); + if (!ssh.ok || ssh.value !== undefined) { + return null; + } + const sshIdentity = parseOptionalFlagValue(argv, "--ssh-identity"); + if (!sshIdentity.ok || sshIdentity.value !== undefined) { + return null; + } + if (hasFlag(argv, "--ssh-auto")) { + return null; + } + return { + rpc: { + url: url.value, + token: token.value, + password: password.value, + timeout: timeout.value, + }, + deep: hasFlag(argv, "--deep"), + json: hasFlag(argv, "--json"), + requireRpc: hasFlag(argv, "--require-rpc"), + probe: !hasFlag(argv, "--no-probe"), + }; +} + +export function parseSessionsRouteArgs(argv: string[]) { + const agent = parseOptionalFlagValue(argv, "--agent"); + if (!agent.ok) { + return null; + } + const store = parseOptionalFlagValue(argv, "--store"); + if (!store.ok) { + return null; + } + const active = parseOptionalFlagValue(argv, "--active"); + if (!active.ok) { + return null; + } + return { + json: hasFlag(argv, "--json"), + allAgents: hasFlag(argv, "--all-agents"), + agent: agent.value, + store: store.value, + active: active.value, + }; +} + +export function parseAgentsListRouteArgs(argv: string[]) { + return { + json: hasFlag(argv, "--json"), + bindings: hasFlag(argv, "--bindings"), + }; +} + +export function parseConfigGetRouteArgs(argv: string[]) { + const path = parseSinglePositional(argv, { + commandPath: ["config", "get"], + booleanFlags: ["--json"], + }); + if (!path) { + return null; + } + return { + path, + json: hasFlag(argv, "--json"), + }; +} + +export function parseConfigUnsetRouteArgs(argv: string[]) { + const path = parseSinglePositional(argv, { + commandPath: ["config", "unset"], + }); + if (!path) { + return null; + } + return { path }; +} + +export function parseModelsListRouteArgs(argv: string[]) { + const provider = parseOptionalFlagValue(argv, "--provider"); + if (!provider.ok) { + return null; + } + return { + provider: provider.value, + all: hasFlag(argv, "--all"), + local: hasFlag(argv, "--local"), + json: hasFlag(argv, "--json"), + plain: hasFlag(argv, "--plain"), + }; +} + +export function parseModelsStatusRouteArgs(argv: string[]) { + const probeProvider = parseOptionalFlagValue(argv, "--probe-provider"); + if (!probeProvider.ok) { + return null; + } + const probeTimeout = parseOptionalFlagValue(argv, "--probe-timeout"); + if (!probeTimeout.ok) { + return null; + } + const probeConcurrency = parseOptionalFlagValue(argv, "--probe-concurrency"); + if (!probeConcurrency.ok) { + return null; + } + const probeMaxTokens = parseOptionalFlagValue(argv, "--probe-max-tokens"); + if (!probeMaxTokens.ok) { + return null; + } + const agent = parseOptionalFlagValue(argv, "--agent"); + if (!agent.ok) { + return null; + } + const probeProfileValues = parseRepeatedFlagValues(argv, "--probe-profile"); + if (probeProfileValues === null) { + return null; + } + const probeProfile = + probeProfileValues.length === 0 + ? undefined + : probeProfileValues.length === 1 + ? probeProfileValues[0] + : probeProfileValues; + return { + probeProvider: probeProvider.value, + probeTimeout: probeTimeout.value, + probeConcurrency: probeConcurrency.value, + probeMaxTokens: probeMaxTokens.value, + agent: agent.value, + probeProfile, + json: hasFlag(argv, "--json"), + plain: hasFlag(argv, "--plain"), + check: hasFlag(argv, "--check"), + probe: hasFlag(argv, "--probe"), + }; +} diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 5b341c5cabc..53dea4e26bf 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -1,12 +1,17 @@ -import { isValueToken } from "../../infra/cli-root-options.js"; import { defaultRuntime } from "../../runtime.js"; +import { hasFlag } from "../argv.js"; +import { shouldLoadPluginsForCommandPath } from "../command-startup-policy.js"; import { - getCommandPositionalsWithRootOptions, - getFlagValue, - getPositiveIntFlagValue, - getVerboseFlag, - hasFlag, -} from "../argv.js"; + parseAgentsListRouteArgs, + parseConfigGetRouteArgs, + parseConfigUnsetRouteArgs, + parseGatewayStatusRouteArgs, + parseHealthRouteArgs, + parseModelsListRouteArgs, + parseModelsStatusRouteArgs, + parseSessionsRouteArgs, + parseStatusRouteArgs, +} from "./route-args.js"; export type RouteSpec = { match: (path: string[]) => boolean; @@ -18,16 +23,18 @@ const routeHealth: RouteSpec = { match: (path) => path[0] === "health", // `health --json` only relays gateway RPC output and does not need local plugin metadata. // Keep plugin preload for text output where channel diagnostics/logSelfId are rendered. - loadPlugins: (argv) => !hasFlag(argv, "--json"), + loadPlugins: (argv) => + shouldLoadPluginsForCommandPath({ + commandPath: ["health"], + jsonOutputMode: hasFlag(argv, "--json"), + }), run: async (argv) => { - const json = hasFlag(argv, "--json"); - const verbose = getVerboseFlag(argv, { includeDebug: true }); - const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); - if (timeoutMs === null) { + const args = parseHealthRouteArgs(argv); + if (!args) { return false; } const { healthCommand } = await import("../../commands/health.js"); - await healthCommand({ json, timeoutMs, verbose }, defaultRuntime); + await healthCommand(args, defaultRuntime); return true; }, }; @@ -36,24 +43,31 @@ const routeStatus: RouteSpec = { match: (path) => path[0] === "status", // `status --json` can defer channel plugin loading until config/env inspection // proves it is needed, which keeps the fast-path startup lightweight. - loadPlugins: (argv) => !hasFlag(argv, "--json"), + loadPlugins: (argv) => + shouldLoadPluginsForCommandPath({ + commandPath: ["status"], + jsonOutputMode: hasFlag(argv, "--json"), + }), run: async (argv) => { - const json = hasFlag(argv, "--json"); - const deep = hasFlag(argv, "--deep"); - const all = hasFlag(argv, "--all"); - const usage = hasFlag(argv, "--usage"); - const verbose = getVerboseFlag(argv, { includeDebug: true }); - const timeoutMs = getPositiveIntFlagValue(argv, "--timeout"); - if (timeoutMs === null) { + const args = parseStatusRouteArgs(argv); + if (!args) { return false; } - if (json) { + if (args.json) { const { statusJsonCommand } = await import("../../commands/status-json.js"); - await statusJsonCommand({ deep, all, usage, timeoutMs }, defaultRuntime); + await statusJsonCommand( + { + deep: args.deep, + all: args.all, + usage: args.usage, + timeoutMs: args.timeoutMs, + }, + defaultRuntime, + ); return true; } const { statusCommand } = await import("../../commands/status.js"); - await statusCommand({ json, deep, all, usage, timeoutMs, verbose }, defaultRuntime); + await statusCommand(args, defaultRuntime); return true; }, }; @@ -61,56 +75,12 @@ const routeStatus: RouteSpec = { const routeGatewayStatus: RouteSpec = { match: (path) => path[0] === "gateway" && path[1] === "status", run: async (argv) => { - const url = getFlagValue(argv, "--url"); - if (url === null) { + const args = parseGatewayStatusRouteArgs(argv); + if (!args) { return false; } - const token = getFlagValue(argv, "--token"); - if (token === null) { - return false; - } - const password = getFlagValue(argv, "--password"); - if (password === null) { - return false; - } - const timeout = getFlagValue(argv, "--timeout"); - if (timeout === null) { - return false; - } - const ssh = getFlagValue(argv, "--ssh"); - if (ssh === null) { - return false; - } - if (ssh !== undefined) { - return false; - } - const sshIdentity = getFlagValue(argv, "--ssh-identity"); - if (sshIdentity === null) { - return false; - } - if (sshIdentity !== undefined) { - return false; - } - if (hasFlag(argv, "--ssh-auto")) { - return false; - } - const deep = hasFlag(argv, "--deep"); - const json = hasFlag(argv, "--json"); - const requireRpc = hasFlag(argv, "--require-rpc"); - const probe = !hasFlag(argv, "--no-probe"); const { runDaemonStatus } = await import("../daemon-cli/status.js"); - await runDaemonStatus({ - rpc: { - url: url ?? undefined, - token: token ?? undefined, - password: password ?? undefined, - timeout: timeout ?? undefined, - }, - probe, - requireRpc, - deep, - json, - }); + await runDaemonStatus(args); return true; }, }; @@ -120,22 +90,12 @@ const routeSessions: RouteSpec = { // must fall through to Commander so nested handlers run. match: (path) => path[0] === "sessions" && !path[1], run: async (argv) => { - const json = hasFlag(argv, "--json"); - const allAgents = hasFlag(argv, "--all-agents"); - const agent = getFlagValue(argv, "--agent"); - if (agent === null) { - return false; - } - const store = getFlagValue(argv, "--store"); - if (store === null) { - return false; - } - const active = getFlagValue(argv, "--active"); - if (active === null) { + const args = parseSessionsRouteArgs(argv); + if (!args) { return false; } const { sessionsCommand } = await import("../../commands/sessions.js"); - await sessionsCommand({ json, store, agent, allAgents, active }, defaultRuntime); + await sessionsCommand(args, defaultRuntime); return true; }, }; @@ -143,59 +103,21 @@ const routeSessions: RouteSpec = { const routeAgentsList: RouteSpec = { match: (path) => path[0] === "agents" && path[1] === "list", run: async (argv) => { - const json = hasFlag(argv, "--json"); - const bindings = hasFlag(argv, "--bindings"); const { agentsListCommand } = await import("../../commands/agents.js"); - await agentsListCommand({ json, bindings }, defaultRuntime); + await agentsListCommand(parseAgentsListRouteArgs(argv), defaultRuntime); return true; }, }; -function getFlagValues(argv: string[], name: string): string[] | null { - const values: string[] = []; - const args = argv.slice(2); - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg || arg === "--") { - break; - } - if (arg === name) { - const next = args[i + 1]; - if (!isValueToken(next)) { - return null; - } - values.push(next); - i += 1; - continue; - } - if (arg.startsWith(`${name}=`)) { - const value = arg.slice(name.length + 1).trim(); - if (!value) { - return null; - } - values.push(value); - } - } - return values; -} - const routeConfigGet: RouteSpec = { match: (path) => path[0] === "config" && path[1] === "get", run: async (argv) => { - const positionals = getCommandPositionalsWithRootOptions(argv, { - commandPath: ["config", "get"], - booleanFlags: ["--json"], - }); - if (!positionals || positionals.length !== 1) { + const args = parseConfigGetRouteArgs(argv); + if (!args) { return false; } - const pathArg = positionals[0]; - if (!pathArg) { - return false; - } - const json = hasFlag(argv, "--json"); const { runConfigGet } = await import("../config-cli.js"); - await runConfigGet({ path: pathArg, json }); + await runConfigGet(args); return true; }, }; @@ -203,18 +125,12 @@ const routeConfigGet: RouteSpec = { const routeConfigUnset: RouteSpec = { match: (path) => path[0] === "config" && path[1] === "unset", run: async (argv) => { - const positionals = getCommandPositionalsWithRootOptions(argv, { - commandPath: ["config", "unset"], - }); - if (!positionals || positionals.length !== 1) { - return false; - } - const pathArg = positionals[0]; - if (!pathArg) { + const args = parseConfigUnsetRouteArgs(argv); + if (!args) { return false; } const { runConfigUnset } = await import("../config-cli.js"); - await runConfigUnset({ path: pathArg }); + await runConfigUnset(args); return true; }, }; @@ -222,16 +138,12 @@ const routeConfigUnset: RouteSpec = { const routeModelsList: RouteSpec = { match: (path) => path[0] === "models" && path[1] === "list", run: async (argv) => { - const provider = getFlagValue(argv, "--provider"); - if (provider === null) { + const args = parseModelsListRouteArgs(argv); + if (!args) { return false; } - const all = hasFlag(argv, "--all"); - const local = hasFlag(argv, "--local"); - const json = hasFlag(argv, "--json"); - const plain = hasFlag(argv, "--plain"); const { modelsListCommand } = await import("../../commands/models.js"); - await modelsListCommand({ all, local, provider, json, plain }, defaultRuntime); + await modelsListCommand(args, defaultRuntime); return true; }, }; @@ -239,56 +151,12 @@ const routeModelsList: RouteSpec = { const routeModelsStatus: RouteSpec = { match: (path) => path[0] === "models" && path[1] === "status", run: async (argv) => { - const probeProvider = getFlagValue(argv, "--probe-provider"); - if (probeProvider === null) { + const args = parseModelsStatusRouteArgs(argv); + if (!args) { return false; } - const probeTimeout = getFlagValue(argv, "--probe-timeout"); - if (probeTimeout === null) { - return false; - } - const probeConcurrency = getFlagValue(argv, "--probe-concurrency"); - if (probeConcurrency === null) { - return false; - } - const probeMaxTokens = getFlagValue(argv, "--probe-max-tokens"); - if (probeMaxTokens === null) { - return false; - } - const agent = getFlagValue(argv, "--agent"); - if (agent === null) { - return false; - } - const probeProfileValues = getFlagValues(argv, "--probe-profile"); - if (probeProfileValues === null) { - return false; - } - const probeProfile = - probeProfileValues.length === 0 - ? undefined - : probeProfileValues.length === 1 - ? probeProfileValues[0] - : probeProfileValues; - const json = hasFlag(argv, "--json"); - const plain = hasFlag(argv, "--plain"); - const check = hasFlag(argv, "--check"); - const probe = hasFlag(argv, "--probe"); const { modelsStatusCommand } = await import("../../commands/models.js"); - await modelsStatusCommand( - { - json, - plain, - check, - probe, - probeProvider, - probeProfile, - probeTimeout, - probeConcurrency, - probeMaxTokens, - agent, - }, - defaultRuntime, - ); + await modelsStatusCommand(args, defaultRuntime); return true; }, }; diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index 9b88d1f13be..68cb11b144a 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -38,12 +38,15 @@ describe("tryRouteCli", () => { // Capture the same reference that route.js uses. let loggingState: typeof import("../logging/state.js").loggingState; let originalDisableRouteFirst: string | undefined; + let originalHideBanner: string | undefined; let originalForceStderr: boolean; beforeEach(async () => { vi.clearAllMocks(); originalDisableRouteFirst = process.env.OPENCLAW_DISABLE_ROUTE_FIRST; + originalHideBanner = process.env.OPENCLAW_HIDE_BANNER; delete process.env.OPENCLAW_DISABLE_ROUTE_FIRST; + delete process.env.OPENCLAW_HIDE_BANNER; vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); ({ loggingState } = await import("../logging/state.js")); @@ -64,6 +67,11 @@ describe("tryRouteCli", () => { } else { process.env.OPENCLAW_DISABLE_ROUTE_FIRST = originalDisableRouteFirst; } + if (originalHideBanner === undefined) { + delete process.env.OPENCLAW_HIDE_BANNER; + } else { + process.env.OPENCLAW_HIDE_BANNER = originalHideBanner; + } }); it("skips config guard for routed status --json commands", async () => { @@ -133,4 +141,12 @@ describe("tryRouteCli", () => { }); expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); }); + + it("respects OPENCLAW_HIDE_BANNER for routed commands", async () => { + process.env.OPENCLAW_HIDE_BANNER = "1"; + + await expect(tryRouteCli(["node", "openclaw", "status"])).resolves.toBe(true); + + expect(emitCliBannerMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/cli/route.ts b/src/cli/route.ts index ec87fcd46d1..8177f09f379 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -1,7 +1,11 @@ import { isTruthyEnvValue } from "../infra/env.js"; -import { loggingState } from "../logging/state.js"; import { defaultRuntime } from "../runtime.js"; import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js"; +import { + applyCliExecutionStartupPresentation, + ensureCliExecutionBootstrap, + resolveCliExecutionStartupContext, +} from "./command-execution-startup.js"; import { findRoutedCommand } from "./program/routes.js"; async function prepareRoutedCommand(params: { @@ -9,44 +13,28 @@ async function prepareRoutedCommand(params: { commandPath: string[]; loadPlugins?: boolean | ((argv: string[]) => boolean); }) { - const suppressDoctorStdout = hasFlag(params.argv, "--json"); - const skipConfigGuard = - (params.commandPath[0] === "status" && suppressDoctorStdout) || - (params.commandPath[0] === "gateway" && params.commandPath[1] === "status"); - if (!suppressDoctorStdout && process.stdout.isTTY) { - const [{ emitCliBanner }, { VERSION }] = await Promise.all([ - import("./banner.js"), - import("../version.js"), - ]); - emitCliBanner(VERSION, { argv: params.argv }); - } - if (!skipConfigGuard) { - const { ensureConfigReady } = await import("./program/config-guard.js"); - await ensureConfigReady({ - runtime: defaultRuntime, - commandPath: params.commandPath, - ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), - }); - } + const { startupPolicy } = resolveCliExecutionStartupContext({ + argv: params.argv, + jsonOutputMode: hasFlag(params.argv, "--json"), + env: process.env, + routeMode: true, + }); + const { VERSION } = await import("../version.js"); + await applyCliExecutionStartupPresentation({ + argv: params.argv, + routeLogsToStderrOnSuppress: false, + startupPolicy, + showBanner: process.stdout.isTTY && !startupPolicy.suppressDoctorStdout, + version: VERSION, + }); const shouldLoadPlugins = typeof params.loadPlugins === "function" ? params.loadPlugins(params.argv) : params.loadPlugins; - if (shouldLoadPlugins) { - const { ensurePluginRegistryLoaded } = await import("./plugin-registry.js"); - const prev = loggingState.forceConsoleToStderr; - if (suppressDoctorStdout) { - loggingState.forceConsoleToStderr = true; - } - try { - ensurePluginRegistryLoaded({ - scope: - params.commandPath[0] === "status" || params.commandPath[0] === "health" - ? "channels" - : "all", - }); - } finally { - loggingState.forceConsoleToStderr = prev; - } - } + await ensureCliExecutionBootstrap({ + runtime: defaultRuntime, + commandPath: params.commandPath, + startupPolicy, + loadPlugins: shouldLoadPlugins ?? startupPolicy.loadPlugins, + }); } export async function tryRouteCli(argv: string[]): Promise { diff --git a/src/commands/status.scan.fast-json.ts b/src/commands/status.scan.fast-json.ts index 383aef86cb6..ee98bddab68 100644 --- a/src/commands/status.scan.fast-json.ts +++ b/src/commands/status.scan.fast-json.ts @@ -1,4 +1,5 @@ import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; +import { ensureCliPluginRegistryLoaded } from "../cli/plugin-registry-loader.js"; import type { RuntimeEnv } from "../runtime.js"; import { executeStatusScanFromOverview } from "./status.scan-execute.ts"; import { @@ -7,12 +8,6 @@ import { } from "./status.scan-memory.ts"; import { collectStatusScanOverview } from "./status.scan-overview.ts"; import type { StatusScanResult } from "./status.scan-result.ts"; -let pluginRegistryModulePromise: Promise | undefined; - -function loadPluginRegistryModule() { - pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); - return pluginRegistryModulePromise; -} type StatusJsonScanPolicy = { commandName: string; @@ -41,15 +36,10 @@ export async function scanStatusJsonWithPolicy( includeChannelsData: false, }); if (overview.hasConfiguredChannels) { - const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); - const { loggingState } = await import("../logging/state.js"); - const previousForceStderr = loggingState.forceConsoleToStderr; - loggingState.forceConsoleToStderr = true; - try { - ensurePluginRegistryLoaded({ scope: "configured-channels" }); - } finally { - loggingState.forceConsoleToStderr = previousForceStderr; - } + await ensureCliPluginRegistryLoaded({ + scope: "configured-channels", + routeLogsToStderr: true, + }); } return await executeStatusScanFromOverview({