From 41905d9fd7013909dcd13146e92e747184451c46 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 14:51:57 +0100 Subject: [PATCH] refactor: share cli command descriptor helpers --- .../program/command-descriptor-utils.test.ts | 74 +++++ src/cli/program/command-descriptor-utils.ts | 68 +++++ .../program/command-group-descriptors.test.ts | 134 ++++++++ src/cli/program/command-group-descriptors.ts | 117 +++++++ src/cli/program/command-registry.test.ts | 2 + src/cli/program/command-registry.ts | 288 ++++++------------ src/cli/program/core-command-descriptors.ts | 30 +- src/cli/program/register-command-groups.ts | 25 +- src/cli/program/root-help.ts | 37 +-- src/cli/program/subcli-descriptors.ts | 19 +- 10 files changed, 555 insertions(+), 239 deletions(-) create mode 100644 src/cli/program/command-descriptor-utils.test.ts create mode 100644 src/cli/program/command-descriptor-utils.ts create mode 100644 src/cli/program/command-group-descriptors.test.ts create mode 100644 src/cli/program/command-group-descriptors.ts diff --git a/src/cli/program/command-descriptor-utils.test.ts b/src/cli/program/command-descriptor-utils.test.ts new file mode 100644 index 00000000000..4eab242c07c --- /dev/null +++ b/src/cli/program/command-descriptor-utils.test.ts @@ -0,0 +1,74 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; +import { + addCommandDescriptorsToProgram, + collectUniqueCommandDescriptors, + defineCommandDescriptorCatalog, + getCommandDescriptorNames, + getCommandsWithSubcommands, +} from "./command-descriptor-utils.js"; + +describe("command-descriptor-utils", () => { + const descriptors = [ + { name: "alpha", description: "Alpha", hasSubcommands: false }, + { name: "beta", description: "Beta", hasSubcommands: true }, + { name: "gamma", description: "Gamma", hasSubcommands: true }, + ] as const; + + it("returns descriptor names in order", () => { + expect(getCommandDescriptorNames(descriptors)).toEqual(["alpha", "beta", "gamma"]); + }); + + it("returns commands with subcommands", () => { + expect(getCommandsWithSubcommands(descriptors)).toEqual(["beta", "gamma"]); + }); + + it("collects unique descriptors across groups in order", () => { + expect( + collectUniqueCommandDescriptors([ + [ + { name: "alpha", description: "Alpha" }, + { name: "beta", description: "Beta" }, + ], + [ + { name: "beta", description: "Ignored duplicate" }, + { name: "gamma", description: "Gamma" }, + ], + ]), + ).toEqual([ + { name: "alpha", description: "Alpha" }, + { name: "beta", description: "Beta" }, + { name: "gamma", description: "Gamma" }, + ]); + }); + + it("defines a reusable descriptor catalog", () => { + const catalog = defineCommandDescriptorCatalog(descriptors); + + expect(catalog.descriptors).toBe(descriptors); + expect(catalog.getDescriptors()).toBe(descriptors); + expect(catalog.getNames()).toEqual(["alpha", "beta", "gamma"]); + expect(catalog.getCommandsWithSubcommands()).toEqual(["beta", "gamma"]); + }); + + it("adds descriptors without duplicating existing commands", () => { + const program = new Command(); + const existingCommands = addCommandDescriptorsToProgram(program, descriptors); + + addCommandDescriptorsToProgram( + program, + [ + { name: "beta", description: "Ignored duplicate" }, + { name: "delta", description: "Delta" }, + ], + existingCommands, + ); + + expect(program.commands.map((command) => command.name())).toEqual([ + "alpha", + "beta", + "gamma", + "delta", + ]); + }); +}); diff --git a/src/cli/program/command-descriptor-utils.ts b/src/cli/program/command-descriptor-utils.ts new file mode 100644 index 00000000000..98559c0f93d --- /dev/null +++ b/src/cli/program/command-descriptor-utils.ts @@ -0,0 +1,68 @@ +import type { Command } from "commander"; +import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; + +export type CommandDescriptorLike = Pick; + +export type CommandDescriptorCatalog = { + descriptors: readonly TDescriptor[]; + getDescriptors: () => readonly TDescriptor[]; + getNames: () => string[]; + getCommandsWithSubcommands: () => string[]; +}; + +export function getCommandDescriptorNames( + descriptors: readonly TDescriptor[], +): string[] { + return descriptors.map((descriptor) => descriptor.name); +} + +export function getCommandsWithSubcommands( + descriptors: readonly TDescriptor[], +): string[] { + return descriptors + .filter((descriptor) => descriptor.hasSubcommands) + .map((descriptor) => descriptor.name); +} + +export function collectUniqueCommandDescriptors( + descriptorGroups: readonly (readonly TDescriptor[])[], +): TDescriptor[] { + const seen = new Set(); + const descriptors: TDescriptor[] = []; + for (const group of descriptorGroups) { + for (const descriptor of group) { + if (seen.has(descriptor.name)) { + continue; + } + seen.add(descriptor.name); + descriptors.push(descriptor); + } + } + return descriptors; +} + +export function defineCommandDescriptorCatalog( + descriptors: readonly TDescriptor[], +): CommandDescriptorCatalog { + return { + descriptors, + getDescriptors: () => descriptors, + getNames: () => getCommandDescriptorNames(descriptors), + getCommandsWithSubcommands: () => getCommandsWithSubcommands(descriptors), + }; +} + +export function addCommandDescriptorsToProgram( + program: Command, + descriptors: readonly TDescriptor[], + existingCommands: Set = new Set(), +): Set { + for (const descriptor of descriptors) { + if (existingCommands.has(descriptor.name)) { + continue; + } + program.command(descriptor.name).description(descriptor.description); + existingCommands.add(descriptor.name); + } + return existingCommands; +} diff --git a/src/cli/program/command-group-descriptors.test.ts b/src/cli/program/command-group-descriptors.test.ts new file mode 100644 index 00000000000..b95c3c6e640 --- /dev/null +++ b/src/cli/program/command-group-descriptors.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it, vi } from "vitest"; +import { + buildCommandGroupEntries, + defineImportedCommandGroupSpec, + defineImportedCommandGroupSpecs, + defineImportedProgramCommandGroupSpec, + defineImportedProgramCommandGroupSpecs, + resolveCommandGroupEntries, +} from "./command-group-descriptors.js"; + +const descriptors = [ + { + name: "alpha", + description: "Alpha command", + hasSubcommands: false, + }, + { + name: "beta", + description: "Beta command", + hasSubcommands: true, + }, +] as const; + +describe("command-group-descriptors", () => { + it("resolves placeholders by descriptor name", () => { + const register = vi.fn(); + expect( + resolveCommandGroupEntries(descriptors, [{ commandNames: ["alpha"], register }]), + ).toEqual([ + { + placeholders: [descriptors[0]], + register, + }, + ]); + }); + + it("builds command-group entries with a register mapper", () => { + const register = vi.fn(); + const mappedRegister = vi.fn(); + const entries = buildCommandGroupEntries( + descriptors, + [{ commandNames: ["beta"], register }], + () => mappedRegister, + ); + + expect(entries).toEqual([ + { + placeholders: [descriptors[1]], + register: mappedRegister, + }, + ]); + expect(register).not.toHaveBeenCalled(); + }); + + it("builds imported specs that lazy-load and register once", async () => { + const module = { register: vi.fn() }; + const loadModule = vi.fn(async () => module); + const spec = defineImportedCommandGroupSpec(["alpha"], loadModule, (loaded, args: string) => { + loaded.register(args); + }); + + await spec.register("ok"); + + expect(loadModule).toHaveBeenCalledTimes(1); + expect(module.register).toHaveBeenCalledWith("ok"); + }); + + it("builds imported specs from definition arrays", async () => { + const alpha = { registerAlpha: vi.fn() }; + const beta = { registerBeta: vi.fn() }; + const specs = defineImportedCommandGroupSpecs([ + { + commandNames: ["alpha"], + loadModule: async () => alpha, + register: (loaded, value) => { + if ("registerAlpha" in loaded) { + loaded.registerAlpha(value); + } + }, + }, + { + commandNames: ["beta"], + loadModule: async () => beta, + register: (loaded, value) => { + if ("registerBeta" in loaded) { + loaded.registerBeta(value); + } + }, + }, + ]); + + await specs[0].register("one"); + await specs[1].register("two"); + + expect(alpha.registerAlpha).toHaveBeenCalledWith("one"); + expect(beta.registerBeta).toHaveBeenCalledWith("two"); + }); + + it("builds program-only imported specs from exported registrar names", async () => { + const module = { registerAlpha: vi.fn() }; + const spec = defineImportedProgramCommandGroupSpec({ + commandNames: ["alpha"], + loadModule: async () => module, + exportName: "registerAlpha", + }); + + await spec.register("program" as never); + + expect(module.registerAlpha).toHaveBeenCalledWith("program"); + }); + + it("builds multiple program-only imported specs from definition arrays", async () => { + const alpha = { registerAlpha: vi.fn() }; + const beta = { registerBeta: vi.fn() }; + const specs = defineImportedProgramCommandGroupSpecs([ + { + commandNames: ["alpha"], + loadModule: async () => alpha, + exportName: "registerAlpha", + }, + { + commandNames: ["beta"], + loadModule: async () => beta, + exportName: "registerBeta", + }, + ]); + + await specs[0].register("program-one" as never); + await specs[1].register("program-two" as never); + + expect(alpha.registerAlpha).toHaveBeenCalledWith("program-one"); + expect(beta.registerBeta).toHaveBeenCalledWith("program-two"); + }); +}); diff --git a/src/cli/program/command-group-descriptors.ts b/src/cli/program/command-group-descriptors.ts new file mode 100644 index 00000000000..cac027de517 --- /dev/null +++ b/src/cli/program/command-group-descriptors.ts @@ -0,0 +1,117 @@ +import type { Command } from "commander"; +import type { CommandGroupEntry } from "./register-command-groups.js"; + +export type NamedCommandDescriptor = { + name: string; + description: string; + hasSubcommands: boolean; +}; + +export type CommandGroupDescriptorSpec = { + commandNames: readonly string[]; + register: TRegister; +}; + +export type ImportedCommandGroupDefinition = { + commandNames: readonly string[]; + loadModule: () => Promise; + register: (module: TModule, args: TRegisterArgs) => Promise | void; +}; + +export type ResolvedCommandGroupEntry = { + placeholders: TDescriptor[]; + register: TRegister; +}; + +function buildDescriptorIndex( + descriptors: readonly TDescriptor[], +): Map { + return new Map(descriptors.map((descriptor) => [descriptor.name, descriptor])); +} + +export function resolveCommandGroupEntries( + descriptors: readonly TDescriptor[], + specs: readonly CommandGroupDescriptorSpec[], +): ResolvedCommandGroupEntry[] { + const descriptorsByName = buildDescriptorIndex(descriptors); + return specs.map((spec) => ({ + placeholders: spec.commandNames.map((name) => { + const descriptor = descriptorsByName.get(name); + if (!descriptor) { + throw new Error(`Unknown command descriptor: ${name}`); + } + return descriptor; + }), + register: spec.register, + })); +} + +export function buildCommandGroupEntries( + descriptors: readonly TDescriptor[], + specs: readonly CommandGroupDescriptorSpec[], + mapRegister: (register: TRegister) => CommandGroupEntry["register"], +): CommandGroupEntry[] { + return resolveCommandGroupEntries(descriptors, specs).map((entry) => ({ + placeholders: entry.placeholders, + register: mapRegister(entry.register), + })); +} + +export function defineImportedCommandGroupSpec( + commandNames: readonly string[], + loadModule: () => Promise, + register: (module: TModule, args: TRegisterArgs) => Promise | void, +): CommandGroupDescriptorSpec<(args: TRegisterArgs) => Promise> { + return { + commandNames, + register: async (args: TRegisterArgs) => { + const module = await loadModule(); + await register(module, args); + }, + }; +} + +export function defineImportedCommandGroupSpecs( + definitions: readonly ImportedCommandGroupDefinition[], +): CommandGroupDescriptorSpec<(args: TRegisterArgs) => Promise>[] { + return definitions.map((definition) => + defineImportedCommandGroupSpec( + definition.commandNames, + definition.loadModule, + definition.register, + ), + ); +} + +type ProgramCommandRegistrar = (program: Command) => Promise | void; + +export type ImportedProgramCommandGroupDefinition< + TModule extends Record, + TKey extends keyof TModule & string, +> = { + commandNames: readonly string[]; + loadModule: () => Promise; + exportName: TKey; +}; + +export function defineImportedProgramCommandGroupSpec< + TModule extends Record, + TKey extends keyof TModule & string, +>( + definition: ImportedProgramCommandGroupDefinition, +): CommandGroupDescriptorSpec<(program: Command) => Promise> { + return defineImportedCommandGroupSpec( + definition.commandNames, + definition.loadModule, + (module, program: Command) => module[definition.exportName](program), + ); +} + +export function defineImportedProgramCommandGroupSpecs< + TModule extends Record, + TKey extends keyof TModule & string, +>( + definitions: readonly ImportedProgramCommandGroupDefinition[], +): CommandGroupDescriptorSpec<(program: Command) => Promise>[] { + return definitions.map((definition) => defineImportedProgramCommandGroupSpec(definition)); +} diff --git a/src/cli/program/command-registry.test.ts b/src/cli/program/command-registry.test.ts index 0601ff6ba8e..49db2c0a240 100644 --- a/src/cli/program/command-registry.test.ts +++ b/src/cli/program/command-registry.test.ts @@ -67,6 +67,7 @@ describe("command-registry", () => { it("includes both agent and agents in core CLI command names", () => { const names = getCoreCliCommandNames(); + expect(names).toContain("mcp"); expect(names).toContain("agent"); expect(names).toContain("agents"); }); @@ -76,6 +77,7 @@ describe("command-registry", () => { expect(names).toContain("config"); expect(names).toContain("agents"); expect(names).toContain("backup"); + expect(names).toContain("mcp"); expect(names).toContain("sessions"); expect(names).toContain("tasks"); expect(names).not.toContain("agent"); diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 3c20ef3eea3..77d80be3fce 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -1,10 +1,16 @@ import type { Command } from "commander"; import { resolveCliArgvInvocation } from "../argv-invocation.js"; import { shouldRegisterPrimaryCommandOnly } from "../command-registration-policy.js"; +import { + buildCommandGroupEntries, + defineImportedCommandGroupSpec, + defineImportedProgramCommandGroupSpecs, + type CommandGroupDescriptorSpec, +} from "./command-group-descriptors.js"; import type { ProgramContext } from "./context.js"; import { - type CoreCliCommandDescriptor, getCoreCliCommandDescriptors, + getCoreCliCommandNames as getCoreDescriptorNames, getCoreCliCommandsWithSubcommands, } from "./core-command-descriptors.js"; import { @@ -27,197 +33,105 @@ export type CommandRegistration = { register: (params: CommandRegisterParams) => void; }; -type CoreCliEntry = { - commands: CoreCliCommandDescriptor[]; - registerWithParams: (params: CommandRegisterParams) => Promise | void; -}; - -// Note for humans and agents: -// If you update the list of commands, also check whether they have subcommands -// and set the flag accordingly. -const coreEntries: CoreCliEntry[] = [ - { - commands: [ - { - name: "setup", - description: "Initialize local config and agent workspace", - hasSubcommands: false, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.setup.js"); - mod.registerSetupCommand(program); - }, - }, - { - commands: [ - { - name: "onboard", - description: "Interactive onboarding for gateway, workspace, and skills", - hasSubcommands: false, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.onboard.js"); - mod.registerOnboardCommand(program); - }, - }, - { - commands: [ - { - name: "configure", - description: - "Interactive configuration for credentials, channels, gateway, and agent defaults", - hasSubcommands: false, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.configure.js"); - mod.registerConfigureCommand(program); - }, - }, - { - commands: [ - { - name: "config", - description: - "Non-interactive config helpers (get/set/unset/file/validate). Default: starts guided setup.", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("../config-cli.js"); - mod.registerConfigCli(program); - }, - }, - { - commands: [ - { - name: "backup", - description: "Create and verify local backup archives for OpenClaw state", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.backup.js"); - mod.registerBackupCommand(program); - }, - }, - { - commands: [ - { - name: "doctor", - description: "Health checks + quick fixes for the gateway and channels", - hasSubcommands: false, - }, - { - name: "dashboard", - description: "Open the Control UI with your current token", - hasSubcommands: false, - }, - { - name: "reset", - description: "Reset local config/state (keeps the CLI installed)", - hasSubcommands: false, - }, - { - name: "uninstall", - description: "Uninstall the gateway service + local data (CLI remains)", - hasSubcommands: false, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.maintenance.js"); - mod.registerMaintenanceCommands(program); - }, - }, - { - commands: [ - { - name: "message", - description: "Send, read, and manage messages", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program, ctx }) => { - const mod = await import("./register.message.js"); - mod.registerMessageCommands(program, ctx); - }, - }, - { - commands: [ - { - name: "mcp", - description: "Manage OpenClaw MCP config and channel bridge", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("../mcp-cli.js"); - mod.registerMcpCli(program); - }, - }, - { - commands: [ - { - name: "agent", - description: "Run one agent turn via the Gateway", - hasSubcommands: false, - }, - { - name: "agents", - description: "Manage isolated agents (workspaces, auth, routing)", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program, ctx }) => { - const mod = await import("./register.agent.js"); - mod.registerAgentCommands(program, { - agentChannelOptions: ctx.agentChannelOptions, - }); - }, - }, - { - commands: [ - { - name: "status", - description: "Show channel health and recent session recipients", - hasSubcommands: false, - }, - { - name: "health", - description: "Fetch health from the running gateway", - hasSubcommands: false, - }, - { - name: "sessions", - description: "List stored conversation sessions", - hasSubcommands: true, - }, - { - name: "tasks", - description: "Inspect durable background task state", - hasSubcommands: true, - }, - ], - registerWithParams: async ({ program }) => { - const mod = await import("./register.status-health-sessions.js"); - mod.registerStatusHealthSessionsCommands(program); - }, - }, -]; - -function resolveCoreCommandGroups(ctx: ProgramContext, argv: string[]): CommandGroupEntry[] { - return coreEntries.map((entry) => ({ - placeholders: entry.commands, - register: async (program) => { - await entry.registerWithParams({ program, ctx, argv }); +function withProgramOnlySpecs( + specs: readonly CommandGroupDescriptorSpec<(program: Command) => Promise | void>[], +): CommandGroupDescriptorSpec<(params: CommandRegisterParams) => Promise>[] { + return specs.map((spec) => ({ + commandNames: spec.commandNames, + register: async ({ program }) => { + await spec.register(program); }, })); } +// Note for humans and agents: +// If you update the list of commands, also check whether they have subcommands +// and set the flag accordingly. +const coreEntrySpecs: readonly CommandGroupDescriptorSpec< + (params: CommandRegisterParams) => Promise | void +>[] = [ + ...withProgramOnlySpecs( + defineImportedProgramCommandGroupSpecs([ + { + commandNames: ["setup"], + loadModule: () => import("./register.setup.js"), + exportName: "registerSetupCommand", + }, + { + commandNames: ["onboard"], + loadModule: () => import("./register.onboard.js"), + exportName: "registerOnboardCommand", + }, + { + commandNames: ["configure"], + loadModule: () => import("./register.configure.js"), + exportName: "registerConfigureCommand", + }, + { + commandNames: ["config"], + loadModule: () => import("../config-cli.js"), + exportName: "registerConfigCli", + }, + { + commandNames: ["backup"], + loadModule: () => import("./register.backup.js"), + exportName: "registerBackupCommand", + }, + { + commandNames: ["doctor", "dashboard", "reset", "uninstall"], + loadModule: () => import("./register.maintenance.js"), + exportName: "registerMaintenanceCommands", + }, + ]), + ), + defineImportedCommandGroupSpec( + ["message"], + () => import("./register.message.js"), + (mod, { program, ctx }) => { + mod.registerMessageCommands(program, ctx); + }, + ), + ...withProgramOnlySpecs( + defineImportedProgramCommandGroupSpecs([ + { + commandNames: ["mcp"], + loadModule: () => import("../mcp-cli.js"), + exportName: "registerMcpCli", + }, + ]), + ), + defineImportedCommandGroupSpec( + ["agent", "agents"], + () => import("./register.agent.js"), + (mod, { program, ctx }) => { + mod.registerAgentCommands(program, { + agentChannelOptions: ctx.agentChannelOptions, + }); + }, + ), + ...withProgramOnlySpecs( + defineImportedProgramCommandGroupSpecs([ + { + commandNames: ["status", "health", "sessions", "tasks"], + loadModule: () => import("./register.status-health-sessions.js"), + exportName: "registerStatusHealthSessionsCommands", + }, + ]), + ), +]; + +function resolveCoreCommandGroups(ctx: ProgramContext, argv: string[]): CommandGroupEntry[] { + return buildCommandGroupEntries( + getCoreCliCommandDescriptors(), + coreEntrySpecs, + (register) => async (program) => { + await register({ program, ctx, argv }); + }, + ); +} + export function getCoreCliCommandNames(): string[] { - return getCoreCliCommandDescriptors().map((command) => command.name); + return getCoreDescriptorNames(); } export async function registerCoreCliByName( diff --git a/src/cli/program/core-command-descriptors.ts b/src/cli/program/core-command-descriptors.ts index 302c086fc4e..e557f1a568e 100644 --- a/src/cli/program/core-command-descriptors.ts +++ b/src/cli/program/core-command-descriptors.ts @@ -1,10 +1,9 @@ -export type CoreCliCommandDescriptor = { - name: string; - description: string; - hasSubcommands: boolean; -}; +import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js"; +import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; -export const CORE_CLI_COMMAND_DESCRIPTORS = [ +export type CoreCliCommandDescriptor = NamedCommandDescriptor; + +const coreCliCommandCatalog = defineCommandDescriptorCatalog([ { name: "setup", description: "Initialize local config and agent workspace", @@ -56,6 +55,11 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [ description: "Send, read, and manage messages", hasSubcommands: true, }, + { + name: "mcp", + description: "Manage OpenClaw MCP config and channel bridge", + hasSubcommands: true, + }, { name: "agent", description: "Run one agent turn via the Gateway", @@ -86,14 +90,18 @@ export const CORE_CLI_COMMAND_DESCRIPTORS = [ description: "Inspect durable background task state", hasSubcommands: true, }, -] as const satisfies ReadonlyArray; +] as const satisfies ReadonlyArray); + +export const CORE_CLI_COMMAND_DESCRIPTORS = coreCliCommandCatalog.descriptors; export function getCoreCliCommandDescriptors(): ReadonlyArray { - return CORE_CLI_COMMAND_DESCRIPTORS; + return coreCliCommandCatalog.getDescriptors(); +} + +export function getCoreCliCommandNames(): string[] { + return coreCliCommandCatalog.getNames(); } export function getCoreCliCommandsWithSubcommands(): string[] { - return CORE_CLI_COMMAND_DESCRIPTORS.filter((command) => command.hasSubcommands).map( - (command) => command.name, - ); + return coreCliCommandCatalog.getCommandsWithSubcommands(); } diff --git a/src/cli/program/register-command-groups.ts b/src/cli/program/register-command-groups.ts index e1e401f17b1..0a4e7503b85 100644 --- a/src/cli/program/register-command-groups.ts +++ b/src/cli/program/register-command-groups.ts @@ -9,16 +9,25 @@ export type CommandGroupPlaceholder = { export type CommandGroupEntry = { placeholders: readonly CommandGroupPlaceholder[]; + names?: readonly string[]; register: (program: Command) => Promise | void; }; -function findCommandGroupEntry( +export function getCommandGroupNames(entry: CommandGroupEntry): readonly string[] { + return entry.names ?? entry.placeholders.map((placeholder) => placeholder.name); +} + +export function findCommandGroupEntry( entries: readonly CommandGroupEntry[], name: string, ): CommandGroupEntry | undefined { - return entries.find((entry) => - entry.placeholders.some((placeholder) => placeholder.name === name), - ); + return entries.find((entry) => getCommandGroupNames(entry).includes(name)); +} + +export function removeCommandGroupNames(program: Command, entry: CommandGroupEntry) { + for (const name of new Set(getCommandGroupNames(entry))) { + removeCommandByName(program, name); + } } export async function registerCommandGroupByName( @@ -30,14 +39,12 @@ export async function registerCommandGroupByName( if (!entry) { return false; } - for (const placeholder of entry.placeholders) { - removeCommandByName(program, placeholder.name); - } + removeCommandGroupNames(program, entry); await entry.register(program); return true; } -function registerLazyCommandGroup( +export function registerLazyCommandGroup( program: Command, entry: CommandGroupEntry, placeholder: CommandGroupPlaceholder, @@ -46,7 +53,7 @@ function registerLazyCommandGroup( program, name: placeholder.name, description: placeholder.description, - removeNames: entry.placeholders.map((candidate) => candidate.name), + removeNames: [...new Set(getCommandGroupNames(entry))], register: async () => { await entry.register(program); }, diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index a7f1e5d1387..5ac5bc759c1 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -3,6 +3,10 @@ import type { OpenClawConfig } from "../../config/config.js"; import { getPluginCliCommandDescriptors } from "../../plugins/cli.js"; import type { PluginLoadOptions } from "../../plugins/loader.js"; import { VERSION } from "../../version.js"; +import { + addCommandDescriptorsToProgram, + collectUniqueCommandDescriptors, +} from "./command-descriptor-utils.js"; import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; @@ -21,29 +25,16 @@ async function buildRootHelpProgram(renderOptions?: RootHelpRenderOptions): Prom agentChannelOptions: "", }); - const existingCommands = new Set(); - for (const command of getCoreCliCommandDescriptors()) { - program.command(command.name).description(command.description); - existingCommands.add(command.name); - } - for (const command of getSubCliEntries()) { - if (existingCommands.has(command.name)) { - continue; - } - program.command(command.name).description(command.description); - existingCommands.add(command.name); - } - for (const command of await getPluginCliCommandDescriptors( - renderOptions?.config, - renderOptions?.env, - { pluginSdkResolution: renderOptions?.pluginSdkResolution }, - )) { - if (existingCommands.has(command.name)) { - continue; - } - program.command(command.name).description(command.description); - existingCommands.add(command.name); - } + addCommandDescriptorsToProgram( + program, + collectUniqueCommandDescriptors([ + getCoreCliCommandDescriptors(), + getSubCliEntries(), + await getPluginCliCommandDescriptors(renderOptions?.config, renderOptions?.env, { + pluginSdkResolution: renderOptions?.pluginSdkResolution, + }), + ]), + ); return program; } diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index a7dae0b47b4..64dc5b09ec0 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -1,10 +1,9 @@ -export type SubCliDescriptor = { - name: string; - description: string; - hasSubcommands: boolean; -}; +import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js"; +import type { NamedCommandDescriptor } from "./command-group-descriptors.js"; -export const SUB_CLI_DESCRIPTORS = [ +export type SubCliDescriptor = NamedCommandDescriptor; + +const subCliCommandCatalog = defineCommandDescriptorCatalog([ { name: "acp", description: "Agent Control Protocol tools", hasSubcommands: true }, { name: "gateway", @@ -138,12 +137,14 @@ export const SUB_CLI_DESCRIPTORS = [ description: "Generate shell completion script", hasSubcommands: false, }, -] as const satisfies ReadonlyArray; +] as const satisfies ReadonlyArray); + +export const SUB_CLI_DESCRIPTORS = subCliCommandCatalog.descriptors; export function getSubCliEntries(): ReadonlyArray { - return SUB_CLI_DESCRIPTORS; + return subCliCommandCatalog.getDescriptors(); } export function getSubCliCommandsWithSubcommands(): string[] { - return SUB_CLI_DESCRIPTORS.filter((entry) => entry.hasSubcommands).map((entry) => entry.name); + return subCliCommandCatalog.getCommandsWithSubcommands(); }