From c01bfcbc12fa7795284719f23b83a3a6b97f127b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 00:48:36 +0000 Subject: [PATCH] fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot) --- CHANGELOG.md | 1 + src/cli/channels-cli.ts | 8 +-- src/cli/cron-cli/shared.ts | 5 +- src/cli/directory-cli.ts | 2 + src/cli/pairing-cli.test.ts | 16 ++++-- src/cli/pairing-cli.ts | 15 +++-- src/cli/program/command-metadata.test.ts | 24 ++++++++ src/cli/program/command-metadata.ts | 21 +++++++ src/cli/program/command-registry.ts | 1 + src/cli/program/preaction.test.ts | 56 +++++++++++++++++++ src/cli/program/preaction.ts | 10 ++-- src/cli/program/register.agent.ts | 5 +- src/cli/program/register.configure.ts | 4 +- src/cli/program/register.message.ts | 2 + src/cli/program/register.onboard.ts | 4 +- .../register.status-health-sessions.ts | 7 ++- src/cli/sandbox-cli.ts | 4 +- src/cli/security-cli.ts | 2 + 18 files changed, 160 insertions(+), 27 deletions(-) create mode 100644 src/cli/program/command-metadata.test.ts create mode 100644 src/cli/program/command-metadata.ts create mode 100644 src/cli/program/preaction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c84616062..1f441e059 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. - TUI: align custom editor initialization with the latest pi-tui API. (#1298) — thanks @sibbl. - CLI: avoid duplicating --profile/--dev flags when formatting commands. +- CLI: load channel plugins for commands that need registry-backed lookups. (#1338) — thanks @MaudeBot. - Status: route native `/status` to the active agent so model selection reflects the correct profile. (#1301) - Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297) — thanks @ysqander. - Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304) diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 586e7c5c2..ac4748275 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -1,5 +1,4 @@ import type { Command } from "commander"; -import { listChannelPlugins } from "../channels/plugins/index.js"; import { channelsAddCommand, channelsCapabilitiesCommand, @@ -13,9 +12,11 @@ import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { resolveCliChannelOptions } from "./channel-options.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { runCommandWithRuntime } from "./cli-utils.js"; import { hasExplicitOptions } from "./command-options.js"; +import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js"; const optionNamesAdd = [ "channel", @@ -58,9 +59,7 @@ function runChannelsCommandWithDanger(action: () => Promise, label: string } export function registerChannelsCli(program: Command) { - const channelNames = listChannelPlugins() - .map((plugin) => plugin.id) - .join("|"); + const channelNames = resolveCliChannelOptions().join("|"); const channels = program .command("channels") .description("Manage chat channel accounts") @@ -72,6 +71,7 @@ export function registerChannelsCli(program: Command) { "docs.clawd.bot/cli/channels", )}\n`, ); + markCommandRequiresPluginRegistry(channels); channels .command("list") diff --git a/src/cli/cron-cli/shared.ts b/src/cli/cron-cli/shared.ts index 5e5efc81a..151fadb28 100644 --- a/src/cli/cron-cli/shared.ts +++ b/src/cli/cron-cli/shared.ts @@ -1,13 +1,12 @@ -import { listChannelPlugins } from "../../channels/plugins/index.js"; import { parseAbsoluteTimeMs } from "../../cron/parse.js"; import type { CronJob, CronSchedule } from "../../cron/types.js"; import { defaultRuntime } from "../../runtime.js"; import { colorize, isRich, theme } from "../../terminal/theme.js"; +import { resolveCliChannelOptions } from "../channel-options.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { callGatewayFromCli } from "../gateway-rpc.js"; -export const getCronChannelOptions = () => - ["last", ...listChannelPlugins().map((plugin) => plugin.id)].join("|"); +export const getCronChannelOptions = () => ["last", ...resolveCliChannelOptions()].join("|"); export async function warnIfCronSchedulerDisabled(opts: GatewayRpcOpts) { try { diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts index ea927c7b6..cfb7dd300 100644 --- a/src/cli/directory-cli.ts +++ b/src/cli/directory-cli.ts @@ -8,6 +8,7 @@ import { resolveMessageChannelSelection } from "../infra/outbound/channel-select import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js"; function parseLimit(value: unknown): number | null { if (typeof value === "number" && Number.isFinite(value)) { @@ -42,6 +43,7 @@ export function registerDirectoryCli(program: Command) { .action(() => { directory.help({ error: true }); }); + markCommandRequiresPluginRegistry(directory); const withChannel = (cmd: Command) => cmd diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 9bbe0e3f2..8c792bfe0 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -34,12 +34,16 @@ vi.mock("../channels/plugins/index.js", () => ({ normalizeChannelId, })); -vi.mock("../config/config.js", () => ({ - loadConfig: vi.fn().mockReturnValue({}), -})); +vi.mock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: vi.fn().mockReturnValue({}), + }; +}); describe("pairing cli", () => { - it("evaluates pairing channels when registering the CLI (not at import)", async () => { + it("defers pairing channel lookup until command execution", async () => { listPairingChannels.mockClear(); const { registerPairingCli } = await import("./pairing-cli.js"); @@ -49,6 +53,10 @@ describe("pairing cli", () => { program.name("test"); registerPairingCli(program); + expect(listPairingChannels).not.toHaveBeenCalled(); + + listChannelPairingRequests.mockResolvedValueOnce([]); + await program.parseAsync(["pairing", "list", "telegram"], { from: "user" }); expect(listPairingChannels).toHaveBeenCalledTimes(1); }); diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index d0ecbce14..71e5412e7 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -10,7 +10,9 @@ import { } from "../pairing/pairing-store.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { resolveCliChannelOptions } from "./channel-options.js"; import { formatCliCommand } from "./command-format.js"; +import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js"; /** Parse channel, allowing extension channels not in core registry. */ function parseChannel(raw: unknown, channels: PairingChannel[]): PairingChannel { @@ -44,7 +46,9 @@ async function notifyApproved(channel: PairingChannel, id: string) { } export function registerPairingCli(program: Command) { - const channels = listPairingChannels(); + const channelOptions = resolveCliChannelOptions(); + const channelHint = + channelOptions.length > 0 ? `Channel (${channelOptions.join(", ")})` : "Channel"; const pairing = program .command("pairing") .description("Secure DM pairing (approve inbound requests)") @@ -53,14 +57,16 @@ export function registerPairingCli(program: Command) { () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/pairing", "docs.clawd.bot/cli/pairing")}\n`, ); + markCommandRequiresPluginRegistry(pairing); pairing .command("list") .description("List pending pairing requests") - .option("--channel ", `Channel (${channels.join(", ")})`) - .argument("[channel]", `Channel (${channels.join(", ")})`) + .option("--channel ", channelHint) + .argument("[channel]", channelHint) .option("--json", "Print JSON", false) .action(async (channelArg, opts) => { + const channels = listPairingChannels(); const channelRaw = opts.channel ?? channelArg; if (!channelRaw) { throw new Error( @@ -87,11 +93,12 @@ export function registerPairingCli(program: Command) { pairing .command("approve") .description("Approve a pairing code and allow that sender") - .option("--channel ", `Channel (${channels.join(", ")})`) + .option("--channel ", channelHint) .argument("", "Pairing code (or channel when using 2 args)") .argument("[code]", "Pairing code (when channel is passed as the 1st arg)") .option("--notify", "Notify the requester on the same channel", false) .action(async (codeOrChannel, code, opts) => { + const channels = listPairingChannels(); const channelRaw = opts.channel ?? codeOrChannel; const resolvedCode = opts.channel ? codeOrChannel : code; if (!opts.channel && !code) { diff --git a/src/cli/program/command-metadata.test.ts b/src/cli/program/command-metadata.test.ts new file mode 100644 index 000000000..ab932da11 --- /dev/null +++ b/src/cli/program/command-metadata.test.ts @@ -0,0 +1,24 @@ +import { Command } from "commander"; +import { describe, expect, it } from "vitest"; + +import { + commandRequiresPluginRegistry, + markCommandRequiresPluginRegistry, +} from "./command-metadata.js"; + +describe("commandRequiresPluginRegistry", () => { + it("detects direct requirement", () => { + const program = new Command(); + const cmd = program.command("message"); + markCommandRequiresPluginRegistry(cmd); + expect(commandRequiresPluginRegistry(cmd)).toBe(true); + }); + + it("walks parent chain", () => { + const program = new Command(); + const parent = program.command("channels"); + const child = parent.command("list"); + markCommandRequiresPluginRegistry(parent); + expect(commandRequiresPluginRegistry(child)).toBe(true); + }); +}); diff --git a/src/cli/program/command-metadata.ts b/src/cli/program/command-metadata.ts new file mode 100644 index 000000000..e05e00bed --- /dev/null +++ b/src/cli/program/command-metadata.ts @@ -0,0 +1,21 @@ +import type { Command } from "commander"; + +const REQUIRES_PLUGIN_REGISTRY = Symbol.for("clawdbot.requiresPluginRegistry"); + +type CommandWithPluginRequirement = Command & { + [REQUIRES_PLUGIN_REGISTRY]?: boolean; +}; + +export function markCommandRequiresPluginRegistry(command: Command): Command { + (command as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY] = true; + return command; +} + +export function commandRequiresPluginRegistry(command?: Command | null): boolean { + let current: Command | null | undefined = command; + while (current) { + if ((current as CommandWithPluginRequirement)[REQUIRES_PLUGIN_REGISTRY]) return true; + current = current.parent ?? undefined; + } + return false; +} diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 7c538362e..69a137b73 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -86,6 +86,7 @@ const routeSessions: RouteSpec = { const routeAgentsList: RouteSpec = { match: (path) => path[0] === "agents" && path[1] === "list", + loadPlugins: true, run: async (argv) => { const json = hasFlag(argv, "--json"); const bindings = hasFlag(argv, "--bindings"); diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts new file mode 100644 index 000000000..57c1515c1 --- /dev/null +++ b/src/cli/program/preaction.test.ts @@ -0,0 +1,56 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; + +vi.mock("../plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: vi.fn(), +})); +vi.mock("./config-guard.js", () => ({ + ensureConfigReady: vi.fn(async () => {}), +})); +vi.mock("../banner.js", () => ({ + emitCliBanner: vi.fn(), +})); +vi.mock("../argv.js", () => ({ + getCommandPath: vi.fn(() => ["message"]), + hasHelpOrVersion: vi.fn(() => false), +})); + +const loadRegisterPreActionHooks = async () => { + const mod = await import("./preaction.js"); + return mod.registerPreActionHooks; +}; + +const loadEnsurePluginRegistryLoaded = async () => { + const mod = await import("../plugin-registry.js"); + return mod.ensurePluginRegistryLoaded; +}; + +describe("registerPreActionHooks", () => { + beforeEach(async () => { + const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded(); + vi.mocked(ensurePluginRegistryLoaded).mockClear(); + }); + + it("loads plugins for marked commands", async () => { + const registerPreActionHooks = await loadRegisterPreActionHooks(); + const ensurePluginRegistryLoaded = await loadEnsurePluginRegistryLoaded(); + const program = new Command(); + registerPreActionHooks(program, "test"); + + const message = program.command("message").action(() => {}); + markCommandRequiresPluginRegistry(message); + + const originalArgv = process.argv; + const argv = ["node", "clawdbot", "message"]; + process.argv = argv; + try { + await program.parseAsync(argv); + } finally { + process.argv = originalArgv; + } + + expect(vi.mocked(ensurePluginRegistryLoaded)).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index 6ebf7c880..e4736b24e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -2,8 +2,9 @@ import type { Command } from "commander"; import { defaultRuntime } from "../../runtime.js"; import { emitCliBanner } from "../banner.js"; import { getCommandPath, hasHelpOrVersion } from "../argv.js"; -import { ensureConfigReady } from "./config-guard.js"; import { ensurePluginRegistryLoaded } from "../plugin-registry.js"; +import { commandRequiresPluginRegistry } from "./command-metadata.js"; +import { ensureConfigReady } from "./config-guard.js"; function setProcessTitleForCommand(actionCommand: Command) { let current: Command = actionCommand; @@ -15,20 +16,17 @@ function setProcessTitleForCommand(actionCommand: Command) { process.title = `clawdbot-${name}`; } -// Commands that need channel plugins loaded -const PLUGIN_REQUIRED_COMMANDS = new Set(["message", "channels", "directory"]); - export function registerPreActionHooks(program: Command, programVersion: string) { program.hook("preAction", async (_thisCommand, actionCommand) => { setProcessTitleForCommand(actionCommand); emitCliBanner(programVersion); const argv = process.argv; if (hasHelpOrVersion(argv)) return; + const needsPlugins = commandRequiresPluginRegistry(actionCommand); const commandPath = getCommandPath(argv, 2); if (commandPath[0] === "doctor") return; await ensureConfigReady({ runtime: defaultRuntime, commandPath }); - // Load plugins for commands that need channel access - if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + if (needsPlugins) { ensurePluginRegistryLoaded(); } }); diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 7cbb63e2f..b94e60b93 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -14,10 +14,11 @@ import { theme } from "../../terminal/theme.js"; import { hasExplicitOptions } from "../command-options.js"; import { createDefaultDeps } from "../deps.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; import { collectOption } from "./helpers.js"; export function registerAgentCommands(program: Command, args: { agentChannelOptions: string }) { - program + const agent = program .command("agent") .description("Run an agent turn via the Gateway (use --local for embedded)") .requiredOption("-m, --message ", "Message body for the agent") @@ -67,6 +68,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent await agentCliCommand(opts, defaultRuntime, deps); }); }); + markCommandRequiresPluginRegistry(agent); const agents = program .command("agents") @@ -76,6 +78,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agents", "docs.clawd.bot/cli/agents")}\n`, ); + markCommandRequiresPluginRegistry(agents); agents .command("list") diff --git a/src/cli/program/register.configure.ts b/src/cli/program/register.configure.ts index a10805e88..499d32f5e 100644 --- a/src/cli/program/register.configure.ts +++ b/src/cli/program/register.configure.ts @@ -8,9 +8,10 @@ import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; export function registerConfigureCommand(program: Command) { - program + const configure = program .command("configure") .description("Interactive prompt to set up credentials, devices, and agent defaults") .addHelpText( @@ -48,4 +49,5 @@ export function registerConfigureCommand(program: Command) { await configureCommandWithSections(sections as never, defaultRuntime); }); }); + markCommandRequiresPluginRegistry(configure); } diff --git a/src/cli/program/register.message.ts b/src/cli/program/register.message.ts index bcff92a98..560cc8ce9 100644 --- a/src/cli/program/register.message.ts +++ b/src/cli/program/register.message.ts @@ -2,6 +2,7 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import type { ProgramContext } from "./context.js"; +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; import { createMessageCliHelpers } from "./message/helpers.js"; import { registerMessageDiscordAdminCommands } from "./message/register.discord-admin.js"; import { @@ -39,6 +40,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/message", "docs.clawd.bot/cli/mes .action(() => { message.help({ error: true }); }); + markCommandRequiresPluginRegistry(message); const helpers = createMessageCliHelpers(message, ctx.messageChannelOptions); registerMessageSendCommand(message, helpers); diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts index 4f1005304..17ce853d4 100644 --- a/src/cli/program/register.onboard.ts +++ b/src/cli/program/register.onboard.ts @@ -12,6 +12,7 @@ import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; function resolveInstallDaemonFlag( command: unknown, @@ -32,7 +33,7 @@ function resolveInstallDaemonFlag( } export function registerOnboardCommand(program: Command) { - program + const onboard = program .command("onboard") .description("Interactive wizard to set up the gateway, workspace, and skills") .addHelpText( @@ -150,4 +151,5 @@ export function registerOnboardCommand(program: Command) { ); }); }); + markCommandRequiresPluginRegistry(onboard); } diff --git a/src/cli/program/register.status-health-sessions.ts b/src/cli/program/register.status-health-sessions.ts index cb34e7cea..12ce854f1 100644 --- a/src/cli/program/register.status-health-sessions.ts +++ b/src/cli/program/register.status-health-sessions.ts @@ -7,6 +7,7 @@ import { defaultRuntime } from "../../runtime.js"; import { formatDocsLink } from "../../terminal/links.js"; import { theme } from "../../terminal/theme.js"; import { runCommandWithRuntime } from "../cli-utils.js"; +import { markCommandRequiresPluginRegistry } from "./command-metadata.js"; import { parsePositiveIntOrUndefined } from "./helpers.js"; function resolveVerbose(opts: { verbose?: boolean; debug?: boolean }): boolean { @@ -24,7 +25,7 @@ function parseTimeoutMs(timeout: unknown): number | null | undefined { } export function registerStatusHealthSessionsCommands(program: Command) { - program + const status = program .command("status") .description("Show channel health and recent session recipients") .option("--json", "Output JSON instead of text", false) @@ -72,8 +73,9 @@ Examples: ); }); }); + markCommandRequiresPluginRegistry(status); - program + const health = program .command("health") .description("Fetch health from the running gateway") .option("--json", "Output JSON instead of text", false) @@ -103,6 +105,7 @@ Examples: ); }); }); + markCommandRequiresPluginRegistry(health); program .command("sessions") diff --git a/src/cli/sandbox-cli.ts b/src/cli/sandbox-cli.ts index 4766c0ec4..505923cef 100644 --- a/src/cli/sandbox-cli.ts +++ b/src/cli/sandbox-cli.ts @@ -5,6 +5,7 @@ import { sandboxExplainCommand } from "../commands/sandbox-explain.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { theme } from "../terminal/theme.js"; +import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js"; // --- Types --- @@ -142,7 +143,7 @@ export function registerSandboxCli(program: Command) { // --- Explain Command --- - sandbox + const explain = sandbox .command("explain") .description("Explain effective sandbox/tool policy for a session/agent") .option("--session ", "Session key to inspect (defaults to agent main)") @@ -161,4 +162,5 @@ export function registerSandboxCli(program: Command) { ), ), ); + markCommandRequiresPluginRegistry(explain); } diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index 31311022d..9c50d3dfd 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -8,6 +8,7 @@ import { fixSecurityFootguns } from "../security/fix.js"; import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { formatCliCommand } from "./command-format.js"; +import { markCommandRequiresPluginRegistry } from "./program/command-metadata.js"; type SecurityAuditOptions = { json?: boolean; @@ -36,6 +37,7 @@ export function registerSecurityCli(program: Command) { () => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.clawd.bot/cli/security")}\n`, ); + markCommandRequiresPluginRegistry(security); security .command("audit")