Files
openclaw/src/cli/program/root-command-descriptions.test.ts
T
2026-08-10 07:08:40 -07:00

432 lines
14 KiB
TypeScript

// Root help renders catalog placeholders while command help and completion use
// registered Commander commands. Keep those user-facing descriptions aligned.
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cliCommandCatalog } from "../command-catalog.js";
import { isReservedNonPluginCommandRoot } from "../command-registration-policy.js";
import { collectShellCompletionCommandTree } from "../completion-command-tree.js";
import { getCoreCliCommandNames, registerCoreCliByName } from "./command-registry-core.js";
import { createProgramContext } from "./context.js";
import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js";
import { registerSubCliByName, registerSubCliCommands } from "./register.subclis.js";
import { getSubCliEntries } from "./subcli-descriptors.js";
const RESERVED_CATALOG_ROOTS = {
tool: "reserved so plugin registration cannot claim this unregistered root",
tools: "reserved so plugin registration cannot claim this unregistered root",
} as const;
const PLUGIN_CATALOG_PATHS = {
memory: "registered and covered by the memory-core plugin",
"memory search": "registered and covered by the memory-core plugin",
"memory status": "registered and covered by the memory-core plugin",
} as const;
const JSON_NOT_APPLICABLE = {
namespaces: {
reason: "command group only; reporting subcommands declare JSON output individually",
commands: [
"backup",
"backup sqlite",
"database",
"database ownership",
"message",
"message thread",
"message emoji",
"message sticker",
"message role",
"message channel",
"message member",
"message voice",
"message event",
"mcp",
"transcripts",
"gateway restart-handoff",
"gateway diagnostics",
"daemon",
"system",
"system heartbeat",
"promos",
"infer",
"infer model",
"infer model auth",
"infer image",
"infer audio",
"infer tts",
"infer video",
"infer web",
"infer embedding",
"approvals",
"approvals allowlist",
"exec-policy",
"nodes",
"nodes camera",
"nodes screen",
"nodes location",
"devices",
"users",
"node",
"sandbox",
"fleet",
"worktrees",
"cron",
"dns",
"proxy",
"webhooks",
"webhooks gmail",
"clawbot",
"pairing",
"plugins",
"plugins marketplace",
"channels",
"channels dead-letters",
"directory",
"directory peers",
"directory groups",
"security",
"secrets",
"secrets store",
"models aliases",
"models fallbacks",
"models image-fallbacks",
"models auth",
"models auth order",
"tasks flow",
"skills workshop",
],
},
interactive: {
reason: "interactive wizard, login, or terminal session has no single report document",
commands: [
"configure",
"config",
"acp client",
"gateway auth-token",
"promos claim",
"infer model auth login",
"models auth add",
"models auth login",
"models auth setup-token",
"models auth paste-token",
"models auth paste-api-key",
"models auth login-github-copilot",
"mcp configure",
"mcp login",
"attach",
"tui",
"resume",
"update wizard",
],
},
longRunning: {
reason: "long-running server, worker, or live stream does not terminate with one JSON document",
commands: [
"acp",
"gateway",
"gateway run",
"mcp serve",
"node worker",
"node run",
"worker",
"fleet logs",
"proxy start",
"proxy run",
"webhooks gmail run",
"hooks relay",
"sessions tail",
],
},
mutations: {
reason: "pure side-effect command has no meaningful report payload",
commands: [
"reset",
"uninstall",
"config set",
"mcp add",
"mcp set",
"mcp tools",
"mcp logout",
"mcp reload",
"mcp unset",
"onboard recommendations acknowledge",
"onboard recommendations refresh",
"tasks notify",
"tasks cancel",
"tasks retry",
"tasks dismiss",
"tasks flow cancel",
"models set",
"models set-image",
"models aliases add",
"models aliases remove",
"models fallbacks add",
"models fallbacks remove",
"models fallbacks clear",
"models image-fallbacks add",
"models image-fallbacks remove",
"models image-fallbacks clear",
"models auth logout",
"models auth order set",
"models auth order clear",
"hooks enable",
"hooks disable",
"hooks install",
"hooks update",
"skills install",
"skills update",
"sandbox recreate",
"fleet start",
"fleet stop",
"fleet restart",
"fleet upgrade",
"fleet rm",
"cron enable",
"cron disable",
"cron run",
"cron edit",
"dns setup",
"proxy purge",
"pairing approve",
"plugins enable",
"plugins disable",
"plugins uninstall",
"plugins install",
"plugins update",
"plugins build",
"plugins init",
"channels add",
"channels remove",
"channels login",
"channels logout",
"secrets store set",
"secrets store rm",
"secrets store import",
],
},
rawArtifacts: {
reason: "command streams a raw artifact whose bytes are already the machine contract",
commands: ["proxy blob"],
},
shellIntegration: {
reason: "shell integration output must remain executable shell source",
commands: ["completion"],
},
} as const;
// These subcommands intentionally consume --json from their parent and emit JSON.
const JSON_OUTPUT_INHERITED_FROM_PARENT = new Set([
"skills curator status",
"skills curator pin",
"skills curator unpin",
"skills curator restore",
]);
// Route-first parsing accepts JSON before Commander registration is reached.
const JSON_OUTPUT_ROUTE_FIRST = new Set(["agents"]);
async function registerAllBuiltInCommands(): Promise<Command> {
const program = new Command().name("openclaw");
const ctx = createProgramContext();
const argv = ["node", "openclaw", "completion"];
for (const name of getCoreCliCommandNames()) {
await registerCoreCliByName(program, ctx, name, argv);
}
for (const entry of getSubCliEntries()) {
await registerSubCliByName(program, entry.name, argv, { purpose: "completion" });
}
return program;
}
function hasOwnJsonOption(command: Command): boolean {
return command.options.some((option) => option.long === "--json");
}
function hasAncestorJsonOption(command: Command): boolean {
for (let parent = command.parent; parent; parent = parent.parent) {
if (hasOwnJsonOption(parent)) {
return true;
}
}
return false;
}
function supportsJsonOutput(path: string, command: Command): boolean {
// `config set --json` is a legacy strict-input parser alias. Only its
// `--dry-run --json` combination reports JSON, so the mutation stays N/A.
if (path === "config set") {
return false;
}
return (
hasOwnJsonOption(command) ||
(JSON_OUTPUT_INHERITED_FROM_PARENT.has(path) && hasAncestorJsonOption(command)) ||
JSON_OUTPUT_ROUTE_FIRST.has(path)
);
}
function collectRegisteredCommandPaths(...programs: Command[]): Set<string> {
return new Set(
programs.flatMap((program) =>
collectShellCompletionCommandTree(program).descendants.flatMap((context) =>
context.pathVariants.map((path) => path.join(" ")),
),
),
);
}
describe("root command descriptions", () => {
beforeEach(() => {
vi.stubEnv("OPENCLAW_ENABLE_PRIVATE_QA_CLI", "");
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("keeps catalog placeholders and registered commands in sync", async () => {
const program = await registerAllBuiltInCommands();
const registeredCommands = new Map<string, { command: Command; registeredAsAlias: boolean }>();
for (const command of program.commands) {
registeredCommands.set(command.name(), { command, registeredAsAlias: false });
for (const alias of command.aliases()) {
registeredCommands.set(alias, { command, registeredAsAlias: true });
}
}
const descriptors = [...getCoreCliCommandDescriptors(), ...getSubCliEntries()];
const missing: string[] = [];
const mismatches: string[] = [];
for (const descriptor of descriptors) {
const registered = registeredCommands.get(descriptor.name);
if (!registered) {
missing.push(descriptor.name);
continue;
}
if (
!registered.registeredAsAlias &&
registered.command.description() !== descriptor.description
) {
mismatches.push(
`${descriptor.name}\n catalog: ${descriptor.description}\n registered: ${registered.command.description()}`,
);
}
}
expect(missing, "catalog entries with no registered command or alias").toEqual([]);
expect(mismatches, "root help vs registered command description drift").toEqual([]);
});
it("keeps startup policy catalog paths registered or explicitly reserved", async () => {
vi.stubEnv("OPENCLAW_EXPERIMENTAL_CLAWS", "1");
const program = await registerAllBuiltInCommands();
// Private QA is a lazy source-checkout command. Its root placeholder proves
// registration without importing the private build omitted from normal dist.
vi.stubEnv("OPENCLAW_ENABLE_PRIVATE_QA_CLI", "1");
const lazyProgram = new Command().name("openclaw");
registerSubCliCommands(lazyProgram, ["node", "openclaw", "--help"]);
const registeredPaths = collectRegisteredCommandPaths(program, lazyProgram);
const catalogPaths = new Set(cliCommandCatalog.map((entry) => entry.commandPath.join(" ")));
const reservedPaths = new Set(Object.keys(RESERVED_CATALOG_ROOTS));
const pluginPaths = new Set(Object.keys(PLUGIN_CATALOG_PATHS));
expect(
Object.entries({ ...RESERVED_CATALOG_ROOTS, ...PLUGIN_CATALOG_PATHS }).filter(
([, reason]) => reason.trim().length === 0,
),
"every catalog exception must document why core has no command",
).toEqual([]);
const missing = [...catalogPaths].filter(
(path) => !registeredPaths.has(path) && !reservedPaths.has(path) && !pluginPaths.has(path),
);
expect(missing, "catalog entries with no registered command or reserved-root decision").toEqual(
[],
);
const staleReservedRoots = [...reservedPaths].filter(
(path) => !catalogPaths.has(path) || !isReservedNonPluginCommandRoot(path),
);
expect(
staleReservedRoots,
"reserved catalog roots must remain cataloged and blocked from plugin registration",
).toEqual([]);
const stalePluginPaths = [...pluginPaths].filter(
(path) => !catalogPaths.has(path) || registeredPaths.has(path),
);
expect(stalePluginPaths, "plugin catalog paths must remain plugin-owned").toEqual([]);
const reservedRootsThatNowRegister = [...reservedPaths].filter((path) =>
registeredPaths.has(path),
);
expect(
reservedRootsThatNowRegister,
"registered commands must leave the reserved-root exception list",
).toEqual([]);
});
it("classifies every built-in command as JSON output or explicitly not applicable", async () => {
const program = await registerAllBuiltInCommands();
const contexts = collectShellCompletionCommandTree(program).descendants;
const registered = new Map(
contexts.map((context) => [context.pathVariants[0]?.join(" ") ?? "", context.command]),
);
const notApplicableEntries = Object.values(JSON_NOT_APPLICABLE).flatMap((category) =>
category.commands.map((command) => ({ command, reason: category.reason })),
);
const notApplicable = new Map<string, string>(
notApplicableEntries.map(({ command, reason }) => [command, reason]),
);
expect(notApplicable.size, "a command must not appear in more than one JSON N/A category").toBe(
notApplicableEntries.length,
);
expect(
notApplicableEntries.filter(({ reason }) => reason.trim().length === 0),
"every JSON N/A category must document why JSON is meaningless",
).toEqual([]);
const unclassified = [...registered]
.filter(([path, command]) => !supportsJsonOutput(path, command) && !notApplicable.has(path))
.map(([path]) => path);
expect(unclassified, "commands missing a deliberate JSON output decision").toEqual([]);
const staleExceptions = [...notApplicable]
.filter(([path]) => !registered.has(path))
.map(([path]) => path);
expect(staleExceptions, "JSON N/A entries for commands that no longer exist").toEqual([]);
const exceptionsThatNowSupportJson = [...notApplicable]
.filter(([path]) => {
const command = registered.get(path);
return command ? supportsJsonOutput(path, command) : false;
})
.map(([path]) => path);
expect(
exceptionsThatNowSupportJson,
"remove stale JSON N/A entries after adding output support",
).toEqual([]);
const staleInheritedSupport = [...JSON_OUTPUT_INHERITED_FROM_PARENT].filter((path) => {
const command = registered.get(path);
return !command || hasOwnJsonOption(command) || !hasAncestorJsonOption(command);
});
expect(
staleInheritedSupport,
"inherited JSON entries must exist, lack their own flag, and inherit a parent flag",
).toEqual([]);
const staleRouteFirstSupport = [...JSON_OUTPUT_ROUTE_FIRST].filter((path) => {
const command = registered.get(path);
return !command || hasOwnJsonOption(command);
});
expect(
staleRouteFirstSupport,
"route-first JSON entries must exist and remain absent from Commander options",
).toEqual([]);
});
});