From f43aba40a2c502045fa61689c22e9f298277302c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 14:15:06 +0100 Subject: [PATCH] refactor: share cli routing metadata --- src/cli/argv-invocation.test.ts | 26 +++ src/cli/argv-invocation.ts | 24 +++ src/cli/command-catalog.ts | 129 +++++++++++++ src/cli/command-execution-startup.test.ts | 7 + src/cli/command-execution-startup.ts | 6 +- src/cli/command-path-matches.test.ts | 46 +++++ src/cli/command-path-matches.ts | 31 +++ src/cli/command-path-policy.test.ts | 55 ++++++ src/cli/command-path-policy.ts | 27 +++ src/cli/command-registration-policy.ts | 6 +- src/cli/command-startup-policy.ts | 57 +----- src/cli/container-target.ts | 4 +- src/cli/profile.ts | 4 +- src/cli/program/route-specs.ts | 56 ++++++ src/cli/program/routed-command-definitions.ts | 97 ++++++++++ src/cli/program/routes.ts | 177 +----------------- src/cli/respawn-policy.ts | 4 +- src/cli/route.ts | 18 +- src/cli/run-main.ts | 17 +- 19 files changed, 541 insertions(+), 250 deletions(-) create mode 100644 src/cli/argv-invocation.test.ts create mode 100644 src/cli/argv-invocation.ts create mode 100644 src/cli/command-catalog.ts create mode 100644 src/cli/command-path-matches.test.ts create mode 100644 src/cli/command-path-matches.ts create mode 100644 src/cli/command-path-policy.test.ts create mode 100644 src/cli/command-path-policy.ts create mode 100644 src/cli/program/route-specs.ts create mode 100644 src/cli/program/routed-command-definitions.ts diff --git a/src/cli/argv-invocation.test.ts b/src/cli/argv-invocation.test.ts new file mode 100644 index 00000000000..59ab058d880 --- /dev/null +++ b/src/cli/argv-invocation.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; + +describe("argv-invocation", () => { + it("resolves root help and empty command path", () => { + expect(resolveCliArgvInvocation(["node", "openclaw", "--help"])).toEqual({ + argv: ["node", "openclaw", "--help"], + commandPath: [], + primary: null, + hasHelpOrVersion: true, + isRootHelpInvocation: true, + }); + }); + + it("resolves command path and primary with root options", () => { + expect( + resolveCliArgvInvocation(["node", "openclaw", "--profile", "work", "gateway", "status"]), + ).toEqual({ + argv: ["node", "openclaw", "--profile", "work", "gateway", "status"], + commandPath: ["gateway", "status"], + primary: "gateway", + hasHelpOrVersion: false, + isRootHelpInvocation: false, + }); + }); +}); diff --git a/src/cli/argv-invocation.ts b/src/cli/argv-invocation.ts new file mode 100644 index 00000000000..ab05807fc58 --- /dev/null +++ b/src/cli/argv-invocation.ts @@ -0,0 +1,24 @@ +import { + getCommandPathWithRootOptions, + getPrimaryCommand, + hasHelpOrVersion, + isRootHelpInvocation, +} from "./argv.js"; + +export type CliArgvInvocation = { + argv: string[]; + commandPath: string[]; + primary: string | null; + hasHelpOrVersion: boolean; + isRootHelpInvocation: boolean; +}; + +export function resolveCliArgvInvocation(argv: string[]): CliArgvInvocation { + return { + argv, + commandPath: getCommandPathWithRootOptions(argv, 2), + primary: getPrimaryCommand(argv), + hasHelpOrVersion: hasHelpOrVersion(argv), + isRootHelpInvocation: isRootHelpInvocation(argv), + }; +} diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts new file mode 100644 index 00000000000..4244d1ec3e3 --- /dev/null +++ b/src/cli/command-catalog.ts @@ -0,0 +1,129 @@ +export type CliCommandPluginLoadPolicy = "never" | "always" | "text-only"; +export type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed"; +export type CliRoutedCommandId = + | "health" + | "status" + | "gateway-status" + | "sessions" + | "agents-list" + | "config-get" + | "config-unset" + | "models-list" + | "models-status"; + +export type CliCommandPathPolicy = { + bypassConfigGuard: boolean; + routeConfigGuard: CliRouteConfigGuardPolicy; + loadPlugins: CliCommandPluginLoadPolicy; + hideBanner: boolean; + ensureCliPath: boolean; +}; + +export type CliCommandCatalogEntry = { + commandPath: readonly string[]; + exact?: boolean; + policy?: Partial; + route?: { + id: CliRoutedCommandId; + preloadPlugins?: boolean; + }; +}; + +export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ + { commandPath: ["agent"], policy: { loadPlugins: "always" } }, + { commandPath: ["message"], policy: { loadPlugins: "always" } }, + { commandPath: ["channels"], policy: { loadPlugins: "always" } }, + { commandPath: ["directory"], policy: { loadPlugins: "always" } }, + { commandPath: ["agents"], policy: { loadPlugins: "always" } }, + { commandPath: ["configure"], policy: { loadPlugins: "always" } }, + { + commandPath: ["status"], + policy: { + loadPlugins: "text-only", + routeConfigGuard: "when-suppressed", + ensureCliPath: false, + }, + route: { id: "status", preloadPlugins: true }, + }, + { + commandPath: ["health"], + policy: { loadPlugins: "text-only", ensureCliPath: false }, + route: { id: "health", preloadPlugins: true }, + }, + { + commandPath: ["gateway", "status"], + exact: true, + policy: { routeConfigGuard: "always" }, + route: { id: "gateway-status" }, + }, + { + commandPath: ["sessions"], + exact: true, + policy: { ensureCliPath: false }, + route: { id: "sessions" }, + }, + { + commandPath: ["agents", "list"], + route: { id: "agents-list" }, + }, + { + commandPath: ["config", "get"], + exact: true, + policy: { ensureCliPath: false }, + route: { id: "config-get" }, + }, + { + commandPath: ["config", "unset"], + exact: true, + policy: { ensureCliPath: false }, + route: { id: "config-unset" }, + }, + { + commandPath: ["models", "list"], + exact: true, + policy: { ensureCliPath: false }, + route: { id: "models-list" }, + }, + { + commandPath: ["models", "status"], + exact: true, + policy: { ensureCliPath: false }, + route: { id: "models-status" }, + }, + { commandPath: ["backup"], policy: { bypassConfigGuard: true } }, + { commandPath: ["doctor"], policy: { bypassConfigGuard: true } }, + { + commandPath: ["completion"], + policy: { + bypassConfigGuard: true, + hideBanner: true, + }, + }, + { commandPath: ["secrets"], policy: { bypassConfigGuard: true } }, + { commandPath: ["update"], policy: { hideBanner: true } }, + { + commandPath: ["config", "validate"], + exact: true, + policy: { bypassConfigGuard: true }, + }, + { + commandPath: ["config", "schema"], + exact: true, + policy: { bypassConfigGuard: true }, + }, + { + commandPath: ["plugins", "update"], + exact: true, + policy: { hideBanner: true }, + }, + { + commandPath: ["onboard"], + exact: true, + policy: { loadPlugins: "never" }, + }, + { + commandPath: ["channels", "add"], + exact: true, + policy: { loadPlugins: "never" }, + }, +]; diff --git a/src/cli/command-execution-startup.test.ts b/src/cli/command-execution-startup.test.ts index f0d1dfb00ea..f50e98fcb34 100644 --- a/src/cli/command-execution-startup.test.ts +++ b/src/cli/command-execution-startup.test.ts @@ -33,6 +33,13 @@ describe("command-execution-startup", () => { routeMode: true, }), ).toEqual({ + invocation: { + argv: ["node", "openclaw", "status", "--json"], + commandPath: ["status"], + primary: "status", + hasHelpOrVersion: false, + isRootHelpInvocation: false, + }, commandPath: ["status"], startupPolicy: { suppressDoctorStdout: true, diff --git a/src/cli/command-execution-startup.ts b/src/cli/command-execution-startup.ts index caa500720d6..3498c9f46d9 100644 --- a/src/cli/command-execution-startup.ts +++ b/src/cli/command-execution-startup.ts @@ -1,6 +1,6 @@ import { routeLogsToStderr } from "../logging/console.js"; import type { RuntimeEnv } from "../runtime.js"; -import { getCommandPathWithRootOptions } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { ensureCliCommandBootstrap } from "./command-bootstrap.js"; import { resolveCliStartupPolicy } from "./command-startup-policy.js"; @@ -12,8 +12,10 @@ export function resolveCliExecutionStartupContext(params: { env?: NodeJS.ProcessEnv; routeMode?: boolean; }) { - const commandPath = getCommandPathWithRootOptions(params.argv, 2); + const invocation = resolveCliArgvInvocation(params.argv); + const { commandPath } = invocation; return { + invocation, commandPath, startupPolicy: resolveCliStartupPolicy({ commandPath, diff --git a/src/cli/command-path-matches.test.ts b/src/cli/command-path-matches.test.ts new file mode 100644 index 00000000000..084064ff7e8 --- /dev/null +++ b/src/cli/command-path-matches.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { + matchesAnyCommandPath, + matchesCommandPath, + matchesCommandPathRule, +} from "./command-path-matches.js"; + +describe("command-path-matches", () => { + it("matches prefix and exact command paths", () => { + expect(matchesCommandPath(["status"], ["status"])).toBe(true); + expect(matchesCommandPath(["status", "watch"], ["status"])).toBe(true); + expect(matchesCommandPath(["status", "watch"], ["status"], { exact: true })).toBe(false); + expect(matchesCommandPath(["config", "get"], ["config", "get"], { exact: true })).toBe(true); + }); + + it("matches declarative rules", () => { + expect(matchesCommandPathRule(["plugins", "update"], ["plugins"])).toBe(true); + expect( + matchesCommandPathRule(["plugins", "update"], { + pattern: ["plugins", "update"], + exact: true, + }), + ).toBe(true); + expect( + matchesCommandPathRule(["plugins", "update", "now"], { + pattern: ["plugins", "update"], + exact: true, + }), + ).toBe(false); + }); + + it("matches any command path from a rule set", () => { + expect( + matchesAnyCommandPath( + ["config", "schema"], + [["backup"], { pattern: ["config", "schema"], exact: true }], + ), + ).toBe(true); + expect( + matchesAnyCommandPath( + ["message", "send"], + [["status"], { pattern: ["config", "schema"], exact: true }], + ), + ).toBe(false); + }); +}); diff --git a/src/cli/command-path-matches.ts b/src/cli/command-path-matches.ts new file mode 100644 index 00000000000..d0b5e6ef391 --- /dev/null +++ b/src/cli/command-path-matches.ts @@ -0,0 +1,31 @@ +export type CommandPathMatchRule = + | readonly string[] + | { + pattern: readonly string[]; + exact?: boolean; + }; + +export function matchesCommandPath( + commandPath: string[], + pattern: readonly string[], + params?: { exact?: boolean }, +): boolean { + if (pattern.some((segment, index) => commandPath[index] !== segment)) { + return false; + } + return !params?.exact || commandPath.length === pattern.length; +} + +export function matchesCommandPathRule(commandPath: string[], rule: CommandPathMatchRule): boolean { + if (Array.isArray(rule)) { + return matchesCommandPath(commandPath, rule); + } + return matchesCommandPath(commandPath, rule.pattern, { exact: rule.exact }); +} + +export function matchesAnyCommandPath( + commandPath: string[], + rules: readonly CommandPathMatchRule[], +): boolean { + return rules.some((rule) => matchesCommandPathRule(commandPath, rule)); +} diff --git a/src/cli/command-path-policy.test.ts b/src/cli/command-path-policy.test.ts new file mode 100644 index 00000000000..38dd6cc9895 --- /dev/null +++ b/src/cli/command-path-policy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; + +describe("command-path-policy", () => { + it("resolves status policy with shared startup semantics", () => { + expect(resolveCliCommandPathPolicy(["status"])).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "when-suppressed", + loadPlugins: "text-only", + hideBanner: false, + ensureCliPath: false, + }); + }); + + it("applies exact overrides after broader channel plugin rules", () => { + expect(resolveCliCommandPathPolicy(["channels", "send"])).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "always", + hideBanner: false, + ensureCliPath: true, + }); + expect(resolveCliCommandPathPolicy(["channels", "add"])).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "never", + hideBanner: false, + ensureCliPath: true, + }); + }); + + it("resolves mixed startup-only rules", () => { + expect(resolveCliCommandPathPolicy(["config", "validate"])).toEqual({ + bypassConfigGuard: true, + routeConfigGuard: "never", + loadPlugins: "never", + hideBanner: false, + ensureCliPath: true, + }); + expect(resolveCliCommandPathPolicy(["gateway", "status"])).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "always", + loadPlugins: "never", + hideBanner: false, + ensureCliPath: true, + }); + expect(resolveCliCommandPathPolicy(["plugins", "update"])).toEqual({ + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "never", + hideBanner: true, + ensureCliPath: true, + }); + }); +}); diff --git a/src/cli/command-path-policy.ts b/src/cli/command-path-policy.ts new file mode 100644 index 00000000000..13b4b913d0f --- /dev/null +++ b/src/cli/command-path-policy.ts @@ -0,0 +1,27 @@ +import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js"; +import { matchesCommandPath } from "./command-path-matches.js"; + +const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = { + bypassConfigGuard: false, + routeConfigGuard: "never", + loadPlugins: "never", + hideBanner: false, + ensureCliPath: true, +}; + +export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPathPolicy { + let resolvedPolicy: CliCommandPathPolicy = { ...DEFAULT_CLI_COMMAND_PATH_POLICY }; + for (const entry of cliCommandCatalog) { + if (!entry.policy) { + continue; + } + if (!matchesCommandPath(commandPath, entry.commandPath, { exact: entry.exact })) { + continue; + } + resolvedPolicy = { + ...resolvedPolicy, + ...entry.policy, + }; + } + return resolvedPolicy; +} diff --git a/src/cli/command-registration-policy.ts b/src/cli/command-registration-policy.ts index ffa7ac71789..6c78e632f9e 100644 --- a/src/cli/command-registration-policy.ts +++ b/src/cli/command-registration-policy.ts @@ -1,8 +1,8 @@ import { isTruthyEnvValue } from "../infra/env.js"; -import { hasHelpOrVersion } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; export function shouldRegisterPrimaryCommandOnly(argv: string[]): boolean { - return !hasHelpOrVersion(argv); + return !resolveCliArgvInvocation(argv).hasHelpOrVersion; } export function shouldSkipPluginCommandRegistration(params: { @@ -14,7 +14,7 @@ export function shouldSkipPluginCommandRegistration(params: { return true; } if (!params.primary) { - return hasHelpOrVersion(params.argv); + return resolveCliArgvInvocation(params.argv).hasHelpOrVersion; } return false; } diff --git a/src/cli/command-startup-policy.ts b/src/cli/command-startup-policy.ts index 5457bb23ccc..4c12e7889f5 100644 --- a/src/cli/command-startup-policy.ts +++ b/src/cli/command-startup-policy.ts @@ -1,36 +1,18 @@ 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"]); +import { resolveCliCommandPathPolicy } from "./command-path-policy.js"; 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"); + return resolveCliCommandPathPolicy(commandPath).bypassConfigGuard; } export function shouldSkipRouteConfigGuardForCommandPath(params: { commandPath: string[]; suppressDoctorStdout: boolean; }): boolean { + const routeConfigGuard = resolveCliCommandPathPolicy(params.commandPath).routeConfigGuard; return ( - (params.commandPath[0] === "status" && params.suppressDoctorStdout) || - (params.commandPath[0] === "gateway" && params.commandPath[1] === "status") + routeConfigGuard === "always" || + (routeConfigGuard === "when-suppressed" && params.suppressDoctorStdout) ); } @@ -38,14 +20,8 @@ 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")); + const loadPlugins = resolveCliCommandPathPolicy(params.commandPath).loadPlugins; + return loadPlugins === "always" || (loadPlugins === "text-only" && !params.jsonOutputMode); } export function shouldHideCliBannerForCommandPath( @@ -54,27 +30,12 @@ export function shouldHideCliBannerForCommandPath( ): boolean { return ( isTruthyEnvValue(env.OPENCLAW_HIDE_BANNER) || - commandPath[0] === "update" || - commandPath[0] === "completion" || - (commandPath[0] === "plugins" && commandPath[1] === "update") + resolveCliCommandPathPolicy(commandPath).hideBanner ); } 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; + return commandPath.length === 0 || resolveCliCommandPathPolicy(commandPath).ensureCliPath; } export function resolveCliStartupPolicy(params: { diff --git a/src/cli/container-target.ts b/src/cli/container-target.ts index 6be827c498e..8fc6720e198 100644 --- a/src/cli/container-target.ts +++ b/src/cli/container-target.ts @@ -1,6 +1,6 @@ import { spawnSync } from "node:child_process"; import { consumeRootOptionToken, FLAG_TERMINATOR } from "../infra/cli-root-options.js"; -import { getPrimaryCommand } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { forwardConsumedCliRootOption } from "./root-option-forward.js"; import { takeCliRootOptionValue } from "./root-option-value.js"; @@ -183,7 +183,7 @@ function buildContainerExecEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { } function isBlockedContainerCommand(argv: string[]): boolean { - if (getPrimaryCommand(["node", "openclaw", ...argv]) === "update") { + if (resolveCliArgvInvocation(["node", "openclaw", ...argv]).primary === "update") { return true; } for (let i = 0; i < argv.length; i += 1) { diff --git a/src/cli/profile.ts b/src/cli/profile.ts index b848edae605..189eacf046d 100644 --- a/src/cli/profile.ts +++ b/src/cli/profile.ts @@ -2,7 +2,7 @@ import os from "node:os"; import path from "node:path"; import { FLAG_TERMINATOR } from "../infra/cli-root-options.js"; import { resolveRequiredHomeDir } from "../infra/home-dir.js"; -import { getPrimaryCommand } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { isValidProfileName } from "./profile-utils.js"; import { forwardConsumedCliRootOption } from "./root-option-forward.js"; import { takeCliRootOptionValue } from "./root-option-value.js"; @@ -32,7 +32,7 @@ export function parseCliProfileArgs(argv: string[]): CliProfileParseResult { } if (arg === "--dev") { - if (getPrimaryCommand(out) === "gateway") { + if (resolveCliArgvInvocation(out).primary === "gateway") { out.push(arg); continue; } diff --git a/src/cli/program/route-specs.ts b/src/cli/program/route-specs.ts new file mode 100644 index 00000000000..3fc1123048e --- /dev/null +++ b/src/cli/program/route-specs.ts @@ -0,0 +1,56 @@ +import { hasFlag } from "../argv.js"; +import { cliCommandCatalog, type CliCommandCatalogEntry } from "../command-catalog.js"; +import { matchesCommandPath } from "../command-path-matches.js"; +import { resolveCliCommandPathPolicy } from "../command-path-policy.js"; +import { + routedCommandDefinitions, + type RoutedCommandDefinition, +} from "./routed-command-definitions.js"; + +export type RouteSpec = { + match: (path: string[]) => boolean; + loadPlugins?: boolean | ((argv: string[]) => boolean); + run: (argv: string[]) => Promise; +}; + +function createCommandLoadPlugins(commandPath: readonly string[]): (argv: string[]) => boolean { + return (argv) => { + const loadPlugins = resolveCliCommandPathPolicy([...commandPath]).loadPlugins; + return loadPlugins === "always" || (loadPlugins === "text-only" && !hasFlag(argv, "--json")); + }; +} + +function createParsedRoute(params: { + entry: CliCommandCatalogEntry; + definition: RoutedCommandDefinition; +}): RouteSpec { + return { + match: (path) => + matchesCommandPath(path, params.entry.commandPath, { exact: params.entry.exact }), + loadPlugins: params.entry.route?.preloadPlugins + ? createCommandLoadPlugins(params.entry.commandPath) + : undefined, + run: async (argv) => { + const args = params.definition.parseArgs(argv); + if (!args) { + return false; + } + await params.definition.runParsedArgs(args); + return true; + }, + }; +} + +export const routedCommands: RouteSpec[] = cliCommandCatalog + .filter( + ( + entry, + ): entry is CliCommandCatalogEntry & { route: { id: keyof typeof routedCommandDefinitions } } => + Boolean(entry.route), + ) + .map((entry) => + createParsedRoute({ + entry, + definition: routedCommandDefinitions[entry.route.id], + }), + ); diff --git a/src/cli/program/routed-command-definitions.ts b/src/cli/program/routed-command-definitions.ts new file mode 100644 index 00000000000..6442d0f255b --- /dev/null +++ b/src/cli/program/routed-command-definitions.ts @@ -0,0 +1,97 @@ +import { defaultRuntime } from "../../runtime.js"; +import type { CliRoutedCommandId } from "../command-catalog.js"; +import { + parseAgentsListRouteArgs, + parseConfigGetRouteArgs, + parseConfigUnsetRouteArgs, + parseGatewayStatusRouteArgs, + parseHealthRouteArgs, + parseModelsListRouteArgs, + parseModelsStatusRouteArgs, + parseSessionsRouteArgs, + parseStatusRouteArgs, +} from "./route-args.js"; + +export type RoutedCommandDefinition = { + parseArgs: (argv: string[]) => TArgs | null; + runParsedArgs: (args: TArgs) => Promise; +}; + +export const routedCommandDefinitions: Record = { + health: { + parseArgs: parseHealthRouteArgs, + runParsedArgs: async (args) => { + const { healthCommand } = await import("../../commands/health.js"); + await healthCommand(args, defaultRuntime); + }, + }, + status: { + parseArgs: parseStatusRouteArgs, + runParsedArgs: async (args) => { + if (args.json) { + const { statusJsonCommand } = await import("../../commands/status-json.js"); + await statusJsonCommand( + { + deep: args.deep, + all: args.all, + usage: args.usage, + timeoutMs: args.timeoutMs, + }, + defaultRuntime, + ); + return; + } + const { statusCommand } = await import("../../commands/status.js"); + await statusCommand(args, defaultRuntime); + }, + }, + "gateway-status": { + parseArgs: parseGatewayStatusRouteArgs, + runParsedArgs: async (args) => { + const { runDaemonStatus } = await import("../daemon-cli/status.js"); + await runDaemonStatus(args); + }, + }, + sessions: { + parseArgs: parseSessionsRouteArgs, + runParsedArgs: async (args) => { + const { sessionsCommand } = await import("../../commands/sessions.js"); + await sessionsCommand(args, defaultRuntime); + }, + }, + "agents-list": { + parseArgs: parseAgentsListRouteArgs, + runParsedArgs: async (args) => { + const { agentsListCommand } = await import("../../commands/agents.js"); + await agentsListCommand(args, defaultRuntime); + }, + }, + "config-get": { + parseArgs: parseConfigGetRouteArgs, + runParsedArgs: async (args) => { + const { runConfigGet } = await import("../config-cli.js"); + await runConfigGet(args); + }, + }, + "config-unset": { + parseArgs: parseConfigUnsetRouteArgs, + runParsedArgs: async (args) => { + const { runConfigUnset } = await import("../config-cli.js"); + await runConfigUnset(args); + }, + }, + "models-list": { + parseArgs: parseModelsListRouteArgs, + runParsedArgs: async (args) => { + const { modelsListCommand } = await import("../../commands/models.js"); + await modelsListCommand(args, defaultRuntime); + }, + }, + "models-status": { + parseArgs: parseModelsStatusRouteArgs, + runParsedArgs: async (args) => { + const { modelsStatusCommand } = await import("../../commands/models.js"); + await modelsStatusCommand(args, defaultRuntime); + }, + }, +}; diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index 53dea4e26bf..5239df898f6 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -1,180 +1,9 @@ -import { defaultRuntime } from "../../runtime.js"; -import { hasFlag } from "../argv.js"; -import { shouldLoadPluginsForCommandPath } from "../command-startup-policy.js"; -import { - parseAgentsListRouteArgs, - parseConfigGetRouteArgs, - parseConfigUnsetRouteArgs, - parseGatewayStatusRouteArgs, - parseHealthRouteArgs, - parseModelsListRouteArgs, - parseModelsStatusRouteArgs, - parseSessionsRouteArgs, - parseStatusRouteArgs, -} from "./route-args.js"; +import { routedCommands, type RouteSpec } from "./route-specs.js"; -export type RouteSpec = { - match: (path: string[]) => boolean; - loadPlugins?: boolean | ((argv: string[]) => boolean); - run: (argv: string[]) => Promise; -}; - -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) => - shouldLoadPluginsForCommandPath({ - commandPath: ["health"], - jsonOutputMode: hasFlag(argv, "--json"), - }), - run: async (argv) => { - const args = parseHealthRouteArgs(argv); - if (!args) { - return false; - } - const { healthCommand } = await import("../../commands/health.js"); - await healthCommand(args, defaultRuntime); - return true; - }, -}; - -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) => - shouldLoadPluginsForCommandPath({ - commandPath: ["status"], - jsonOutputMode: hasFlag(argv, "--json"), - }), - run: async (argv) => { - const args = parseStatusRouteArgs(argv); - if (!args) { - return false; - } - if (args.json) { - const { statusJsonCommand } = await import("../../commands/status-json.js"); - 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(args, defaultRuntime); - return true; - }, -}; - -const routeGatewayStatus: RouteSpec = { - match: (path) => path[0] === "gateway" && path[1] === "status", - run: async (argv) => { - const args = parseGatewayStatusRouteArgs(argv); - if (!args) { - return false; - } - const { runDaemonStatus } = await import("../daemon-cli/status.js"); - await runDaemonStatus(args); - return true; - }, -}; - -const routeSessions: RouteSpec = { - // Fast-path only bare `sessions`; subcommands (e.g. `sessions cleanup`) - // must fall through to Commander so nested handlers run. - match: (path) => path[0] === "sessions" && !path[1], - run: async (argv) => { - const args = parseSessionsRouteArgs(argv); - if (!args) { - return false; - } - const { sessionsCommand } = await import("../../commands/sessions.js"); - await sessionsCommand(args, defaultRuntime); - return true; - }, -}; - -const routeAgentsList: RouteSpec = { - match: (path) => path[0] === "agents" && path[1] === "list", - run: async (argv) => { - const { agentsListCommand } = await import("../../commands/agents.js"); - await agentsListCommand(parseAgentsListRouteArgs(argv), defaultRuntime); - return true; - }, -}; - -const routeConfigGet: RouteSpec = { - match: (path) => path[0] === "config" && path[1] === "get", - run: async (argv) => { - const args = parseConfigGetRouteArgs(argv); - if (!args) { - return false; - } - const { runConfigGet } = await import("../config-cli.js"); - await runConfigGet(args); - return true; - }, -}; - -const routeConfigUnset: RouteSpec = { - match: (path) => path[0] === "config" && path[1] === "unset", - run: async (argv) => { - const args = parseConfigUnsetRouteArgs(argv); - if (!args) { - return false; - } - const { runConfigUnset } = await import("../config-cli.js"); - await runConfigUnset(args); - return true; - }, -}; - -const routeModelsList: RouteSpec = { - match: (path) => path[0] === "models" && path[1] === "list", - run: async (argv) => { - const args = parseModelsListRouteArgs(argv); - if (!args) { - return false; - } - const { modelsListCommand } = await import("../../commands/models.js"); - await modelsListCommand(args, defaultRuntime); - return true; - }, -}; - -const routeModelsStatus: RouteSpec = { - match: (path) => path[0] === "models" && path[1] === "status", - run: async (argv) => { - const args = parseModelsStatusRouteArgs(argv); - if (!args) { - return false; - } - const { modelsStatusCommand } = await import("../../commands/models.js"); - await modelsStatusCommand(args, defaultRuntime); - return true; - }, -}; - -const routes: RouteSpec[] = [ - routeHealth, - routeStatus, - routeGatewayStatus, - routeSessions, - routeAgentsList, - routeConfigGet, - routeConfigUnset, - routeModelsList, - routeModelsStatus, -]; +export type { RouteSpec } from "./route-specs.js"; export function findRoutedCommand(path: string[]): RouteSpec | null { - for (const route of routes) { + for (const route of routedCommands) { if (route.match(path)) { return route; } diff --git a/src/cli/respawn-policy.ts b/src/cli/respawn-policy.ts index d0fe1aa22a9..761c4d47eea 100644 --- a/src/cli/respawn-policy.ts +++ b/src/cli/respawn-policy.ts @@ -1,5 +1,5 @@ -import { hasHelpOrVersion } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; export function shouldSkipRespawnForArgv(argv: string[]): boolean { - return hasHelpOrVersion(argv); + return resolveCliArgvInvocation(argv).hasHelpOrVersion; } diff --git a/src/cli/route.ts b/src/cli/route.ts index 8177f09f379..12fabf0ca33 100644 --- a/src/cli/route.ts +++ b/src/cli/route.ts @@ -1,6 +1,7 @@ import { isTruthyEnvValue } from "../infra/env.js"; import { defaultRuntime } from "../runtime.js"; -import { getCommandPathWithRootOptions, hasFlag, hasHelpOrVersion } from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; +import { hasFlag } from "./argv.js"; import { applyCliExecutionStartupPresentation, ensureCliExecutionBootstrap, @@ -41,18 +42,21 @@ export async function tryRouteCli(argv: string[]): Promise { if (isTruthyEnvValue(process.env.OPENCLAW_DISABLE_ROUTE_FIRST)) { return false; } - if (hasHelpOrVersion(argv)) { + const invocation = resolveCliArgvInvocation(argv); + if (invocation.hasHelpOrVersion) { return false; } - - const path = getCommandPathWithRootOptions(argv, 2); - if (!path[0]) { + if (!invocation.commandPath[0]) { return false; } - const route = findRoutedCommand(path); + const route = findRoutedCommand(invocation.commandPath); if (!route) { return false; } - await prepareRoutedCommand({ argv, commandPath: path, loadPlugins: route.loadPlugins }); + await prepareRoutedCommand({ + argv, + commandPath: invocation.commandPath, + loadPlugins: route.loadPlugins, + }); return route.run(argv); } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 7359ea4379b..1c3b1d417e4 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -12,12 +12,7 @@ import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { enableConsoleCapture } from "../logging.js"; import { hasMemoryRuntime } from "../plugins/memory-state.js"; -import { - getCommandPathWithRootOptions, - getPrimaryCommand, - hasHelpOrVersion, - isRootHelpInvocation, -} from "./argv.js"; +import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { shouldRegisterPrimaryCommandOnly, shouldSkipPluginCommandRegistration, @@ -52,14 +47,15 @@ export function rewriteUpdateFlagArgv(argv: string[]): string[] { } export function shouldEnsureCliPath(argv: string[]): boolean { - if (hasHelpOrVersion(argv)) { + const invocation = resolveCliArgvInvocation(argv); + if (invocation.hasHelpOrVersion) { return false; } - return shouldEnsureCliPathForCommandPath(getCommandPathWithRootOptions(argv, 2)); + return shouldEnsureCliPathForCommandPath(invocation.commandPath); } export function shouldUseRootHelpFastPath(argv: string[]): boolean { - return isRootHelpInvocation(argv); + return resolveCliArgvInvocation(argv).isRootHelpInvocation; } export function resolveMissingPluginCommandMessage( @@ -171,9 +167,10 @@ export async function runCli(argv: string[] = process.argv) { }); const parseArgv = rewriteUpdateFlagArgv(normalizedArgv); + const invocation = resolveCliArgvInvocation(parseArgv); // Register the primary command (builtin or subcli) so help and command parsing // are correct even with lazy command registration. - const primary = getPrimaryCommand(parseArgv); + const { primary } = invocation; if (primary && shouldRegisterPrimaryCommandOnly(parseArgv)) { const { getProgramContext } = await import("./program/program-context.js"); const ctx = getProgramContext(program);