@devxeazy/cron-cli
v0.1.0
Published
One table for everything scheduled on a Linux box: user crontab, /etc/crontab, /etc/cron.d, run-parts and systemd timers — plus a preflight that says why a job will fail before it fires.
Downloads
191
Maintainers
Readme
cron-cli
Everything scheduled on this machine. One table.
cron-cli is a scheduled-task inventory for Linux. It reads the user crontab,
/etc/crontab, /etc/cron.d, the cron.{hourly,daily,weekly,monthly} run-parts
directories and systemd timers — system and user — and prints them as one
systemctl list-timers-shaped table. It also tells you which of those entries
fire and then do nothing.
S LEFT PASSED RESULT NAME COMMAND
● 1min 53s 8min ago ok sysstat-collect.timer /usr/lib/sysstat/sa1 1 1
✖ 1min 53s 3min ago exit 1 session-cleanup.timer /usr/local/libexec/session-cleanup/prune-expired-session…
≡ 6min 53s - - sysstat:6 debian-sa1 1 1
● 8min 53s - - crontab:19 cd / && run-parts --report /etc/cron.hourly
● 5h 21min 6h ago ok apt-daily.timer /usr/lib/apt/apt.systemd.daily update
≡ 6h 50min - - sysstat:9 debian-sa1 60 2
● 6h 51min 17h ago ok dpkg-db-backup.timer dpkg/dpkg-db-backup
● 6h 51min 17h ago ok logrotate.timer logrotate /etc/logrotate.conf
● 6h 58min 17h ago ok sysstat-summary.timer /usr/lib/sysstat/sa2 -A
≡ 10h 1min - - e2scrub_all:2 SERVICE_MODE=1 /sbin/e2scrub_all -A -r
≡ 12h 49min 11h ago ok launchpadlib-cache-clean.timer find /home/user/.launchpadlib/api.launchpad.net/cache -ty…
● 13h 12min 10h ago ok apt-daily-upgrade.timer /usr/lib/apt/apt.systemd.daily install
● 13h 16min - - crontab:20 cd / && run-parts --report /etc/cron.daily
≡ 13h 16min - - cron.daily/apt-compat /etc/cron.daily/apt-compat
≡ 13h 16min - - cron.daily/dpkg /etc/cron.daily/dpkg
≡ 13h 16min - - cron.daily/logrotate /etc/cron.daily/logrotate
≡ 13h 16min - - cron.daily/man-db /etc/cron.daily/man-db
≡ 13h 16min - - cron.daily/sysstat /etc/cron.daily/sysstat
● 17h 7min 14h ago ok man-db.timer install -d -o man -g man -m 0755 /var/cache/man ; mandb …
● 22h 39min 1h ago ok update-notifier-download.timer /usr/lib/update-notifier/package-data-downloader
● 22h 49min 1h ago ok systemd-tmpfiles-clean.timer systemd-tmpfiles --clean
● 2d 2h - - monarx-update:1 apt-get update -qq && apt-get install -y -qq monarx-agen…
● 2d 6h - - dev:1 backup.sh --to /srv/backups
● 3d 2h 8d ago ok update-notifier-motd.timer /usr/lib/ubuntu-release-upgrader/release-upgrade-motd
● 4d 10h 2d ago ok e2scrub_all.timer /sbin/e2scrub_all
≡ 4d 10h - - e2scrub_all:1 SERVICE_MODE=1 /usr/lib/x86_64-linux-gnu/e2fsprogs/e2scr…
● 4d 13h - - crontab:21 cd / && run-parts --report /etc/cron.weekly
≡ 4d 13h - - cron.weekly/man-db /etc/cron.weekly/man-db
● 5d 6h 1d ago ok fstrim.timer /sbin/fstrim --listed-in /etc/fstab:/proc/self/mountinfo…
● 8d 13h - - crontab:22 cd / && run-parts --report /etc/cron.monthly
30 scheduled entries from 2 sources · 971.6ms
● will run ✖ last run failed ≡ fires, does nothing
! /var/spool/cron/crontabs is mode 1730 root:crontab; other users' crontabs cannot be listed or even counted. Not zero …Real cron-cli list --width=120 output from a stock Ubuntu 24.04 box. Two private
rows were replaced with generic equivalents (session-cleanup.timer, dev:1);
every distro row is untouched.
crontab -l on that machine prints one line. cron-cli prints thirty. Twenty-nine
of the things scheduled on that host are invisible to the command most people reach for.
And eleven of the thirty fire and do nothing. They are the ≡ rows. Ten of them are
cron entries whose scripts open with a /run/systemd/system guard — /etc/cron.daily/logrotate
runs every day, tests whether systemd is booted, and exits 0 without rotating anything,
because logrotate.timer already did. The eleventh is a user timer whose service reports
ConditionResult=no. Edit /etc/cron.daily/logrotate and nothing will happen, forever,
and nothing on the machine will tell you why. cron-cli does, with the file and line
number of the guard it found:
$ cron-cli --json | grep -B3 '/etc/cron.daily/logrotate:4'
"effect": {
"kind": "no-op",
"by": "systemd-guard",
"evidence_ref": "/etc/cron.daily/logrotate:4",1. Why the table looks like that
| Column | What it means |
| --- | --- |
| S | ● will run · ≡ fires, does nothing · ✖ last run failed · ○ disabled · ? state unreadable |
| NEXT / LEFT | Next firing, computed from the Vixie expression or taken from systemd. ~ marks a randomized delay. |
| LAST / PASSED | Last firing. Populated for systemd timers. See §7. |
| RESULT | ok, exit N, or a signal name. systemd timers only, for now. |
| NAME | <file>:<line> for crontab entries, cron.daily/<script> for run-parts, the unit name for timers. |
Columns are dropped in a fixed order as the terminal narrows, so the table stays
readable at 80 columns and uses the space it has at 200. --width=N pins it,
--ascii replaces the glyphs, NO_COLOR is honoured.
There are never bare dashes for unknown values. Every empty cell carries a reason —
not recorded, not visible, pre-boot, unreadable, no occurrence, BAD SCHEDULE —
because "we do not know" and "it never ran" are different facts and a tool that renders
both as - is lying to you.
2. What this is not
If you want a cron TUI with features, use cronboard.
1,454 stars, in homebrew/core as cronboard 1.1.1, last pushed 2026-09-19, Apache-2.0.
It creates, edits and deletes jobs interactively, manages remote hosts over SSH, and it is
better at all of that than cron-cli will be, because that is its product and not this one's.
You would have found it in thirty seconds anyway; saying so first is the only honest move.
cron-cli is a different shape:
- It unifies cron and systemd timers. cronboard manages crontabs. On a modern Debian or Ubuntu host the crontab is a minority of what is scheduled — 1 of 30 on the box above — and the interesting failure (a cron.daily script superseded by a timer) is only visible when you read both and cross-reference them. The only other project that tried this, aligoren/sta, has 3 stars and its last commit was in April 2026.
- It has a preflight.
cron-cli checksays why a job will fail before it fires. See §5. - It installs as nothing. Zero runtime dependencies, Node builtins only, one
node:-prefixed import graph that CI proves on every push. No daemon, no database, no network, no config file. - It is designed to be read by a program.
--jsonis a stable, versioned envelope and the exit codes are a published contract. See §8.
What it deliberately does not do: create, edit or delete jobs inside a TUI; connect over SSH; alert. Those are cronboard's, and they are not on the roadmap.
3. Install
Requires Node.js >= 20.19 and Linux. No other runtime dependency — the files
allowlist in package.json ships bin/, src/ and skills/, and nothing else.
npm install -g @devxeazy/cron-cli
cron-cli listOr without installing anything:
npx @devxeazy/cron-cli listOr from a release, with no npm at all. install.sh verifies SHA256SUMS before it
writes a single byte, installs atomically into ~/.local, refuses to overwrite a file
it did not create, and never edits a shell startup file:
curl -fLO https://github.com/devxeazy/cron-cli/releases/download/v0.1.0/install.sh
curl -fLO https://github.com/devxeazy/cron-cli/releases/download/v0.1.0/SHA256SUMS
sha256sum --check --ignore-missing SHA256SUMS
sh install.shinstall.sh --uninstall removes exactly what it installed and nothing else.
install.sh --help lists --prefix, --force, --from-source and --version.
Or from a checkout, which is how the outputs in this README were produced:
git clone https://github.com/devxeazy/cron-cli.git
cd cron-cli
node bin/cron-cli.mjs listOn Ubuntu, ~/.local/bin is already added to PATH by the default ~/.profile —
but only if the directory existed when the shell started. If cron-cli is not found
after installing, open a new login shell before editing anything. install.sh detects
this and tells you which case you are in; it will not append to .bashrc on your behalf.
4. The verbs
list — the inventory
cron-cli list # the table above
cron-cli list --fast # next-run only; skips the batched systemctl show
cron-cli list --width=120 # pin the width
cron-cli list --json # the full document (see docs/json-schema.md)check — the preflight
Every entry, every finding, exit 2 if anything is broken. Real output from the same box:
$ cron-cli check
crontab:19 cd / && run-parts --report /etc/cron.hourly
✖ CRON013 output goes nowhere: no redirection, MAILTO unset and no MTA installed
dev:1 /usr/local/bin/backup.sh --to /srv/backups
✖ CRON013 output goes nowhere: no redirection, MAILTO unset and no MTA installed
▲ CRON025 */N in the day-of-month field is not "every N days": cron expands it to a fixed day set that restarts each month, so it fires on consecutive days at ~7 month boundaries a year (6 in a leap year).
! /var/spool/cron/crontabs is mode 1730 root:crontab; other users' crontabs cannot be listed or even counted. Not zero -- unknown. Re-run with sudo to include them.
! no MTA on this host (/usr/sbin/sendmail absent), so cron generates every job's output and then discards it. Failures are silent at any privilege level.explain — what a schedule really means
The humanizer describes field sets and never infers a period across a field boundary,
which is how the usual implementation turns @weekly into "daily at 00:00 on Sun":
$ cron-cli explain "@weekly"
@weekly at 00:00 on Sun
next 2026-09-27T00:00:00.000Z
next 2026-10-04T00:00:00.000Z
next 2026-10-11T00:00:00.000Z
next 2026-10-18T00:00:00.000Z
next 2026-10-25T00:00:00.000ZIt refuses to phrase a lie. */10 in the day-of-month field is not "every ten days",
so it is not described as one — it is enumerated, and the collapse at the month boundary
is called out:
$ cron-cli explain "0 0 */10 * *"
0 0 */10 * * at 00:00 on day 1, 11, 21, 31 of the month
next 2026-10-01T00:00:00.000Z
next 2026-10-11T00:00:00.000Z
next 2026-10-21T00:00:00.000Z
next 2026-10-31T00:00:00.000Z
next 2026-11-01T00:00:00.000Z
▲ CRON025 */N in the day-of-month field is not "every N days": cron expands it to a fixed set of days that restarts each month, so it fires on consecutive days at month boundaries.And the single most misunderstood rule in cron gets said out loud, with the man page:
$ cron-cli explain "0 0 13 * 5"
0 0 13 * 5 at 00:00 on day 13 of the month OR on Fri (whichever matches first)
next 2026-09-25T00:00:00.000Z
...
▲ CRON015 day-of-month AND day-of-week are both restricted, so cron runs when EITHER matches (Vixie OR), not both. man 5 crontab: "if both fields are restricted, the command will be run when either field matches the current time".validate — syntax plus the semantic lint
Syntax comes from crontab -n - itself, through stdin. The semantic layer is ours:
$ cron-cli validate '0 3 * * * date +%F > /tmp/stamp'
syntax ok
▲ CRON007 unescaped % truncates the command and sends the rest to stdin. Write \% or put the command in a script. Suggested: date +\%F > /tmp/stampenv and run <id> --dry-run — the runtime cron will actually give a job
$ cron-cli env
SHELL /bin/sh
PATH /usr/bin:/bin
HOME /home/user
LOGNAME dev
USER dev
cwd /home/user
timezone UTC
note cron sources no shell startup file. Your ~/.bashrc, ~/.profile and any PATH you export there do not exist here.run <id> --dry-run prints the exact argv and environment for one managed job, as JSON
with --json. It does not execute anything in v0.1; see the roadmap.
add, remove, enable, disable, undo — the write path
Everything that changes a crontab goes through one funnel, into one managed block, and
text outside that block is copied byte for byte. --dry-run prints the unified diff and
exits 7 without installing:
$ cron-cli add --file ./demo.crontab --schedule "*/10 * * * *" \
--command "/usr/local/bin/reindex.sh" --name reindex --dry-run
--- ./demo.crontab
+++ ./demo.crontab (proposed)
# my own comment, never touched
0 0 */3 * * /usr/local/bin/backup.sh
+
+ # >>> cron-cli:begin (managed by cron-cli; do not hand-edit inside) <<<
+ # cron-cli:job id=cc-3c4bd847f4 name=reindex owner=dev created=2026-09-22T17:04:12.196Z hash=sha256:978a1ae082e2
+ */10 * * * * /usr/bin/flock -n -E 0 /home/user/.local/state/cron-cli/locks/reindex.lock /usr/local/bin/reindex.sh >> /home/user/.local/state/cron-cli/logs/reindex.log 2>&1
+ # >>> cron-cli:end <<<
(--dry-run: nothing was installed)The line it writes is not the line you gave it, and that is the point: an absolute path,
a flock -n -E 0 wrapper because the interval is under fifteen minutes, and a
destination for the output. Three of the most common cron failures, closed by construction.
Every real write re-reads the crontab inside a lock, refuses on an etag mismatch
(--if-match), takes a timestamped backup first, and verifies the read-back afterwards.
cron-cli undo restores the backup; undo --list shows them.
It also refuses work. --force does not override a block:
$ cron-cli add --schedule "@daily" --command 'rm -rf $BUILD_DIR'
⛔ SAFE001 target `$BUILD_DIR` is a bare variable — if it is unset, /bin/sh expands it to nothing and `rm -r` runs against the working directory, which under cron is $HOME
cron-cli: refusing to schedule this command (--force does not override a block)--file <path> and CRON_CLI_CRONTAB_FILE point every read and write at an ordinary
file instead of the real crontab. Use them in tests, in CI, and in anything an agent drives.
ui — the same table, interactively
cron-cli uiArrow keys and PgUp/PgDn to move, enter for a job's detail, / to filter, r to rescan,
? for help, q to quit. It is a reader. Creating, editing and deleting jobs are not
bound to any key, and non-user-crontab sources say so when you try
(cron.d is read-only in v0.1 — only the user crontab is writable). That is deliberate:
in the tool this replaced, unhandled keys in one view fell through to handlers that rewrote
the crontab.
With no arguments and a TTY on both ends, cron-cli starts here; piped or with --json,
it runs list instead.
5. The preflight
This is the reason the project exists.
$ claude --version
2.1.278 (Claude Code)
$ env -i PATH=/usr/bin:/bin HOME=/home/user /bin/sh -c 'claude --version'
/bin/sh: 1: claude: not foundExit 0 in your shell. Exit 127 under cron. Nothing on the machine will tell you, because
this host has no MTA, so cron generates the "command not found" message and discards it.
This is the number one cause of "it works when I run it myself", and cron-cli catches
it before you install the job:
$ cron-cli validate "0 3 * * * claude --version"
syntax ok
▲ CRON013 no output redirection: cron mails the output, and with no MTA it is discarded
▲ CRON001 `claude` not on cron's PATH (it is at /home/user/.local/bin/claude, which is not on cron's PATH=/usr/bin:/bin)The checks, all of them bought with a reproduction against a real cron daemon:
| Code | What it catches |
| --- | --- |
| CRON001 | First token does not resolve on cron's PATH. Tells you where the binary actually is. |
| CRON007 | Relative command path — cron's cwd is $HOME, not where you tested it. Also unescaped %. |
| CRON010 | [[ ]], set -o pipefail, <(...), <<< under SHELL=/bin/sh, which is dash. Exit 127 or 2. |
| CRON011 | &>, {1..3}, $RANDOM, echo -e under dash. These exit 0 and do the wrong thing silently. |
| CRON012 | Unescaped %: cron truncates the command there and sends the rest to stdin, pre-shell. Quoting does not help. |
| CRON013 | Output goes nowhere: no redirection, MAILTO unset, no MTA. Not a failure — the reason every other failure is invisible. |
| CRON015 | Day-of-month and day-of-week both restricted: Vixie runs on either, not both. |
| CRON025 | */N in day-of-month is not a period; the set restarts each month. |
cron-cli check resolves cron's real PATH rather than assuming one. On a host where
cron.service runs /usr/sbin/cron -f -P, -P means the daemon does not set PATH
and children inherit systemd's manager environment — so the effective PATH is
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin, not the compiled-in
/usr/bin:/bin. A checker that hardcodes the classic value invents a false positive
for every binary in /usr/sbin. check reports the resolved value and how it derived it.
See docs/what-cron-cli-cannot-see.md.
6. Exit codes
They are a published contract. Codes may be added; they are never renumbered.
| Code | Name | Meaning |
| --- | --- | --- |
| 0 | ok | Succeeded, nothing to report. |
| 1 | warnings | Succeeded, findings or skipped sources to report. |
| 2 | usage | Bad invocation — unknown verb, unknown flag, missing argument. |
| 3 | notFound | No managed job with that id or name; no backup to restore. |
| 4 | conflict | --if-match mismatch, or the crontab moved mid-write. Nothing was installed. |
| 5 | invalid | Unparseable schedule, or crontab -n rejected the content. |
| 6 | unsupported | No crontab(1), or the crontab could not be read at all. |
| 7 | wouldChange | --dry-run and the write would have changed something. |
| 8 | locked | Another cron-cli write is in progress. |
| 9 | refused | A safety tier blocked it. --force does not override a block. |
cron-cli check is the one verb with an overload: in human mode it exits 2 when any
finding has severity error, which collides with usage. In --json mode the same state
exits 1. Branch on the JSON, not on check's exit code — the full table and the
caveat are in docs/exit-codes.md.
7. What cron-cli cannot see
An inventory that overstates its own coverage is worse than no inventory. The full accounting, with the evidence, is in docs. The two that matter:
cron records invocation and never outcome. LAST and PASSED populate for systemd
timers, because systemd keeps LastTriggerUSec and Result. They do not populate for
cron jobs, because cron does not store the exit status of a job anywhere — not in the
spool, not in a state file, not in syslog. The syslog line says the command started.
This was checked at every privilege level, including with sudo: 491 CRON lines in the
log for this host, zero exit codes among them. So cron-cli prints not recorded rather
than inventing a green tick. cron-cli wrap, in v0.2, is the only way this can change,
because the only place an exit status exists is inside the job's own process.
Other users' crontabs are unreadable, and that is not the same as absent.
$ stat -c '%A %U:%G %a' /var/spool/cron/crontabs
drwx-wx--T root:crontab 1730
$ ls /var/spool/cron/crontabs
ls: cannot open directory '/var/spool/cron/crontabs': Permission deniedMode 1730 means an unprivileged user cannot list the directory, so cron-cli cannot even
count the crontabs it is missing. It says so, in a warning on every run, instead of
quietly reporting a total that looks complete:
/var/spool/cron/crontabsis mode 1730 root:crontab; other users' crontabs cannot be listed or even counted. Not zero -- unknown. Re-run with sudo to include them.
Also not yet covered: anacron and at (v0.3), launchd/macOS (v1.0), Kubernetes
CronJobs and application-level schedulers such as Celery, Sidekiq or BullMQ (never —
different problem).
8. For AI agents
cron-cli was built to be driven by a program as much as by a person.
--jsonon every read verb emits one versioned object on stdout and nothing else:schema_version,generated_at,host,scan(depth, plan, per-enricher timings, which sources were skipped and why),jobs[],notes[], and an explicitsortblock. Sorting is by UTF-16 code unit, neverlocaleCompare, so two runs in two locales diff clean. Documented field by field in docs/json-schema.md.- Unknowns are typed. Every
next_run,last_runandlast_resultcarries a machine-readablereasonand anevidencestring. There is no branch in the codebase that can produce an unexplained null. - Exit codes are a contract — docs/exit-codes.md.
- Writes are reversible and refusable.
--dry-runreturns the diff,--if-matchgives you compare-and-swap, every write leaves a backup, and theconfirmsafety tier exists precisely so an agent must stop and ask a human rather than pass--yesitself. - An agent skill ships in the repo under
skills/cron-cli/and in the npm tarball. It is the same surface, written for a tool-using model.
Always point agent-driven writes at --file <path> or CRON_CLI_CRONTAB_FILE unless a
human has explicitly asked for the real crontab.
Por que
O crontab -l desta máquina mostra 1 entrada; o cron-cli mostra 30, e avisa que
11 delas disparam e não fazem nada porque um timer do systemd já fez o trabalho.
A pergunta "o que está agendado aqui?" não tem uma resposta única no Linux — ela está
espalhada entre o crontab do usuário, /etc/cron.d, os diretórios run-parts e os timers
do systemd — e é exatamente essa resposta, mais o motivo pelo qual um job vai falhar antes
de ele falhar, que esta ferramenta entrega. Documentação em inglês; o resto do projeto
também. Este parágrafo é o resumo, não um espelho traduzido.
Help and contribute
Run cron-cli list on a box you know well and tell us which row is wrong — that is the
most useful bug report this project can get. Support says where to ask.
See Contributing, the roadmap, and the
Code of Conduct. Report vulnerabilities privately through the
security policy.
Docs: exit codes · JSON schema · writing an adapter · what cron-cli cannot see
License
MIT.
