mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
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:
committed by
GitHub
parent
adfb59c19b
commit
69399b1cc3
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user