Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f43aba4

Browse files
committed
refactor: share cli routing metadata
1 parent f3dd972 commit f43aba4

19 files changed

Lines changed: 541 additions & 250 deletions
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCliArgvInvocation } from "./argv-invocation.js";
3+
4+
describe("argv-invocation", () => {
5+
it("resolves root help and empty command path", () => {
6+
expect(resolveCliArgvInvocation(["node", "openclaw", "--help"])).toEqual({
7+
argv: ["node", "openclaw", "--help"],
8+
commandPath: [],
9+
primary: null,
10+
hasHelpOrVersion: true,
11+
isRootHelpInvocation: true,
12+
});
13+
});
14+
15+
it("resolves command path and primary with root options", () => {
16+
expect(
17+
resolveCliArgvInvocation(["node", "openclaw", "--profile", "work", "gateway", "status"]),
18+
).toEqual({
19+
argv: ["node", "openclaw", "--profile", "work", "gateway", "status"],
20+
commandPath: ["gateway", "status"],
21+
primary: "gateway",
22+
hasHelpOrVersion: false,
23+
isRootHelpInvocation: false,
24+
});
25+
});
26+
});

‎src/cli/argv-invocation.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
getCommandPathWithRootOptions,
3+
getPrimaryCommand,
4+
hasHelpOrVersion,
5+
isRootHelpInvocation,
6+
} from "./argv.js";
7+
8+
export type CliArgvInvocation = {
9+
argv: string[];
10+
commandPath: string[];
11+
primary: string | null;
12+
hasHelpOrVersion: boolean;
13+
isRootHelpInvocation: boolean;
14+
};
15+
16+
export function resolveCliArgvInvocation(argv: string[]): CliArgvInvocation {
17+
return {
18+
argv,
19+
commandPath: getCommandPathWithRootOptions(argv, 2),
20+
primary: getPrimaryCommand(argv),
21+
hasHelpOrVersion: hasHelpOrVersion(argv),
22+
isRootHelpInvocation: isRootHelpInvocation(argv),
23+
};
24+
}

‎src/cli/command-catalog.ts‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
export type CliCommandPluginLoadPolicy = "never" | "always" | "text-only";
2+
export type CliRouteConfigGuardPolicy = "never" | "always" | "when-suppressed";
3+
export type CliRoutedCommandId =
4+
| "health"
5+
| "status"
6+
| "gateway-status"
7+
| "sessions"
8+
| "agents-list"
9+
| "config-get"
10+
| "config-unset"
11+
| "models-list"
12+
| "models-status";
13+
14+
export type CliCommandPathPolicy = {
15+
bypassConfigGuard: boolean;
16+
routeConfigGuard: CliRouteConfigGuardPolicy;
17+
loadPlugins: CliCommandPluginLoadPolicy;
18+
hideBanner: boolean;
19+
ensureCliPath: boolean;
20+
};
21+
22+
export type CliCommandCatalogEntry = {
23+
commandPath: readonly string[];
24+
exact?: boolean;
25+
policy?: Partial<CliCommandPathPolicy>;
26+
route?: {
27+
id: CliRoutedCommandId;
28+
preloadPlugins?: boolean;
29+
};
30+
};
31+
32+
export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
33+
{ commandPath: ["agent"], policy: { loadPlugins: "always" } },
34+
{ commandPath: ["message"], policy: { loadPlugins: "always" } },
35+
{ commandPath: ["channels"], policy: { loadPlugins: "always" } },
36+
{ commandPath: ["directory"], policy: { loadPlugins: "always" } },
37+
{ commandPath: ["agents"], policy: { loadPlugins: "always" } },
38+
{ commandPath: ["configure"], policy: { loadPlugins: "always" } },
39+
{
40+
commandPath: ["status"],
41+
policy: {
42+
loadPlugins: "text-only",
43+
routeConfigGuard: "when-suppressed",
44+
ensureCliPath: false,
45+
},
46+
route: { id: "status", preloadPlugins: true },
47+
},
48+
{
49+
commandPath: ["health"],
50+
policy: { loadPlugins: "text-only", ensureCliPath: false },
51+
route: { id: "health", preloadPlugins: true },
52+
},
53+
{
54+
commandPath: ["gateway", "status"],
55+
exact: true,
56+
policy: { routeConfigGuard: "always" },
57+
route: { id: "gateway-status" },
58+
},
59+
{
60+
commandPath: ["sessions"],
61+
exact: true,
62+
policy: { ensureCliPath: false },
63+
route: { id: "sessions" },
64+
},
65+
{
66+
commandPath: ["agents", "list"],
67+
route: { id: "agents-list" },
68+
},
69+
{
70+
commandPath: ["config", "get"],
71+
exact: true,
72+
policy: { ensureCliPath: false },
73+
route: { id: "config-get" },
74+
},
75+
{
76+
commandPath: ["config", "unset"],
77+
exact: true,
78+
policy: { ensureCliPath: false },
79+
route: { id: "config-unset" },
80+
},
81+
{
82+
commandPath: ["models", "list"],
83+
exact: true,
84+
policy: { ensureCliPath: false },
85+
route: { id: "models-list" },
86+
},
87+
{
88+
commandPath: ["models", "status"],
89+
exact: true,
90+
policy: { ensureCliPath: false },
91+
route: { id: "models-status" },
92+
},
93+
{ commandPath: ["backup"], policy: { bypassConfigGuard: true } },
94+
{ commandPath: ["doctor"], policy: { bypassConfigGuard: true } },
95+
{
96+
commandPath: ["completion"],
97+
policy: {
98+
bypassConfigGuard: true,
99+
hideBanner: true,
100+
},
101+
},
102+
{ commandPath: ["secrets"], policy: { bypassConfigGuard: true } },
103+
{ commandPath: ["update"], policy: { hideBanner: true } },
104+
{
105+
commandPath: ["config", "validate"],
106+
exact: true,
107+
policy: { bypassConfigGuard: true },
108+
},
109+
{
110+
commandPath: ["config", "schema"],
111+
exact: true,
112+
policy: { bypassConfigGuard: true },
113+
},
114+
{
115+
commandPath: ["plugins", "update"],
116+
exact: true,
117+
policy: { hideBanner: true },
118+
},
119+
{
120+
commandPath: ["onboard"],
121+
exact: true,
122+
policy: { loadPlugins: "never" },
123+
},
124+
{
125+
commandPath: ["channels", "add"],
126+
exact: true,
127+
policy: { loadPlugins: "never" },
128+
},
129+
];

