fix: load CLI plugin registry for channel-aware commands (#1338) (thanks @MaudeBot)

This commit is contained in:
Peter Steinberger 2026-01-21 00:48:36 +00:00
parent 47110e88c7
commit c01bfcbc12
18 changed files with 160 additions and 27 deletions

View File

@ -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)

View File

@ -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<void>, 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")

View File

@ -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 {

View File

@ -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

View File

@ -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<typeof import("../config/config.js")>();
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);
});

View File

@ -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>", `Channel (${channels.join(", ")})`)
.argument("[channel]", `Channel (${channels.join(", ")})`)
.option("--channel <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>", `Channel (${channels.join(", ")})`)
.option("--channel <channel>", channelHint)
.argument("<codeOrChannel>", "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) {

View File

@ -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);
});
});

View File

@ -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;
}

View File

@ -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");

View File

@ -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);
});
});

View File

@ -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();
}
});

View File

@ -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 <text>", "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")

View File

@ -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);
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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")

View File

@ -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 <key>", "Session key to inspect (defaults to agent main)")
@ -161,4 +162,5 @@ export function registerSandboxCli(program: Command) {
),
),
);
markCommandRequiresPluginRegistry(explain);
}

View File

@ -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")