feat(cli): add openclaw agent exec headless one-shot runner (#113988)

* feat(cli): add agent exec headless runner

* test(cli): align agent parent startup paths

* fix(cli): scope agent exec environment explicitly
This commit is contained in:
Peter Steinberger
2026-07-25 20:59:20 -07:00
committed by GitHub
parent adfb59c19b
commit 69399b1cc3
24 changed files with 1626 additions and 84 deletions
+1 -1
View File
@@ -109,7 +109,7 @@ describe("command-registry", () => {
expect(names).toContain("sessions");
expect(names).toContain("commitments");
expect(names).toContain("tasks");
expect(names).not.toContain("agent");
expect(names).toContain("agent");
expect(names).not.toContain("setup");
expect(names).not.toContain("status");
expect(names).not.toContain("doctor");
+1 -1
View File
@@ -89,7 +89,7 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
{
name: "agent",
description: "Run an agent turn via the Gateway (use --local for embedded)",
hasSubcommands: false,
hasSubcommands: true,
},
{
name: "agents",
+20 -3
View File
@@ -143,12 +143,17 @@ describe("registerPreActionHooks", () => {
function buildProgram() {
const programLocal = new Command().name("openclaw");
programLocal
const agent = programLocal
.command("agent")
.requiredOption("-m, --message <text>")
.option("--local")
.option("--json")
.action(() => {});
agent
.command("exec")
.argument("[message]")
.option("--json")
.action(() => {});
programLocal
.command("status")
.option("--json")
@@ -401,7 +406,7 @@ describe("registerPreActionHooks", () => {
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["agent", "hi"],
commandPath: ["agent"],
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
scope: "all",
@@ -416,7 +421,7 @@ describe("registerPreActionHooks", () => {
expect(ensureConfigReadyMock).toHaveBeenCalledWith({
runtime: runtimeMock,
commandPath: ["agent", "hi"],
commandPath: ["agent"],
suppressDoctorStdout: true,
});
expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({
@@ -424,6 +429,18 @@ describe("registerPreActionHooks", () => {
});
});
it("bypasses operator config and plugin startup for agent exec", async () => {
await runPreAction({
parseArgv: ["agent", "exec", "fix it"],
processArgv: ["node", "openclaw", "agent", "exec", "fix it"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled();
expect(routeLogsToStderrMock).toHaveBeenCalled();
expect(emitCliBannerMock).not.toHaveBeenCalled();
});
it("keeps setup alias and channels add manifest-first", async () => {
await runPreAction({
parseArgv: ["onboard"],
+77 -1
View File
@@ -6,6 +6,7 @@ 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 AgentExecModule = typeof import("../../commands/agent-exec.js");
type CliUtilsModule = typeof import("../cli-utils.js");
type GlobalStateModule = typeof import("../../global-state.js");
type RuntimeModule = typeof import("../../runtime.js");
@@ -14,6 +15,14 @@ async function loadAgentCliCommand(): Promise<AgentViaGatewayModule["agentCliCom
return (await import("../../commands/agent-via-gateway.js")).agentCliCommand;
}
async function loadAgentExecCommand(): Promise<AgentExecModule["agentExecCommand"]> {
return (await import("../../commands/agent-exec.js")).agentExecCommand;
}
function collectFallback(value: string, previous: string[]): string[] {
return [...previous, value];
}
async function loadDefaultRuntime(): Promise<RuntimeModule["defaultRuntime"]> {
return (await import("../../runtime.js")).defaultRuntime;
}
@@ -31,7 +40,7 @@ export function registerAgentTurnCommand(
program: Command,
args: { agentChannelOptions: string },
): void {
program
const agent = program
.command("agent")
.description("Run an agent turn via the Gateway (use --local for embedded)")
.option("-m, --message <text>", "Message body for the agent")
@@ -109,4 +118,71 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.openclaw.ai/cli/age
await agentCliCommand(opts, defaultRuntime);
});
});
agent
.command("exec [message]")
.description("Run one isolated headless embedded agent turn")
.option("--message-file <path>", "Read the UTF-8 prompt from a file; use - for stdin")
.option("--cwd <dir>", "Set both the agent workspace and tool working directory")
.option("--state-dir <dir>", "Use an existing state directory without deleting it")
.option("--model <provider/model>", "Use an explicit primary model for this run")
.option(
"--thinking <level>",
"Thinking level: off | minimal | low | medium | high | xhigh | adaptive | max where supported",
)
.option(
"--fallback <provider/model>",
"Add an ordered fallback model (repeatable; requires --model)",
collectFallback,
[],
)
.option("--auth-env-only", "Use provider credentials from environment variables only", true)
.option("--no-auth-env-only", "Allow stored and external CLI credential discovery")
.option("--timeout <seconds>", "Agent deadline in seconds", "600")
.option("--json", "Emit the stable agent-exec JSON envelope", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
['openclaw agent exec "Fix the failing test"', "Run in the current directory."],
[
"openclaw agent exec --message-file task.md --cwd ./repo",
"Read a prompt file and set the workspace.",
],
[
'openclaw agent exec "Summarize this repo" --model openai/gpt-5.6-sol --fallback anthropic/claude-sonnet-4-6 --json',
"Use an explicit fallback chain and JSON output.",
],
])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/agent#agent-exec", "docs.openclaw.ai/cli/agent#agent-exec")}`,
)
.action(async (message: string | undefined, opts, command): Promise<void> => {
const parentOpts = command.parent?.opts() as
| {
messageFile?: string;
model?: string;
thinking?: string;
timeout?: string;
json?: boolean;
}
| undefined;
const execOpts = {
...opts,
messageFile: opts.messageFile ?? parentOpts?.messageFile,
model: opts.model ?? parentOpts?.model,
thinking: opts.thinking ?? parentOpts?.thinking,
timeout: parentOpts?.timeout ?? opts.timeout,
json: opts.json === true || parentOpts?.json === true,
};
const [defaultRuntime, runCommandWithRuntime, agentExecCommand] = await Promise.all([
loadDefaultRuntime(),
loadRunCommandWithRuntime(),
loadAgentExecCommand(),
]);
await runCommandWithRuntime(defaultRuntime, async () => {
const result = await agentExecCommand(message, execOpts, defaultRuntime);
if (result.exitCode !== 0) {
defaultRuntime.exit(result.exitCode, { resetStream: process.stderr });
}
});
});
}
+63
View File
@@ -6,6 +6,7 @@ import { registerAgentsCommands } from "./register.agent.js";
const mocks = vi.hoisted(() => ({
agentCliCommandMock: vi.fn(),
agentExecCommandMock: vi.fn(),
agentsAddCommandMock: vi.fn(),
agentsBindingsCommandMock: vi.fn(),
agentsBindCommandMock: vi.fn(),
@@ -22,6 +23,7 @@ const mocks = vi.hoisted(() => ({
}));
const agentCliCommandMock = mocks.agentCliCommandMock;
const agentExecCommandMock = mocks.agentExecCommandMock;
const agentsAddCommandMock = mocks.agentsAddCommandMock;
const agentsBindingsCommandMock = mocks.agentsBindingsCommandMock;
const agentsBindCommandMock = mocks.agentsBindCommandMock;
@@ -36,6 +38,10 @@ vi.mock("../../commands/agent-via-gateway.js", () => ({
agentCliCommand: mocks.agentCliCommandMock,
}));
vi.mock("../../commands/agent-exec.js", () => ({
agentExecCommand: mocks.agentExecCommandMock,
}));
vi.mock("../../commands/agents.commands.add.js", () => ({
agentsAddCommand: mocks.agentsAddCommandMock,
}));
@@ -78,6 +84,7 @@ describe("agent command registration", () => {
vi.clearAllMocks();
runtime.exit.mockImplementation(() => {});
agentCliCommandMock.mockResolvedValue(undefined);
agentExecCommandMock.mockResolvedValue({ exitCode: 0 });
agentsAddCommandMock.mockResolvedValue(undefined);
agentsBindingsCommandMock.mockResolvedValue(undefined);
agentsBindCommandMock.mockResolvedValue(undefined);
@@ -150,6 +157,62 @@ describe("agent command registration", () => {
expect(deps).toBeUndefined();
});
it("keeps bare agent on the existing parent action", async () => {
await runCli(["agent", "--message", "hi", "--agent", "ops"]);
expect(agentCliCommandMock).toHaveBeenCalledTimes(1);
expect(agentExecCommandMock).not.toHaveBeenCalled();
});
it("keeps an exec-valued parent message on the existing parent action", async () => {
await runCli(["agent", "--message", "exec", "--agent", "ops"]);
expect(agentCliCommandMock).toHaveBeenCalledTimes(1);
expect(agentExecCommandMock).not.toHaveBeenCalled();
});
it("runs the nested headless exec command with repeatable fallbacks", async () => {
await runCli([
"agent",
"exec",
"fix it",
"--cwd",
"/tmp/project",
"--model",
"openai/gpt-5.6-sol",
"--fallback",
"anthropic/claude-sonnet-4-6",
"--fallback",
"google/gemini-3.1-pro-preview",
"--json",
]);
expect(agentCliCommandMock).not.toHaveBeenCalled();
expect(agentExecCommandMock).toHaveBeenCalledWith(
"fix it",
expect.objectContaining({
cwd: "/tmp/project",
model: "openai/gpt-5.6-sol",
fallback: ["anthropic/claude-sonnet-4-6", "google/gemini-3.1-pro-preview"],
authEnvOnly: true,
timeout: "600",
json: true,
}),
runtime,
);
});
it("accepts parent options before the nested exec command", async () => {
await runCli(["agent", "--model", "openai/gpt-5.6-sol", "exec", "fix it", "--json"]);
expect(agentCliCommandMock).not.toHaveBeenCalled();
expect(agentExecCommandMock).toHaveBeenCalledWith(
"fix it",
expect.objectContaining({ model: "openai/gpt-5.6-sol", json: true }),
runtime,
);
});
it("runs agents add and computes hasFlags based on explicit options", async () => {
await runCli(["agents", "add", "alpha"]);
const [alphaOptions, alphaRuntime, alphaFlags] = commandCall(agentsAddCommandMock, 0);