From 6b3ed40d0f8b5480fed0fc76c76a07cf182ab31c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 8 Jan 2026 01:16:50 +0100 Subject: [PATCH] feat: format cron list output --- CHANGELOG.md | 1 + docs/cli/index.md | 2 +- src/cli/cron-cli.ts | 141 +++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 142 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a9b22f4..fd2ae52a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ ### Fixes - Discord: format slow listener logs in seconds to match shared duration style. +- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`). - CLI: add cron `create`/`remove`/`delete` aliases for job management. - Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418) - Signal: reconnect SSE monitor with abortable backoff; log stream errors. Thanks @nexty5870 for PR #430. diff --git a/docs/cli/index.md b/docs/cli/index.md index 5e90b1dff..146d834b3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -502,7 +502,7 @@ Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cro Subcommands: - `cron status [--json]` -- `cron list [--all] [--json]` +- `cron list [--all] [--json]` (table output by default; use `--json` for raw) - `cron add` (alias: `create`; requires `--name` and exactly one of `--at` | `--every` | `--cron`, and exactly one payload of `--system-event` | `--message`) - `cron edit ` (patch fields) - `cron rm ` (aliases: `remove`, `delete`) diff --git a/src/cli/cron-cli.ts b/src/cli/cron-cli.ts index f65a1cae6..072676121 100644 --- a/src/cli/cron-cli.ts +++ b/src/cli/cron-cli.ts @@ -1,6 +1,8 @@ +import chalk from "chalk"; import type { Command } from "commander"; import { danger } from "../globals.js"; import { defaultRuntime } from "../runtime.js"; +import type { CronJob, CronSchedule } from "../cron/types.js"; import type { GatewayRpcOpts } from "./gateway-rpc.js"; import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js"; @@ -59,6 +61,138 @@ function parseAtMs(input: string): number | null { return null; } +const CRON_ID_PAD = 36; +const CRON_NAME_PAD = 24; +const CRON_SCHEDULE_PAD = 32; +const CRON_NEXT_PAD = 10; +const CRON_LAST_PAD = 10; +const CRON_STATUS_PAD = 9; +const CRON_TARGET_PAD = 9; + +const isRich = () => Boolean(process.stdout.isTTY && chalk.level > 0); + +const pad = (value: string, width: number) => value.padEnd(width); + +const truncate = (value: string, width: number) => { + if (value.length <= width) return value; + if (width <= 3) return value.slice(0, width); + return `${value.slice(0, width - 3)}...`; +}; + +const formatIsoMinute = (ms: number) => { + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return "-"; + const iso = d.toISOString(); + return `${iso.slice(0, 10)} ${iso.slice(11, 16)}Z`; +}; + +const formatDuration = (ms: number) => { + if (ms < 60_000) return `${Math.max(1, Math.round(ms / 1000))}s`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`; + return `${Math.round(ms / 86_400_000)}d`; +}; + +const formatSpan = (ms: number) => { + if (ms < 60_000) return "<1m"; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`; + if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h`; + return `${Math.round(ms / 86_400_000)}d`; +}; + +const formatRelative = (ms: number | null | undefined, nowMs: number) => { + if (!ms) return "-"; + const delta = ms - nowMs; + const label = formatSpan(Math.abs(delta)); + return delta >= 0 ? `in ${label}` : `${label} ago`; +}; + +const formatSchedule = (schedule: CronSchedule) => { + if (schedule.kind === "at") return `at ${formatIsoMinute(schedule.atMs)}`; + if (schedule.kind === "every") + return `every ${formatDuration(schedule.everyMs)}`; + return schedule.tz + ? `cron ${schedule.expr} @ ${schedule.tz}` + : `cron ${schedule.expr}`; +}; + +const formatStatus = (job: CronJob) => { + if (!job.enabled) return "disabled"; + if (job.state.runningAtMs) return "running"; + return job.state.lastStatus ?? "idle"; +}; + +const colorize = (rich: boolean, color: (msg: string) => string, msg: string) => + rich ? color(msg) : msg; + +function printCronList(jobs: CronJob[], runtime = defaultRuntime) { + if (jobs.length === 0) { + runtime.log("No cron jobs."); + return; + } + + const rich = isRich(); + const header = [ + pad("ID", CRON_ID_PAD), + pad("Name", CRON_NAME_PAD), + pad("Schedule", CRON_SCHEDULE_PAD), + pad("Next", CRON_NEXT_PAD), + pad("Last", CRON_LAST_PAD), + pad("Status", CRON_STATUS_PAD), + pad("Target", CRON_TARGET_PAD), + ].join(" "); + + runtime.log(rich ? chalk.bold(header) : header); + const now = Date.now(); + + for (const job of jobs) { + const idLabel = pad(job.id, CRON_ID_PAD); + const nameLabel = pad(truncate(job.name, CRON_NAME_PAD), CRON_NAME_PAD); + const scheduleLabel = pad( + truncate(formatSchedule(job.schedule), CRON_SCHEDULE_PAD), + CRON_SCHEDULE_PAD, + ); + const nextLabel = pad( + job.enabled ? formatRelative(job.state.nextRunAtMs, now) : "-", + CRON_NEXT_PAD, + ); + const lastLabel = pad( + formatRelative(job.state.lastRunAtMs, now), + CRON_LAST_PAD, + ); + const statusRaw = formatStatus(job); + const statusLabel = pad(statusRaw, CRON_STATUS_PAD); + const targetLabel = pad(job.sessionTarget, CRON_TARGET_PAD); + + const coloredStatus = (() => { + if (statusRaw === "ok") return colorize(rich, chalk.green, statusLabel); + if (statusRaw === "error") return colorize(rich, chalk.red, statusLabel); + if (statusRaw === "running") + return colorize(rich, chalk.yellow, statusLabel); + if (statusRaw === "skipped") + return colorize(rich, chalk.gray, statusLabel); + return colorize(rich, chalk.gray, statusLabel); + })(); + + const coloredTarget = + job.sessionTarget === "isolated" + ? colorize(rich, chalk.magenta, targetLabel) + : colorize(rich, chalk.cyan, targetLabel); + + const line = [ + colorize(rich, chalk.cyan, idLabel), + colorize(rich, chalk.white, nameLabel), + colorize(rich, chalk.white, scheduleLabel), + colorize(rich, chalk.gray, nextLabel), + colorize(rich, chalk.gray, lastLabel), + coloredStatus, + coloredTarget, + ].join(" "); + + runtime.log(line.trimEnd()); + } +} + export function registerCronCli(program: Command) { addGatewayClientOptions( program @@ -120,7 +254,12 @@ export function registerCronCli(program: Command) { const res = await callGatewayFromCli("cron.list", opts, { includeDisabled: Boolean(opts.all), }); - defaultRuntime.log(JSON.stringify(res, null, 2)); + if (opts.json) { + defaultRuntime.log(JSON.stringify(res, null, 2)); + return; + } + const jobs = (res as { jobs?: CronJob[] } | null)?.jobs ?? []; + printCronList(jobs, defaultRuntime); } catch (err) { defaultRuntime.error(danger(String(err))); defaultRuntime.exit(1);