From b5a7bc280e4610e595fa59a61aa7e45fa1f7250a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 10:15:25 -0700 Subject: [PATCH] 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 --- src/cli/command-catalog.ts | 10 +-- .../program/root-command-descriptions.test.ts | 74 ++++++++++++++++++- 2 files changed, 77 insertions(+), 7 deletions(-) diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 10cd7c82b5c6..a9d3e3da7593 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -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" }, }, diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 4a2b94ce672b..10a5a2d373f1 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -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 { + 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;