mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-14 10:41:23 +00:00
refactor: share cli startup and routing helpers
This commit is contained in:
76
src/cli/command-bootstrap.test.ts
Normal file
76
src/cli/command-bootstrap.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
38
src/cli/command-bootstrap.ts
Normal file
38
src/cli/command-bootstrap.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import {
|
||||
ensureCliPluginRegistryLoaded,
|
||||
resolvePluginRegistryScopeForCommandPath,
|
||||
} from "./plugin-registry-loader.js";
|
||||
|
||||
let configGuardModulePromise: Promise<typeof import("./program/config-guard.js")> | 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,
|
||||
});
|
||||
}
|
||||
122
src/cli/command-execution-startup.test.ts
Normal file
122
src/cli/command-execution-startup.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
64
src/cli/command-execution-startup.ts
Normal file
64
src/cli/command-execution-startup.ts
Normal file
@@ -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<typeof resolveCliStartupPolicy>;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
114
src/cli/command-startup-policy.test.ts
Normal file
114
src/cli/command-startup-policy.test.ts
Normal file
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/cli/command-startup-policy.ts
Normal file
101
src/cli/command-startup-policy.ts
Normal file
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
66
src/cli/plugin-registry-loader.test.ts
Normal file
66
src/cli/plugin-registry-loader.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
31
src/cli/plugin-registry-loader.ts
Normal file
31
src/cli/plugin-registry-loader.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { loggingState } from "../logging/state.js";
|
||||
import type { PluginRegistryScope } from "./plugin-registry.js";
|
||||
|
||||
let pluginRegistryModulePromise: Promise<typeof import("./plugin-registry.js")> | undefined;
|
||||
|
||||
function loadPluginRegistryModule() {
|
||||
pluginRegistryModulePromise ??= import("./plugin-registry.js");
|
||||
return pluginRegistryModulePromise;
|
||||
}
|
||||
|
||||
export function resolvePluginRegistryScopeForCommandPath(
|
||||
commandPath: string[],
|
||||
): Exclude<PluginRegistryScope, "configured-channels"> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<typeof import("./config-guard.js")> | undefined;
|
||||
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
210
src/cli/program/route-args.test.ts
Normal file
210
src/cli/program/route-args.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
244
src/cli/program/route-args.ts
Normal file
244
src/cli/program/route-args.ts
Normal file
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<boolean> {
|
||||
|
||||
@@ -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<typeof import("../cli/plugin-registry.js")> | 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({
|
||||
|
||||
Reference in New Issue
Block a user