Metadata-Version: 2.4
Name: routinely
Version: 0.1.0
Summary: Install system-native scheduled jobs (launchd agents, systemd user timers) from a declarative, path-free spec in pyproject.toml
Project-URL: Repository, https://github.com/etjones/routinely
Project-URL: Issues, https://github.com/etjones/routinely/issues
Author-email: Evan Jones <evan_t_jones@mac.com>
License-Expression: MIT
License-File: LICENSE
Keywords: cron,launchd,linux,macos,scheduled-jobs,scheduler,systemd,timer
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: System :: Systems Administration
Classifier: Topic :: Utilities
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# Routinely

[![CI](https://github.com/etjones/routinely/actions/workflows/ci.yml/badge.svg)](https://github.com/etjones/routinely/actions/workflows/ci.yml)

**Scheduled jobs that just work — on macOS and Linux, with zero dependencies.**

Routinely turns "run this program every morning at six" into one block in
your `pyproject.toml` and one command. It installs real, native scheduled
jobs — launchd agents on macOS, systemd user timers on Linux — so your job
survives reboots and sleeps the way OS services do, without you ever writing
a plist or a unit file. And because native schedulers fail *silently*,
Routinely ships the debugging kit they forgot: `doctor` catches every known
silent-failure mode before install, `kick` runs your job right now in the
exact environment the scheduler will use, and `status` answers "did it run,
and when's the next one?" identically on both platforms. Pure standard
library — adding Routinely adds nothing else.

## Contents

- [Quickstart](#quickstart)
- [How it works](#how-it-works)
- [Config reference](#config-reference)
- [Schedule syntax](#schedule-syntax) — [phrases](#phrase-forms) · [cron](#cron-form)
- [CLI reference](#cli-reference)
- [`routinely doctor`](#routinely-doctor) — preflight diagnostics
- [`routinely kick`](#routinely-kick--test-the-job-for-real) — test the job for real
- [Why does my job not run?](#why-does-my-job-not-run)
- [launchd vs systemd semantics](#launchd-vs-systemd-same-knob-different-guarantees)
- [What routinely is not](#what-routinely-is-not)
- [Development](#development)
- [Authorship](#authorship)

## Quickstart

1. Add `routinely` to your project (as a dev dependency is fine):

   ```sh
   uv add --dev routinely
   ```

2. Add a job block to your `pyproject.toml`:

   ```toml
   [tool.routinely.digest]
   command  = "python -m digest.main"
   schedule = "daily at 06:00"
   catch_up = true
   ```

3. Install it:

   ```sh
   uv run routinely install
   ```

That's a working daily job. `routinely render` shows the exact unit file
before anything touches your system.

## How it works

launchd expands nothing in a plist: no `~`, no `$HOME`, no environment
variables. Every path must be absolute and literal, so a working plist
necessarily contains `/Users/<you>/...` and can't be committed to a shared
repo. Routinely instead renders the unit at **install time** from the
path-free spec above, resolving paths against the project root, `$HOME`, and
the active interpreter (`sys.executable`) — so the committed config stays
machine-independent, and moving a project just means running
`routinely install` again. Preflight checks run before anything is written,
catching the failure modes launchd never reports.

Requires Python 3.11+. Zero runtime dependencies (stdlib only).

## Config reference

All keys live under `[tool.routinely.<job-name>]`. One block per job;
multiple blocks are fine.

| Key | Required | Default | Meaning |
|---|---|---|---|
| `command` | yes | — | Command line, shell-split. A leading `python`/`python3`/`python3.x` is replaced with the interpreter running `routinely install` — so `uv run` / an active venv "just works". A leading relative path resolves against the project root; a bare command is looked up on `PATH` at install time. |
| `schedule` | yes | — | 5-field cron (`"0 6 * * *"`) or one of four phrase forms. Full grammar: [Schedule syntax](#schedule-syntax). |
| `catch_up` | no | `false` | Run a missed slot when the machine comes back. See the semantics table below — **this is the key whose meaning differs most across platforms.** |
| `label` | no | `local.<project-name>.<job-name>` | Unit identity: launchd label / systemd unit basename. Uninstall works by label even after the project moves. |
| `working_directory` | no | `"."` (project root) | Relative paths resolve against the project root; `~` expands at install time. |
| `stdout`, `stderr` | no | macOS: `~/Library/Logs/<job>/{out,err}.log`; Linux: the journal | Log destinations. Parent directories are created for you. |
| `environment` | no | `{}` | Extra environment variables. Scheduled jobs inherit **no** shell environment; set `PATH` explicitly if your job spawns other tools. |

The **project root** is the git toplevel, or the directory containing
`pyproject.toml` if there's no git repo.

## Schedule syntax

A `schedule` string is either **5-field cron** or one of **four phrase
forms**. The phrases are routinely's own (there is no external standard for
them); this section is their complete definition, and
`src/routinely/schedule.py` is the source of truth. Parsing is
case-insensitive and whitespace-insensitive (`"Daily  at 6:00"` is fine).
Anything that isn't a recognized phrase is parsed as cron; anything invalid
is rejected at `render`/`install` time with an error naming the bad field —
nothing falls through silently.

### Phrase forms

| Phrase | Grammar | Equivalent | Meaning |
|---|---|---|---|
| `"hourly"` | exactly that word | cron `0 * * * *` | at minute 0 of every hour |
| `"daily at HH:MM"` | `HH` = 0–23 (one or two digits), `MM` = exactly two digits 00–59 | cron `MM HH * * *` | once a day at that local time |
| `"weekdays at HH:MM"` | same time rule | cron `MM HH * * 1-5` | Monday–Friday at that local time |
| `"every N <unit>"` | `N` ≥ 1; unit `s`/`sec(s)`/`second(s)`, `m`/`min(s)`/`minute(s)`, `h`/`hr(s)`/`hour(s)`; space before the unit optional (`15m` or `15 minutes`) | — no cron equivalent | an **interval**, not a calendar time |

That's the whole phrase language — there is deliberately no `"monthly"`,
`"every tuesday"`, or natural-language date parsing. Anything beyond these
four shapes is cron's job.

**Calendar vs interval matters.** The first three phrases produce calendar
schedules (launchd `StartCalendarInterval` / systemd `OnCalendar`), which
support catch-up semantics. `every N …` produces an interval (launchd
`StartInterval` / systemd `OnBootSec` + `OnUnitActiveSec`), which fires
"every N since the last run" with no fixed wall-clock alignment — and on
macOS, interval firings missed during sleep are simply lost (see the
semantics table below).

### Cron form

Standard 5 fields — `minute hour day-of-month month day-of-week` — with the
usual constructs per field:

- `*` — any value
- `5` — a single value
- `1,15` — a list
- `9-17` — an inclusive range
- `*/15`, `9-17/2`, `5/20` — steps over a range (`5/20` = from 5 to the
  field max, every 20)

Ranges: minute 0–59, hour 0–23, day 1–31, month 1–12, weekday 0–7 (0 and 7
are both Sunday; numeric only — `mon`/`jan` names are not supported).
Descending ranges (`5-1`) are rejected rather than wrapped.

One classic cron subtlety is preserved on both platforms: if **both**
day-of-month and day-of-week are restricted, the job runs when **either**
matches (`0 6 1,15 * 1` = the 1st, the 15th, *and* every Monday).

All times are local time. There is no timezone field and no seconds field.


## CLI reference

```
routinely install   [name]   # render → validate → install → load. Idempotent.
routinely render    [name] [--platform launchd|systemd]   # print unit(s), touch nothing
routinely doctor    [name] [--json]   # preflight diagnostics (see below)
routinely kick      [name] [--restart]   # run the job NOW, in the real scheduled environment
routinely status    [name] [--json]   # loaded? last exit? next fire? — same view on both platforms
routinely logs      [name] [-f] [-n N]
routinely uninstall [name] [--label LABEL]   # by label; works after the project moves
```

With no `name`, commands operate on every job in the project. Everything is
**user scope** (`~/Library/LaunchAgents`, `systemctl --user`) — no sudo.

`install` is a reinstall when the job already exists (unload → rewrite →
reload), so it's also how you apply config changes. It refuses to install if
any preflight check fails, and tells you what to fix.

**For scripts and agents:** `status --json` and `doctor --json` emit
structured JSON instead of the human formatting. Exit codes are meaningful
everywhere: `doctor` exits 1 if any check fails, `status` exits 1 if any
selected job is not loaded, `install` exits 1 on refusal or error, and all
commands exit 0 on success. Nothing ever prompts interactively, `install` is
safe to retry, and `kick` lets an automated caller verify a job end-to-end
without waiting for its schedule.

## `routinely doctor`

Every check corresponds to a real way launchd jobs die silently:

```
$ routinely doctor
✓ label        com.evanjones.paperdigest
✓ program      .../.venv/bin/paper-digest -> .../.venv/bin/python3
✓ working dir  /Users/…/daily_scholar_digest
✓ log dir      /Users/…/Library/Logs/paper-digest (created)
✓ unit syntax  plutil -lint OK
✓ loaded       yes · last exit 0 · next fire 2026-07-22 06:00
```

- program exists **and is executable** — the #1 cause of a silently-dead agent
- working directory exists
- log parent directories exist (created if missing — launchd won't)
- rendered unit passes `plutil -lint` (macOS) / `systemd-analyze verify` (Linux)
- loaded state, last exit code, next fire time
- Linux: warns if `loginctl` linger is off (user timers stop at logout)
- warns when a cron expression explodes combinatorially on launchd (see below)

## `routinely kick` — test the job for real

Running your command in a terminal proves almost nothing about how it behaves
under the scheduler: your shell has a full `PATH`, your environment variables,
your working directory. The scheduled run has **none** of that — launchd
starts jobs with an empty environment, and "works in my terminal, dies at 6am"
is the classic launch-agent debugging time sink.

`kick` asks the init system itself to run the job immediately
(`launchctl kickstart` / `systemctl --user start`), so it executes with
exactly the scheduled run's environment, working directory, and log
destinations:

```sh
routinely kick          # fire the job now
routinely logs -f       # watch what it did
routinely status        # …and how it exited
```

`--restart` kills a currently-running instance first (`kickstart -k` /
`systemctl restart`); without it, kick only starts the job if it isn't
already running.

Two things kick deliberately does **not** do:

- It doesn't bypass your app's own run policy. If the job internally decides
  "already ran today, nothing to do," a kick runs the process and the process
  declines — which is itself a useful test. Give your app a force flag (e.g.
  `--once`) if you need to override its policy.
- It doesn't simulate the environment by re-spawning the process itself. The
  init system is the only thing that ever runs your job, so what you debug is
  what ships.

The debugging loop, in order:

1. `routinely doctor` — static checks: paths, permissions, syntax, loaded
   state. Catches most silent failures before anything runs.
2. `routinely kick` — dynamic check: does the job actually work under the
   scheduler, right now?
3. `routinely logs` / `routinely status` — what happened and how it exited.

## Why does my job not run?

launchd fails **silently** — no error, no log, the job just never fires. In
rough order of likelihood:

1. **The interpreter path is stale.** You recreated `.venv` (e.g. fresh
   `uv sync`), so the path baked into the installed plist no longer exists.
   Run `routinely doctor` — the program check fails — then
   `routinely install` to re-render against the new interpreter.
2. **You edited config but didn't reinstall.** Neither launchd nor systemd
   watches files: units are read at load time. `routinely install` again.
3. **The machine was asleep or off at the scheduled time** and
   `catch_up = false`. That slot is simply gone. Set `catch_up = true` if a
   late run is better than no run.
4. **Interval schedules (`every 15m`) miss firings during sleep on macOS** —
   per `launchd.plist(5)`, that's inherent to `StartInterval`. Use a calendar
   schedule if catch-up matters.
5. **The job ran but crashed instantly.** `routinely status` shows the last
   exit code; `routinely logs` shows stderr. A common cause: the job's own
   subprocesses need a `PATH` you haven't set in `environment`. Reproduce it
   on demand with `routinely kick` instead of waiting for the next fire.
6. **Linux: you logged out.** `systemctl --user` timers stop at logout unless
   `loginctl enable-linger` is set. `doctor` warns about this.
7. **macOS said "Background Items Added"** and someone clicked it off in
   System Settings → General → Login Items. Re-enable it there.

## launchd vs systemd: same knob, different guarantees

`routinely` maps one spec to both platforms honestly rather than pretending
parity:

| Situation | launchd (macOS) | systemd (Linux) |
|---|---|---|
| Slot missed while **asleep** (calendar) | Fires on next wake; multiple missed slots coalesce into one | `Persistent=true` catches it at next timer evaluation |
| Slot missed while **powered off** (calendar) | Covered only by `RunAtLoad` → runs at next login | `Persistent=true` runs it at next boot (last-trigger time is tracked on disk) |
| Extra runs when nothing was missed | **Yes**: `catch_up` = `RunAtLoad`, which fires at install and at *every* login — launchd keeps no last-run state | No: `Persistent` fires only if a slot was actually missed |
| Slot missed while asleep (interval) | **Lost** — `StartInterval` limitation | Timer resumes; `OnBootSec` restarts it after reboot |
| Schedule expressiveness | Single integers per field: `*/15 9-17 * * 1-5` expands to **180** calendar dicts (`routinely` warns past 24 and suggests an interval) | `OnCalendar` expresses lists/ranges compactly |
| DST / timezone change | Cached next-fire date can be wrong until reload | Recomputed |
| Logs | Files (launchd has no journal) | journald (`routinely logs` wraps `journalctl`) |
| Last exit code in `status` | Reported after every run | **Reported only after failures.** A successful oneshot resets its exec state to the same zeros as a never-ran service (verified live), and the fix — `RemainAfterExit=yes` — would stop the timer and `kick` from re-triggering the job. Success is inferred from the journal (`routinely logs`), not the exit code. |
| Unit syntax preflight | `plutil -lint` (always available) | `systemd-analyze verify --user` needs systemd ≥ 250 (Ubuntu 24.04+); on older hosts `doctor` downgrades the check to a warning |

The practical consequence: **a `catch_up = true` job must tolerate being
started when there's nothing to do** — e.g. "already ran today? exit 0".
That's by design: `routinely` schedules *opportunities to run*; whether a
run is actually due is application state, and only the application can judge
it.

macOS bonus: `install` points the job at a descriptively-named symlink to the
interpreter, so Login Items and `ps` show your job's name instead of
"python".

## What routinely is not

- **Not a run-policy engine** — "at most once per day" logic belongs in your
  app, which owns the state that defines "done".
- **Not a process backgrounder**: no double-forking, no PEP 3143 detaching —
  the init system owns the process.
- **Not a supervisor** and **not a task queue** — see supervisord, Celery et
  al. Scope here is unit-file lifecycle: render, validate, install, inspect,
  remove.

## Development

```sh
just test    # run the test suite
just run …   # run the CLI from source
```

Renderers are pure functions and both are tested on every host platform; no
test touches `launchctl`, `systemctl`, or your `LaunchAgents` directory.
Contributing with an AI agent? Start with [AGENTS.md](https://github.com/etjones/routinely/blob/main/AGENTS.md) — it holds
the invariants and the list of behaviors that look like bugs but aren't.

## Authorship

Routinely was pair-programmed with [Claude Code](https://claude.com/claude-code)
(Claude Fable 5), with every change human-directed and human-reviewed; the
`Co-Authored-By` trailers in the git history mark the AI's hand, and
[CONVERSATION.md](https://github.com/etjones/routinely/blob/main/CONVERSATION.md) is a running log of the collaboration —
including the design arguments and the live findings on real hardware.
Trust, though, should come from the evidence rather than the byline: the
behavior documented here is backed by a unit suite that runs on every
commit, live validation against real launchd and systemd (including the
platform quirks in the semantics table above, several of which were
discovered empirically), and a production job that has been running on this
code throughout its development.
