diff --git a/docs/cli/cron.md b/docs/cli/cron.md index f9db03d8ab91..21486af49e52 100644 --- a/docs/cli/cron.md +++ b/docs/cli/cron.md @@ -313,7 +313,9 @@ openclaw automations runs --id --run-id `openclaw automations list` shows enabled jobs by default. Pass `--all` to include disabled jobs, or `--agent ` to show only jobs whose effective normalized agent id matches; jobs without a stored agent id count as the configured default agent. -`openclaw automations get ` returns the stored job JSON directly. `get` and `runs` accept `--json` as the explicit machine-output spelling. Use `automations show ` when you want the human-readable view with delivery-route preview. +`--json` always requests JSON output. Commands whose product is already a machine-readable result emit JSON results by default: `add`/`create`, `status`, `enable`, `disable`, `rm`/`remove`/`delete`, `run`, `edit`, `get`, and `runs`. They accept `--json` as the explicit machine-output spelling. `openclaw automations get ` returns the stored job JSON directly; use `automations show ` when you want the human-readable view with delivery-route preview. + +`list` and `show` use human-readable output by default and switch to JSON with `--json`. `scratch` reads raw scratch content by default and prints the scratch plus revision metadata with `--json`; scratch writes return the revision result as JSON by default and accept `--json` as the explicit machine-output spelling. `automations list --json` and `automations show --json` include a top-level `status` field on each job, computed from `enabled`, `state.runningAtMs`, and `state.lastRunStatus`. Values: `disabled`, `running`, `ok`, `error`, `skipped`, or `idle`. JSON status stays canonical and undecorated so external tooling can read job state without re-deriving it; human output may decorate repeated `error` statuses with a failure count. diff --git a/src/cli/cron-cli/output-mode.ts b/src/cli/cron-cli/output-mode.ts index 25faf01ebaa2..81cdb407330a 100644 --- a/src/cli/cron-cli/output-mode.ts +++ b/src/cli/cron-cli/output-mode.ts @@ -1,27 +1,54 @@ -import { getMachineOutputCommandPath } from "../machine-output-argv.js"; +import type { Command } from "commander"; +import { + getMachineOutputCommandPath, + MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION, +} from "../machine-output-argv.js"; -const MACHINE_OUTPUT_COMMANDS = new Set([ - "add", - "create", - "delete", - "disable", - "edit", - "enable", - "get", - "remove", - "rm", - "run", - "runs", - "status", -]); +const CRON_SCRATCH_JSON_OPTION_DESCRIPTION = + "Output scratch plus revision metadata as JSON; writes return JSON by default"; + +type CronOutputCommandDefinition = { + aliases: readonly string[]; + alwaysJson: boolean; +}; + +const CRON_OUTPUT_COMMANDS = { + status: { aliases: [], alwaysJson: true }, + add: { aliases: ["create"], alwaysJson: true }, + rm: { aliases: ["remove", "delete"], alwaysJson: true }, + enable: { aliases: [], alwaysJson: true }, + disable: { aliases: [], alwaysJson: true }, + get: { aliases: [], alwaysJson: true }, + runs: { aliases: [], alwaysJson: true }, + run: { aliases: [], alwaysJson: true }, + edit: { aliases: [], alwaysJson: true }, + scratch: { aliases: [], alwaysJson: false }, +} as const satisfies Record; + +type CronOutputCommandName = keyof typeof CRON_OUTPUT_COMMANDS; +const MACHINE_OUTPUT_COMMANDS = new Set(); +for (const [name, definition] of Object.entries(CRON_OUTPUT_COMMANDS)) { + MACHINE_OUTPUT_COMMANDS.add(name); + for (const alias of definition.aliases) { + MACHINE_OUTPUT_COMMANDS.add(alias); + } +} + +export function createCronOutputCommand(parent: Command, name: CronOutputCommandName): Command { + const definition = CRON_OUTPUT_COMMANDS[name]; + const command = parent.command(name); + for (const alias of definition.aliases) { + command.alias(alias); + } + return definition.alwaysJson + ? command.option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION) + : command.option("--json", CRON_SCRATCH_JSON_OPTION_DESCRIPTION); +} export function isCronMachineOutput(argv: readonly string[]): boolean { const [, command] = getMachineOutputCommandPath(argv, 2); if (!command) { return false; } - if (MACHINE_OUTPUT_COMMANDS.has(command)) { - return true; - } - return command === "scratch"; + return MACHINE_OUTPUT_COMMANDS.has(command); } diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts index 1ae5a4a16be3..c34ea98c03e4 100644 --- a/src/cli/cron-cli/register.cron-add.ts +++ b/src/cli/cron-cli/register.cron-add.ts @@ -13,6 +13,7 @@ import { defaultRuntime } from "../../runtime.js"; import type { GatewayRpcOpts } from "../gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { listCronJobsFromGateway } from "./list-jobs.js"; +import { createCronOutputCommand } from "./output-mode.js"; import { resolveCronCreateScheduleFromArgs } from "./schedule-options.js"; import { getCronChannelOptions, @@ -32,10 +33,8 @@ import { readCronPayloadScript, readCronTriggerScript } from "./trigger-options. export function registerCronStatusCommand(cron: Command) { addGatewayClientOptions( - cron - .command("status") + createCronOutputCommand(cron, "status") .description("Show automations scheduler status") - .option("--json", "Output JSON", false) .action(async (opts) => { try { const res = await callGatewayFromCli("cron.status", opts, {}); @@ -84,9 +83,7 @@ export function registerCronListCommand(cron: Command) { export function registerCronAddCommand(cron: Command) { addGatewayClientOptions( - cron - .command("add") - .alias("create") + createCronOutputCommand(cron, "add") .description("Add an automation") .argument("[scheduleOrName]", "Schedule string, or job name when using --at/--every/--cron") .argument("[message]", "Agent message when using a positional schedule") @@ -163,7 +160,6 @@ export function registerCronAddCommand(cron: Command) { .option("--thread-id ", "Telegram forum topic thread id") .option("--account ", "Channel account id for delivery (multi-account setups)") .option("--best-effort-deliver", "Do not fail the job if delivery fails", false) - .option("--json", "Output JSON", false) .action( async ( nameArg: string | undefined, diff --git a/src/cli/cron-cli/register.cron-edit.test.ts b/src/cli/cron-cli/register.cron-edit.test.ts index ed695869ed83..3791d9e217a6 100644 --- a/src/cli/cron-cli/register.cron-edit.test.ts +++ b/src/cli/cron-cli/register.cron-edit.test.ts @@ -58,6 +58,17 @@ describe("cron edit command", () => { expect(help).toMatch(/also\s+implies --announce when used alone/); }); + it("accepts --json as the explicit machine-output spelling", async () => { + await createCronProgram().parseAsync(["edit", "job-1", "--enable", "--json"], { + from: "user", + }); + + expect(callGatewayFromCli).toHaveBeenCalledWith("cron.update", expect.anything(), { + id: "job-1", + patch: { enabled: true }, + }); + }); + it("updates the human-readable display name without changing the job name", async () => { await createCronProgram().parseAsync(["edit", "job-1", "--display-name", "Daily summary"], { from: "user", diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts index 330061e21f6c..6a873c49b993 100644 --- a/src/cli/cron-cli/register.cron-edit.ts +++ b/src/cli/cron-cli/register.cron-edit.ts @@ -18,6 +18,7 @@ import { } from "../gateway-rpc.js"; import { parseDurationMs } from "../parse-duration.js"; import { isUnknownCronGetMethodError, listCronJobsFromGateway } from "./list-jobs.js"; +import { createCronOutputCommand } from "./output-mode.js"; import { resolveCronEditPayloadDeliveryPatch } from "./register.cron-edit-options.js"; import { applyExistingCronSchedulePatch, @@ -55,8 +56,7 @@ async function readCronJobForEdit(opts: GatewayRpcOpts, id: string): Promise", "Job id") .option("--name ", "Set name") diff --git a/src/cli/cron-cli/register.cron-scratch.test.ts b/src/cli/cron-cli/register.cron-scratch.test.ts index 8b993de3943f..fa075a57385c 100644 --- a/src/cli/cron-cli/register.cron-scratch.test.ts +++ b/src/cli/cron-cli/register.cron-scratch.test.ts @@ -1,6 +1,6 @@ // Cron scratch register tests cover cron scratch command option validation. import { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultRuntime } from "../../runtime.js"; const callGatewayFromCli = vi.fn(); @@ -38,6 +38,38 @@ describe("cron scratch command", () => { }); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.each([ + ["without --json", ["--set", "new note"]], + ["with --json", ["--unset", "--json"]], + ])("prints the write result as JSON %s", async (_label, args) => { + const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const writeJson = vi.spyOn(defaultRuntime, "writeJson").mockImplementation(() => {}); + + await createCronProgram().parseAsync(["scratch", "job-1", ...args], { from: "user" }); + + expect(writeJson).toHaveBeenCalledWith({ + ok: true, + scratch: null, + currentRevision: 3, + maxBytes: 1024, + }); + expect(stdoutWrite).not.toHaveBeenCalled(); + }); + + it("documents the read/write JSON split", () => { + const scratch = createCronProgram().commands.find((command) => command.name() === "scratch"); + const jsonOption = scratch?.options.find((option) => option.long === "--json"); + + expect(jsonOption?.description).toBe( + "Output scratch plus revision metadata as JSON; writes return JSON by default", + ); + expect(jsonOption?.defaultValue).toBeUndefined(); + }); + it.each(["0x2", "1e2", "2.5", "-1", "2a"])( "rejects non-decimal --expected-revision %j", async (revision) => { @@ -72,6 +104,7 @@ describe("cron scratch command", () => { ])( "passes decimal --expected-revision %j through to the CAS write", async (revision, expectedRevision) => { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); await createCronProgram().parseAsync( ["scratch", "job-1", "--set", "x", "--expected-revision", revision], { from: "user" }, diff --git a/src/cli/cron-cli/register.cron-scratch.ts b/src/cli/cron-cli/register.cron-scratch.ts index 622798b6032a..1ba9e836ac61 100644 --- a/src/cli/cron-cli/register.cron-scratch.ts +++ b/src/cli/cron-cli/register.cron-scratch.ts @@ -2,6 +2,7 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/numb // Cron scratch CLI: private per-job prompt context reads and compare-and-swap writes. import type { Command } from "commander"; import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; +import { createCronOutputCommand } from "./output-mode.js"; import { handleCronCliError, printCronJson } from "./shared.js"; import { readCronScratchContent } from "./trigger-options.js"; @@ -28,15 +29,13 @@ function parseExpectedRevision(value: string | undefined): number | undefined { export function registerCronScratchCommand(cron: Command) { addGatewayClientOptions( - cron - .command("scratch") + createCronOutputCommand(cron, "scratch") .description("Read or replace an automation's private scratch") .argument("", "Job id") .option("--set ", "Replace scratch with exact text") .option("--file ", "Replace scratch from a file, or - for stdin") .option("--unset", "Remove the scratch row", false) .option("--expected-revision ", "Require the current scratch revision") - .option("--json", "Output JSON", false) .action(async (id, opts) => { try { const mutations = [ diff --git a/src/cli/cron-cli/register.cron-simple.test.ts b/src/cli/cron-cli/register.cron-simple.test.ts index aa0f6a34ecc0..d7c3f3396d32 100644 --- a/src/cli/cron-cli/register.cron-simple.test.ts +++ b/src/cli/cron-cli/register.cron-simple.test.ts @@ -15,6 +15,8 @@ vi.mock("../gateway-rpc.js", async () => { }; }); +const { isCronMachineOutput } = await import("./output-mode.js"); +const { registerCronCli } = await import("./register.js"); const { registerCronSimpleCommands } = await import("./register.cron-simple.js"); const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); @@ -65,6 +67,60 @@ function restoreStderrIsTTY(): void { } } +function createRegisteredCronCommand(): Command { + const program = new Command().name("openclaw"); + registerCronCli(program); + const cron = program.commands.find((command) => command.name() === "cron"); + if (!cron) { + throw new Error("cron command was not registered"); + } + return cron; +} + +describe("cron machine-output help", () => { + it.each([ + { name: "status", aliases: [] }, + { name: "add", aliases: ["create"] }, + { name: "rm", aliases: ["remove", "delete"] }, + { name: "enable", aliases: [] }, + { name: "disable", aliases: [] }, + { name: "get", aliases: [] }, + { name: "runs", aliases: [] }, + { name: "run", aliases: [] }, + { name: "edit", aliases: [] }, + ])("documents $name as always-JSON machine output", ({ name, aliases }) => { + const command = createRegisteredCronCommand().commands.find((candidate) => + [candidate.name(), ...candidate.aliases()].includes(name), + ); + const jsonOption = command?.options.find((option) => option.long === "--json"); + + expect(command?.aliases()).toEqual(aliases); + expect(jsonOption?.description).toBe( + "Explicit machine-output spelling (command results are JSON by default)", + ); + expect(jsonOption?.defaultValue).toBeUndefined(); + for (const commandName of [name, ...aliases]) { + expect(isCronMachineOutput(["node", "openclaw", "cron", commandName])).toBe(true); + } + }); + + it("keeps registered command output declarations aligned with early stdout routing", () => { + const cron = createRegisteredCronCommand(); + for (const command of cron.commands) { + const jsonOption = command.options.find((option) => option.long === "--json"); + const alwaysJson = + jsonOption?.description === + "Explicit machine-output spelling (command results are JSON by default)"; + const reservesMachineOutput = command.name() === "scratch" || alwaysJson; + for (const commandName of [command.name(), ...command.aliases()]) { + expect(isCronMachineOutput(["node", "openclaw", "cron", commandName]), commandName).toBe( + reservesMachineOutput, + ); + } + } + }); +}); + describe("cron show pagination guard (regression for #83856)", () => { beforeEach(() => { callGatewayFromCli.mockReset(); diff --git a/src/cli/cron-cli/register.cron-simple.ts b/src/cli/cron-cli/register.cron-simple.ts index cce22319e47a..84aabee23992 100644 --- a/src/cli/cron-cli/register.cron-simple.ts +++ b/src/cli/cron-cli/register.cron-simple.ts @@ -12,6 +12,7 @@ import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js"; import { parseDurationMs } from "../parse-duration.js"; import { parseTimeoutMs } from "../parse-timeout.js"; import { findCronJobByIdOrName } from "./list-jobs.js"; +import { createCronOutputCommand } from "./output-mode.js"; import { enrichCronJsonWithStatus, handleCronCliError, @@ -101,8 +102,7 @@ function registerCronToggleCommand(params: { enabled: boolean; }) { addGatewayClientOptions( - params.cron - .command(params.name) + createCronOutputCommand(params.cron, params.name) .description(params.description) .argument("", "Job id") .action(async (id, opts) => { @@ -127,13 +127,9 @@ function registerCronToggleCommand(params: { export function registerCronSimpleCommands(cron: Command) { addGatewayClientOptions( - cron - .command("rm") - .alias("remove") - .alias("delete") + createCronOutputCommand(cron, "rm") .description("Remove an automation") .argument("", "Job id") - .option("--json", "Output JSON", false) .action(async (id, opts) => { try { const res = await callGatewayFromCli("cron.remove", opts, { id }); @@ -158,11 +154,9 @@ export function registerCronSimpleCommands(cron: Command) { }); addGatewayClientOptions( - cron - .command("get") + createCronOutputCommand(cron, "get") .description("Get an automation as JSON") .argument("", "Job id") - .option("--json", "Output JSON", false) .action(async (id, opts) => { try { const res = await callGatewayFromCli("cron.get", opts, { id: String(id) }); @@ -199,11 +193,9 @@ export function registerCronSimpleCommands(cron: Command) { ); addGatewayClientOptions( - cron - .command("runs") + createCronOutputCommand(cron, "runs") .description("Show automation run history") .requiredOption("--id ", "Job id") - .option("--json", "Output JSON", false) .option("--run-id ", "Filter by cron run id") .option("--limit ", "Max entries (default 50)", "50") .action(async (opts) => { @@ -229,8 +221,7 @@ export function registerCronSimpleCommands(cron: Command) { ); addGatewayClientOptions( - cron - .command("run") + createCronOutputCommand(cron, "run") .description("Run an automation now (debug)") .argument("", "Job id") .option("--due", "Run only when due (default behavior in older versions)", false) diff --git a/src/cli/gateway-cli/register-restart-handoff.test.ts b/src/cli/gateway-cli/register-restart-handoff.test.ts index 17a1848e9246..9c12804a6430 100644 --- a/src/cli/gateway-cli/register-restart-handoff.test.ts +++ b/src/cli/gateway-cli/register-restart-handoff.test.ts @@ -42,6 +42,21 @@ describe("gateway restart-handoff commands", () => { expect(gateway.helpInformation()).not.toContain("restart-handoff"); }); + it("documents restart-handoff output as unconditionally JSON", () => { + const program = new Command(); + const gateway = program.command("gateway"); + addGatewayRestartHandoffCommands(gateway); + const restartHandoff = gateway.commands.find((command) => command.name() === "restart-handoff"); + + for (const command of restartHandoff?.commands ?? []) { + const jsonOption = command.options.find((option) => option.long === "--json"); + expect(jsonOption?.description, command.name()).toBe( + "Explicit machine-output spelling (command results are JSON by default)", + ); + expect(jsonOption?.defaultValue, command.name()).toBeUndefined(); + } + }); + it("reports protocol version 1 capabilities", async () => { await runRegisteredCli({ register: registerGatewayRestartHandoffCli, diff --git a/src/cli/gateway-cli/register-restart-handoff.ts b/src/cli/gateway-cli/register-restart-handoff.ts index 2e17b1d94344..141089ff3bb9 100644 --- a/src/cli/gateway-cli/register-restart-handoff.ts +++ b/src/cli/gateway-cli/register-restart-handoff.ts @@ -7,6 +7,7 @@ import { GATEWAY_RESTART_HANDOFF_PROTOCOL_VERSION, } from "../../infra/restart-handoff-contract.js"; import { defaultRuntime } from "../../runtime.js"; +import { MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION } from "../machine-output-argv.js"; function writeRestartHandoffError(reason: "invalid-expected-pid" | "store-unavailable") { defaultRuntime.writeJson({ @@ -28,7 +29,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void { restartHandoff .command("capabilities") .description("Report the gateway restart-handoff machine contract") - .option("--json", "Output JSON", false) + .option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION) .action(() => { defaultRuntime.writeJson({ ok: true, @@ -43,7 +44,7 @@ export function addGatewayRestartHandoffCommands(gateway: Command): void { .allowUnknownOption() .allowExcessArguments() .option("--expected-pid [pid]", "PID of the exited gateway process", collectExpectedPid, []) - .option("--json", "Output JSON", false) + .option("--json", MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION) .action(async (opts, command: Command) => { const expectedPidValues = Array.isArray(opts.expectedPid) ? opts.expectedPid : []; const expectedPid = diff --git a/src/cli/machine-output-argv.ts b/src/cli/machine-output-argv.ts index 042aa7a94780..cd26494ae04d 100644 --- a/src/cli/machine-output-argv.ts +++ b/src/cli/machine-output-argv.ts @@ -10,6 +10,9 @@ export type MachineOutputResolverParams = { export type MachineOutputResolver = (params: MachineOutputResolverParams) => boolean; +export const MACHINE_OUTPUT_JSON_OPTION_DESCRIPTION = + "Explicit machine-output spelling (command results are JSON by default)"; + /** Normalize Node's absent `isTTY` property to the public resolver's boolean contract. */ export function isMachineOutputStdoutTTY( stdout: { readonly isTTY?: boolean } = process.stdout, diff --git a/src/cli/machine-output-modes.test.ts b/src/cli/machine-output-modes.test.ts index 31207c5aefbf..99db184157d4 100644 --- a/src/cli/machine-output-modes.test.ts +++ b/src/cli/machine-output-modes.test.ts @@ -86,6 +86,12 @@ describe("built-in machine-output resolvers", () => { it("reserves raw cron scratch output", () => { expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true); + expect( + isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--set", "note"]), + ).toBe(true); + expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job", "--unset"])).toBe( + true, + ); }); it.each(["get", "file", "schema"])("reserves config %s machine output", (subcommand) => { diff --git a/src/cli/program/root-command-descriptions.test.ts b/src/cli/program/root-command-descriptions.test.ts index 733c4feb1166..fc1e0cc869d8 100644 --- a/src/cli/program/root-command-descriptions.test.ts +++ b/src/cli/program/root-command-descriptions.test.ts @@ -187,10 +187,6 @@ const JSON_NOT_APPLICABLE = { "fleet restart", "fleet upgrade", "fleet rm", - "cron enable", - "cron disable", - "cron run", - "cron edit", "dns setup", "proxy purge", "pairing approve",