‎src/cli/command-execution-startup.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ describe("command-execution-startup", () => {
3333
routeMode: true,
3434
}),
3535
).toEqual({
36+
invocation: {
37+
argv: ["node", "openclaw", "status", "--json"],
38+
commandPath: ["status"],
39+
primary: "status",
40+
hasHelpOrVersion: false,
41+
isRootHelpInvocation: false,
42+
},
3643
commandPath: ["status"],
3744
startupPolicy: {
3845
suppressDoctorStdout: true,

‎src/cli/command-execution-startup.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { routeLogsToStderr } from "../logging/console.js";
22
import type { RuntimeEnv } from "../runtime.js";
3-
import { getCommandPathWithRootOptions } from "./argv.js";
3+
import { resolveCliArgvInvocation } from "./argv-invocation.js";
44
import { ensureCliCommandBootstrap } from "./command-bootstrap.js";
55
import { resolveCliStartupPolicy } from "./command-startup-policy.js";
66

@@ -12,8 +12,10 @@ export function resolveCliExecutionStartupContext(params: {
1212
env?: NodeJS.ProcessEnv;
1313
routeMode?: boolean;
1414
}) {
15-
const commandPath = getCommandPathWithRootOptions(params.argv, 2);
15+
const invocation = resolveCliArgvInvocation(params.argv);
16+
const { commandPath } = invocation;
1617
return {
18+
invocation,
1719
commandPath,
1820
startupPolicy: resolveCliStartupPolicy({
1921
commandPath,
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
matchesAnyCommandPath,
4+
matchesCommandPath,
5+
matchesCommandPathRule,
6+
} from "./command-path-matches.js";
7+
8+
describe("command-path-matches", () => {
9+
it("matches prefix and exact command paths", () => {
10+
expect(matchesCommandPath(["status"], ["status"])).toBe(true);
11+
expect(matchesCommandPath(["status", "watch"], ["status"])).toBe(true);
12+
expect(matchesCommandPath(["status", "watch"], ["status"], { exact: true })).toBe(false);
13+
expect(matchesCommandPath(["config", "get"], ["config", "get"], { exact: true })).toBe(true);
14+
});
15+
16+
it("matches declarative rules", () => {
17+
expect(matchesCommandPathRule(["plugins", "update"], ["plugins"])).toBe(true);
18+
expect(
19+
matchesCommandPathRule(["plugins", "update"], {
20+
pattern: ["plugins", "update"],
21+
exact: true,
22+
}),
23+
).toBe(true);
24+
expect(
25+
matchesCommandPathRule(["plugins", "update", "now"], {
26+
pattern: ["plugins", "update"],
27+
exact: true,
28+
}),
29+
).toBe(false);
30+
});
31+
32+
it("matches any command path from a rule set", () => {
33+
expect(
34+
matchesAnyCommandPath(
35+
["config", "schema"],
36+
[["backup"], { pattern: ["config", "schema"], exact: true }],
37+
),
38+
).toBe(true);
39+
expect(
40+
matchesAnyCommandPath(
41+
["message", "send"],
42+
[["status"], { pattern: ["config", "schema"], exact: true }],
43+
),
44+
).toBe(false);
45+
});
46+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export type CommandPathMatchRule =
2+
| readonly string[]
3+
| {
4+
pattern: readonly string[];
5+
exact?: boolean;
6+
};
7+
8+
export function matchesCommandPath(
9+
commandPath: string[],
10+
pattern: readonly string[],
11+
params?: { exact?: boolean },
12+
): boolean {
13+
if (pattern.some((segment, index) => commandPath[index] !== segment)) {
14+
return false;
15+
}
16+
return !params?.exact || commandPath.length === pattern.length;
17+
}
18+
19+
export function matchesCommandPathRule(commandPath: string[], rule: CommandPathMatchRule): boolean {
20+
if (Array.isArray(rule)) {
21+
return matchesCommandPath(commandPath, rule);
22+
}
23+
return matchesCommandPath(commandPath, rule.pattern, { exact: rule.exact });
24+
}
25+
26+
export function matchesAnyCommandPath(
27+
commandPath: string[],
28+
rules: readonly CommandPathMatchRule[],
29+
): boolean {
30+
return rules.some((rule) => matchesCommandPathRule(commandPath, rule));
31+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveCliCommandPathPolicy } from "./command-path-policy.js";
3+
4+
describe("command-path-policy", () => {
5+
it("resolves status policy with shared startup semantics", () => {
6+
expect(resolveCliCommandPathPolicy(["status"])).toEqual({
7+
bypassConfigGuard: false,
8+
routeConfigGuard: "when-suppressed",
9+
loadPlugins: "text-only",
10+
hideBanner: false,
11+
ensureCliPath: false,
12+
});
13+
});
14+
15+
it("applies exact overrides after broader channel plugin rules", () => {
16+
expect(resolveCliCommandPathPolicy(["channels", "send"])).toEqual({
17+
bypassConfigGuard: false,
18+
routeConfigGuard: "never",
19+
loadPlugins: "always",
20+
hideBanner: false,
21+
ensureCliPath: true,
22+
});
23+
expect(resolveCliCommandPathPolicy(["channels", "add"])).toEqual({
24+
bypassConfigGuard: false,
25+
routeConfigGuard: "never",
26+
loadPlugins: "never",
27+
hideBanner: false,
28+
ensureCliPath: true,
29+
});
30+
});
31+
32+
it("resolves mixed startup-only rules", () => {
33+
expect(resolveCliCommandPathPolicy(["config", "validate"])).toEqual({
34+
bypassConfigGuard: true,
35+
routeConfigGuard: "never",
36+
loadPlugins: "never",
37+
hideBanner: false,
38+
ensureCliPath: true,
39+
});
40+
expect(resolveCliCommandPathPolicy(["gateway", "status"])).toEqual({
41+
bypassConfigGuard: false,
42+
routeConfigGuard: "always",
43+
loadPlugins: "never",
44+
hideBanner: false,
45+
ensureCliPath: true,
46+
});
47+
expect(resolveCliCommandPathPolicy(["plugins", "update"])).toEqual({
48+
bypassConfigGuard: false,
49+
routeConfigGuard: "never",
50+
loadPlugins: "never",
51+
hideBanner: true,
52+
ensureCliPath: true,
53+
});
54+
});
55+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { cliCommandCatalog, type CliCommandPathPolicy } from "./command-catalog.js";
2+
import { matchesCommandPath } from "./command-path-matches.js";
3+
4+
const DEFAULT_CLI_COMMAND_PATH_POLICY: CliCommandPathPolicy = {
5+
bypassConfigGuard: false,
6+
routeConfigGuard: "never",
7+
loadPlugins: "never",
8+
hideBanner: false,
9+
ensureCliPath: true,
10+
};
11+
12+
export function resolveCliCommandPathPolicy(commandPath: string[]): CliCommandPathPolicy {
13+
let resolvedPolicy: CliCommandPathPolicy = { ...DEFAULT_CLI_COMMAND_PATH_POLICY };
14+
for (const entry of cliCommandCatalog) {
15+
if (!entry.policy) {
16+
continue;
17+
}
18+
if (!matchesCommandPath(commandPath, entry.commandPath, { exact: entry.exact })) {
19+
continue;
20+
}
21+
resolvedPolicy = {
22+
...resolvedPolicy,
23+
...entry.policy,
24+
};
25+
}
26+
return resolvedPolicy;
27+
}

‎src/cli/command-registration-policy.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { isTruthyEnvValue } from "../infra/env.js";
2-
import { hasHelpOrVersion } from "./argv.js";
2+
import { resolveCliArgvInvocation } from "./argv-invocation.js";
33

44
export function shouldRegisterPrimaryCommandOnly(argv: string[]): boolean {
5-
return !hasHelpOrVersion(argv);
5+
return !resolveCliArgvInvocation(argv).hasHelpOrVersion;
66
}
77

88
export function shouldSkipPluginCommandRegistration(params: {
@@ -14,7 +14,7 @@ export function shouldSkipPluginCommandRegistration(params: {
1414
return true;
1515
}
1616
if (!params.primary) {
17-
return hasHelpOrVersion(params.argv);
17+
return resolveCliArgvInvocation(params.argv).hasHelpOrVersion;
1818
}
1919
return false;
2020
}

0 commit comments

Comments
 (0)