docs(cli): explain reserved catalog roots and drop stale entries (#118040)

* docs(cli): explain reserved catalog roots and drop stale entries

* test(cli): use plugin registration for catalog guard

* test(cli): load memory CLI through public facade

* test(cli): keep plugin catalog paths owner-scoped
This commit is contained in:
Peter Steinberger
2026-08-02 10:15:25 -07:00
committed by GitHub
parent 6e87d3b5f3
commit b5a7bc280e
2 changed files with 77 additions and 7 deletions
+4 -6
View File
@@ -150,11 +150,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
exact: true,
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["config", "models"],
exact: true,
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
},
{
commandPath: ["migrate"],
policy: { configGuard: "skip", loadPlugins: "never", networkProxy: "bypass" },
@@ -201,7 +196,6 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
{ commandPath: ["gateway", "call"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "diagnostics"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "discover"], exact: true, policy: { networkProxy: "bypass" } },
{ commandPath: ["gateway", "export"], exact: true, policy: { networkProxy: "bypass" } },
{
commandPath: ["gateway", "health"],
exact: true,
@@ -331,10 +325,14 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [
route: { id: "tasks-list" },
},
{
// This unregistered root is reserved so plugin registration cannot claim it;
// the catalog entry preserves its startup policy.
commandPath: ["tool"],
policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" },
},
{
// This unregistered root is reserved so plugin registration cannot claim it;
// the catalog entry preserves its startup policy.
commandPath: ["tools"],
policy: { loadPlugins: "never", ensureCliPath: false, networkProxy: "bypass" },
},
@@ -2,13 +2,25 @@
// 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 } from "./register.subclis.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 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",
@@ -242,6 +254,16 @@ function supportsJsonOutput(path: string, command: Command): boolean {
);
}
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", "");
@@ -285,6 +307,56 @@ describe("root command descriptions", () => {
expect(mismatches, "root help vs registered command description drift").toEqual([]);
});
it("keeps startup policy catalog paths registered or explicitly reserved", async () => {
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;