perf(cli): slim agent command registration

This commit is contained in:
Peter Steinberger
2026-05-31 11:14:05 +01:00
parent ae4ab2a41f
commit 6fb1f386c6
5 changed files with 175 additions and 109 deletions

View File

@@ -115,14 +115,21 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
]),
),
defineImportedCommandGroupSpec(
["agent", "agents"],
() => import("./register.agent.js"),
["agent"],
() => import("./register.agent-turn.js"),
(mod, { program, ctx }) => {
mod.registerAgentCommands(program, {
mod.registerAgentTurnCommand(program, {
agentChannelOptions: ctx.agentChannelOptions,
});
},
),
defineImportedCommandGroupSpec(
["agents"],
() => import("./register.agent.js"),
(mod, { program }) => {
mod.registerAgentsCommands(program);
},
),
...withProgramOnlySpecs(
defineImportedProgramCommandGroupSpecs([
{

View File

@@ -5,12 +5,17 @@ import type { ProgramContext } from "./context.js";
// Perf: `registerCoreCliByName(...)` dynamically imports registrar modules.
// Mock the heavy registrars so this suite stays focused on command-registry wiring.
vi.mock("./register.agent.js", () => ({
registerAgentCommands: (program: Command) => {
program.command("agent");
registerAgentsCommands: (program: Command) => {
program.command("agents");
},
}));
vi.mock("./register.agent-turn.js", () => ({
registerAgentTurnCommand: (program: Command) => {
program.command("agent");
},
}));
vi.mock("./register.backup.js", () => ({
registerBackupCommand: (program: Command) => {
const backup = program.command("backup");
@@ -95,15 +100,17 @@ describe("command-registry", () => {
expect(names).not.toContain("doctor");
});
it("registerCoreCliByName resolves agents to the agent entry", async () => {
it("registerCoreCliByName resolves agent and agents separately", async () => {
const program = createProgram();
const found = await registerCoreCliByName(program, testProgramContext, "agents");
expect(found).toBe(true);
// The registrar also installs the singular "agent" command from the same entry.
expect(program.commands.map((command) => command.name()).toSorted()).toEqual([
"agent",
"agents",
]);
expect(program.commands.map((command) => command.name())).toEqual(["agents"]);
const agentProgram = createProgram();
await expect(registerCoreCliByName(agentProgram, testProgramContext, "agent")).resolves.toBe(
true,
);
expect(agentProgram.commands.map((command) => command.name())).toEqual(["agent"]);
});
it("registerCoreCliByName returns false for unknown commands", async () => {

View File

@@ -0,0 +1,108 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { formatHelpExamples } from "../help-format.js";
type AgentViaGatewayModule = typeof import("../../commands/agent-via-gateway.js");
type CliUtilsModule = typeof import("../cli-utils.js");
type GlobalStateModule = typeof import("../../global-state.js");
type RuntimeModule = typeof import("../../runtime.js");
async function loadAgentCliCommand(): Promise<AgentViaGatewayModule["agentCliCommand"]> {
return (await import("../../commands/agent-via-gateway.js")).agentCliCommand;
}
async function loadDefaultRuntime(): Promise<RuntimeModule["defaultRuntime"]> {
return (await import("../../runtime.js")).defaultRuntime;
}
async function loadRunCommandWithRuntime(): Promise<CliUtilsModule["runCommandWithRuntime"]> {
return (await import("../cli-utils.js")).runCommandWithRuntime;
}
async function loadSetVerbose(): Promise<GlobalStateModule["setVerbose"]> {
return (await import("../../global-state.js")).setVerbose;
}
export function registerAgentTurnCommand(
program: Command,
args: { agentChannelOptions: string },
): void {
program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--model <id>", "Model override for this run (provider/model or model id)")
.option(
"--thinking <level>",
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",
)
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",
`Delivery channel: ${args.agentChannelOptions} (omit to use the main session channel)`,
)
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")
.option("--reply-account <id>", "Delivery account id override")
.option(
"--local",
"Run the embedded agent locally (requires model provider API keys in your shell)",
false,
)
.option("--deliver", "Send the agent's reply back to the selected channel", false)
.option("--json", "Output result as JSON", false)
.option(
"--timeout <seconds>",
"Override agent command timeout (seconds, default 600 or config value)",
)
.addHelpText(
"after",
() =>
`
${theme.heading("Examples:")}
${formatHelpExamples([
['openclaw agent --to +15555550123 --message "status update"', "Start a new session."],
['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."],
[
'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"',
"Target an exact session key.",
],
[
'openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium',
"Target a session with explicit thinking level.",
],
[
'openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json',
"Enable verbose logging and JSON output.",
],
['openclaw agent --to +15555550123 --message "Summon reply" --deliver', "Deliver reply."],
[
'openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"',
"Send reply to a different channel/target.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/agent")}`,
)
.action(async (opts): Promise<void> => {
const verboseLevel =
typeof opts.verbose === "string" ? normalizeLowercaseStringOrEmpty(opts.verbose) : "";
const [defaultRuntime, runCommandWithRuntime, setVerbose, agentCliCommand] =
await Promise.all([
loadDefaultRuntime(),
loadRunCommandWithRuntime(),
loadSetVerbose(),
loadAgentCliCommand(),
]);
await runCommandWithRuntime(defaultRuntime, async () => {
setVerbose(verboseLevel === "on");
await agentCliCommand(opts, defaultRuntime);
});
});
}

View File

@@ -1,20 +1,18 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { defaultRuntime } from "../../runtime.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { hasExplicitOptions } from "../command-options.js";
import { formatHelpExamples } from "../help-format.js";
import { collectOption } from "./helpers.js";
import { registerAgentTurnCommand } from "./register.agent-turn.js";
type AgentViaGatewayModule = typeof import("../../commands/agent-via-gateway.js");
type AgentsAddModule = typeof import("../../commands/agents.commands.add.js");
type AgentsBindModule = typeof import("../../commands/agents.commands.bind.js");
type AgentsDeleteModule = typeof import("../../commands/agents.commands.delete.js");
type AgentsIdentityModule = typeof import("../../commands/agents.commands.identity.js");
type AgentsListModule = typeof import("../../commands/agents.commands.list.js");
type GlobalStateModule = typeof import("../../global-state.js");
type CliUtilsModule = typeof import("../cli-utils.js");
type RuntimeModule = typeof import("../../runtime.js");
let agentsBindModulePromise: Promise<AgentsBindModule> | undefined;
@@ -22,10 +20,6 @@ function loadAgentsBindModule(): Promise<AgentsBindModule> {
return (agentsBindModulePromise ??= import("../../commands/agents.commands.bind.js"));
}
async function loadAgentCliCommand(): Promise<AgentViaGatewayModule["agentCliCommand"]> {
return (await import("../../commands/agent-via-gateway.js")).agentCliCommand;
}
async function loadAgentsAddCommand(): Promise<AgentsAddModule["agentsAddCommand"]> {
return (await import("../../commands/agents.commands.add.js")).agentsAddCommand;
}
@@ -56,86 +50,35 @@ async function loadAgentsListCommand(): Promise<AgentsListModule["agentsListComm
return (await import("../../commands/agents.commands.list.js")).agentsListCommand;
}
async function loadSetVerbose(): Promise<GlobalStateModule["setVerbose"]> {
return (await import("../../global-state.js")).setVerbose;
async function loadAgentsActionRuntime(): Promise<{
defaultRuntime: RuntimeModule["defaultRuntime"];
runCommandWithRuntime: CliUtilsModule["runCommandWithRuntime"];
}> {
const [{ defaultRuntime }, { runCommandWithRuntime }] = await Promise.all([
import("../../runtime.js"),
import("../cli-utils.js"),
]);
return { defaultRuntime, runCommandWithRuntime };
}
async function runAgentsCommandAction(
action: (runtime: RuntimeModule["defaultRuntime"]) => Promise<void>,
): Promise<void> {
const { defaultRuntime, runCommandWithRuntime } = await loadAgentsActionRuntime();
await runCommandWithRuntime(defaultRuntime, async () => {
await action(defaultRuntime);
});
}
export function registerAgentCommands(
program: Command,
args: { agentChannelOptions: string },
): void {
program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--model <id>", "Model override for this run (provider/model or model id)")
.option(
"--thinking <level>",
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",
)
.option("--verbose <on|off>", "Persist agent verbose level for the session")
.option(
"--channel <channel>",
`Delivery channel: ${args.agentChannelOptions} (omit to use the main session channel)`,
)
.option("--reply-to <target>", "Delivery target override (separate from session routing)")
.option("--reply-channel <channel>", "Delivery channel override (separate from routing)")
.option("--reply-account <id>", "Delivery account id override")
.option(
"--local",
"Run the embedded agent locally (requires model provider API keys in your shell)",
false,
)
.option("--deliver", "Send the agent's reply back to the selected channel", false)
.option("--json", "Output result as JSON", false)
.option(
"--timeout <seconds>",
"Override agent command timeout (seconds, default 600 or config value)",
)
.addHelpText(
"after",
() =>
`
${theme.heading("Examples:")}
${formatHelpExamples([
['openclaw agent --to +15555550123 --message "status update"', "Start a new session."],
['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."],
[
'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"',
"Target an exact session key.",
],
[
'openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium',
"Target a session with explicit thinking level.",
],
[
'openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json',
"Enable verbose logging and JSON output.",
],
['openclaw agent --to +15555550123 --message "Summon reply" --deliver', "Deliver reply."],
[
'openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"',
"Send reply to a different channel/target.",
],
])}
${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/agent")}`,
)
.action(async (opts): Promise<void> => {
const verboseLevel =
typeof opts.verbose === "string" ? normalizeLowercaseStringOrEmpty(opts.verbose) : "";
await runCommandWithRuntime(defaultRuntime, async () => {
const setVerbose = await loadSetVerbose();
setVerbose(verboseLevel === "on");
const agentCliCommand = await loadAgentCliCommand();
await agentCliCommand(opts, defaultRuntime);
});
});
registerAgentTurnCommand(program, args);
registerAgentsCommands(program);
}
export function registerAgentsCommands(program: Command): void {
const agents = program
.command("agents")
.description("Manage isolated agents (workspaces + auth + routing)")
@@ -151,11 +94,11 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.option("--json", "Output JSON instead of text", false)
.option("--bindings", "Include routing bindings", false)
.action(async (opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsListCommand = await loadAgentsListCommand();
await agentsListCommand(
{ json: Boolean(opts.json), bindings: Boolean(opts.bindings) },
defaultRuntime,
runtime,
);
});
});
@@ -166,14 +109,14 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.option("--agent <id>", "Filter by agent id")
.option("--json", "Output JSON instead of text", false)
.action(async (opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsBindingsCommand = await loadAgentsBindingsCommand();
await agentsBindingsCommand(
{
agent: opts.agent as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
);
});
});
@@ -190,7 +133,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
)
.option("--json", "Output JSON summary", false)
.action(async (opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsBindCommand = await loadAgentsBindCommand();
await agentsBindCommand(
{
@@ -198,7 +141,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
bind: Array.isArray(opts.bind) ? (opts.bind as string[]) : undefined,
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
);
});
});
@@ -211,7 +154,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.option("--all", "Remove all bindings for this agent", false)
.option("--json", "Output JSON summary", false)
.action(async (opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsUnbindCommand = await loadAgentsUnbindCommand();
await agentsUnbindCommand(
{
@@ -220,7 +163,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
all: Boolean(opts.all),
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
);
});
});
@@ -235,7 +178,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
.option("--non-interactive", "Disable prompts; requires --workspace", false)
.option("--json", "Output JSON summary", false)
.action(async (name, opts, command): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const hasFlags = hasExplicitOptions(command, [
"workspace",
"model",
@@ -254,7 +197,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
nonInteractive: Boolean(opts.nonInteractive),
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
{ hasFlags },
);
});
@@ -292,7 +235,7 @@ ${formatHelpExamples([
`,
)
.action(async (opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsSetIdentityCommand = await loadAgentsSetIdentityCommand();
await agentsSetIdentityCommand(
{
@@ -306,7 +249,7 @@ ${formatHelpExamples([
avatar: opts.avatar as string | undefined,
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
);
});
});
@@ -317,7 +260,7 @@ ${formatHelpExamples([
.option("--force", "Skip confirmation", false)
.option("--json", "Output JSON summary", false)
.action(async (id, opts): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsDeleteCommand = await loadAgentsDeleteCommand();
await agentsDeleteCommand(
{
@@ -325,15 +268,15 @@ ${formatHelpExamples([
force: Boolean(opts.force),
json: Boolean(opts.json),
},
defaultRuntime,
runtime,
);
});
});
agents.action(async (): Promise<void> => {
await runCommandWithRuntime(defaultRuntime, async () => {
await runAgentsCommandAction(async (runtime) => {
const agentsListCommand = await loadAgentsListCommand();
await agentsListCommand({}, defaultRuntime);
await agentsListCommand({}, runtime);
});
});
}