refactor: share cli routing metadata

This commit is contained in:
Peter Steinberger
2026-04-06 14:15:06 +01:00
parent f3dd9723e1
commit f43aba40a2
19 changed files with 541 additions and 250 deletions

View File

@@ -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,
});
});
});

View File

@@ -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),
};
}

129
src/cli/command-catalog.ts Normal file
View File

@@ -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<CliCommandPathPolicy>;
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" },
},
];

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -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,
});
});
});

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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: {

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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<boolean>;
};
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<TArgs>(params: {
entry: CliCommandCatalogEntry;
definition: RoutedCommandDefinition<TArgs>;
}): 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],
}),
);

View File

@@ -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<TArgs = unknown> = {
parseArgs: (argv: string[]) => TArgs | null;
runParsedArgs: (args: TArgs) => Promise<void>;
};
export const routedCommandDefinitions: Record<CliRoutedCommandId, RoutedCommandDefinition> = {
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);
},
},
};

View File

@@ -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<boolean>;
};
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;
}

View File

@@ -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;
}

View File

@@ -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<boolean> {
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);
}

View File

@@ -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);