cronfish 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +541 -0
  3. package/examples/.cronfish.json +17 -0
  4. package/examples/README.md +28 -0
  5. package/examples/disk-space.sh +25 -0
  6. package/examples/healthcheck.ts +27 -0
  7. package/examples/hello.md +18 -0
  8. package/examples/one-time/cleanup.ts +25 -0
  9. package/examples/one-time/reminder.md +22 -0
  10. package/package.json +44 -0
  11. package/src/alerts/dispatch.ts +134 -0
  12. package/src/alerts/index.ts +21 -0
  13. package/src/alerts/registry.ts +34 -0
  14. package/src/alerts/safe.ts +26 -0
  15. package/src/alerts/shell.ts +63 -0
  16. package/src/alerts/slack.ts +98 -0
  17. package/src/alerts/types.ts +34 -0
  18. package/src/cli.ts +1097 -0
  19. package/src/db.ts +365 -0
  20. package/src/frontmatter.ts +654 -0
  21. package/src/jobs.ts +536 -0
  22. package/src/models.ts +54 -0
  23. package/src/oneTime.ts +259 -0
  24. package/src/parsers/friendly.ts +53 -0
  25. package/src/platform/index.ts +14 -0
  26. package/src/platform/launchd.ts +564 -0
  27. package/src/prune.ts +155 -0
  28. package/src/result.ts +111 -0
  29. package/src/runner.ts +827 -0
  30. package/src/schedule.ts +188 -0
  31. package/src/state.ts +55 -0
  32. package/src/ts-shim.ts +44 -0
  33. package/src/ui/server.ts +469 -0
  34. package/src/watchdog.ts +201 -0
  35. package/templates/_examples/one-time/echo-at.md +10 -0
  36. package/templates/plist.template +35 -0
  37. package/ui/README.md +73 -0
  38. package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  39. package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  40. package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  41. package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  42. package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  43. package/ui/dist/assets/index-DNE046Zp.js +9 -0
  44. package/ui/dist/assets/index-DmWTmu9X.css +2 -0
  45. package/ui/dist/favicon.svg +1 -0
  46. package/ui/dist/icons.svg +24 -0
  47. package/ui/dist/index.html +14 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dan Caddigan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,541 @@
1
+ <p align="center">
2
+ <img src="assets/cronfish-mascot.png" alt="cronfish mascot" width="240">
3
+ </p>
4
+
5
+ <h1 align="center">cronfish ๐ŸŸ</h1>
6
+
7
+ <p align="center"><strong>A file-based job scheduler for macOS. Drop a script in a folder; launchd runs it on a schedule.</strong></p>
8
+
9
+ <p align="center">
10
+ <a href="https://github.com/goldcaddy77/cronfish/actions/workflows/ci.yml"><img src="https://github.com/goldcaddy77/cronfish/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
11
+ <img src="https://img.shields.io/badge/runtime-Bun%20%E2%89%A5%201.0-black?logo=bun" alt="Bun >= 1.0">
12
+ <img src="https://img.shields.io/badge/platform-macOS-lightgrey?logo=apple" alt="macOS">
13
+ <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License"></a>
14
+ </p>
15
+
16
+ Cronfish is for personal automation โ€” the small scheduled jobs you'd otherwise hand-roll as
17
+ launchd plists or crontab lines. Drop a `.md` (Claude prompt), `.ts` (Bun program), or `.sh`
18
+ (bash script) in `cron/`, run `cronfish sync`, and launchd takes it from there.
19
+
20
+ It makes the file the job: frontmatter is the schedule, the path is the slug โ€” no hand-written
21
+ plists, no registration step. You also get per-run logs, retries, concurrency guards, failure
22
+ alerts, fire-once jobs, and a dashboard. See [`examples/`](./examples) for a copy-pasteable job of
23
+ every kind.
24
+
25
+ <p align="center">
26
+ <img src="assets/cronfish-dashboard.png" alt="cronfish dashboard โ€” the jobs view, showing each job's schedule, last-run status, and timing" width="900">
27
+ </p>
28
+
29
+ <p align="center"><em>The built-in dashboard โ€” <code>cronfish ui</code>.</em></p>
30
+
31
+ ## Why cronfish?
32
+
33
+ Claude's native scheduled agents are the obvious alternative, and for pure-LLM tasks they're great
34
+ โ€” zero setup, nothing to host. But everything runs on Anthropic's managed runtime: you pay their
35
+ rates, and every job is a Claude job. Cronfish trades that convenience for control:
36
+
37
+ - **Any job, not just LLM jobs.** Most scheduled work is plain bash or a TypeScript script โ€” a
38
+ backup, a sync, a healthcheck. Cronfish runs those first-class, alongside `.md` Claude prompts.
39
+ - **Any harness.** `.md` jobs shell out to a CLI you choose โ€” Claude Code by default, or another
40
+ agent runner โ€” so you're not locked to one model or one vendor.
41
+ - **Local, so you control the bill.** Jobs run on your machine against whatever model you point
42
+ them at (hosted Claude, a local Ollama model, a LAN LiteLLM box). No managed-runtime markup, and
43
+ your data stays on your hardware.
44
+
45
+ Reach for Claude's scheduler when you want zero-ops and a single LLM task. Reach for cronfish when
46
+ you have a mix of scripts and agents, care about cost, or want to choose the model.
47
+
48
+ ## Quickstart
49
+
50
+ ```bash
51
+ bun add cronfish # or `bun add file:../cronfish` for local dev
52
+ bunx cronfish init # scaffolds starter jobs in cron/ (disabled)
53
+ bunx cronfish enable hello-md # flip on, sync to launchd
54
+ bunx cronfish list # see what's scheduled and what's loaded
55
+ ```
56
+
57
+ ## Where jobs live
58
+
59
+ `cron/` is a tree, not a flat directory. Any `.md`, `.ts`, or `.sh` file at any depth is a job.
60
+
61
+ The slug encodes the kind: the path relative to `cron/` has its trailing `.<ext>` rewritten
62
+ to `-<ext>`, so:
63
+
64
+ - `cron/email/triage.ts` โ†’ slug `email/triage-ts`
65
+ - `cron/hello.md` โ†’ slug `hello-md`
66
+ - `cron/obsidian-keepalive.sh` โ†’ slug `obsidian-keepalive-sh`
67
+
68
+ This means `foo.md` and `foo.sh` can coexist without colliding. Use folders to group related
69
+ crons (`cron/email/`, `cron/linkedin/`).
70
+
71
+ One reserved filename: `README.md`. A file named exactly `README.md` is ignored at any depth, so
72
+ you can document a folder of crons without the README getting parsed as a job.
73
+
74
+ ## Job spec
75
+
76
+ ### Markdown โ€” `cron/<slug>.md`
77
+
78
+ ```markdown
79
+ ---
80
+ schedule: "every 5 minutes" # see below for all accepted shapes
81
+ model: haiku # claude alias | raw ID | local:<name>
82
+ enabled: true # default true
83
+ timeout: 300 # seconds; runner kills past this
84
+ retries: 0 # retry count on non-zero exit
85
+ concurrency: skip # skip | queue
86
+ ---
87
+
88
+ Anything you'd type into a fresh Claude session โ€” tools, files, prompts.
89
+ ```
90
+
91
+ Cronfish shells to `claude --dangerously-skip-permissions --model <id> -p <body>` with `cwd =
92
+ consumer repo root`, so the job inherits your project's `.claude/` config (tools, MCP servers,
93
+ permissions) and your global `~/.claude/`.
94
+
95
+ ### TypeScript โ€” `cron/<slug>.ts`
96
+
97
+ ```ts
98
+ export const config = {
99
+ schedule: "every 10 minutes",
100
+ enabled: true,
101
+ timeout: 540,
102
+ retries: 0,
103
+ concurrency: "skip",
104
+ };
105
+
106
+ export default async function run(): Promise<void> {
107
+ // anything. stdout/stderr captured to the log file.
108
+ }
109
+ ```
110
+
111
+ ### Bash โ€” `cron/<slug>.sh`
112
+
113
+ ```sh
114
+ #!/bin/bash
115
+ # ---
116
+ # schedule: every 5 minutes
117
+ # enabled: true
118
+ # timeout: 30
119
+ # concurrency: skip
120
+ # ---
121
+
122
+ echo "hello from bash"
123
+ ```
124
+
125
+ Config lives in a `# ---` / `# ---` comment block at the top of the file (after the shebang, if
126
+ present). Each inner line is `# key: value` โ€” same scalar rules as Markdown frontmatter. Cronfish
127
+ invokes the file as `/bin/bash <path>` with `cwd = consumer repo root`; stdout/stderr go to the
128
+ per-run log. **A `.sh` file with no frontmatter block fails at discovery** โ€” cronfish prints the
129
+ error in `list`/`sync` so you know to add one.
130
+
131
+ ## `model:` โ€” claude alias, raw ID, or local
132
+
133
+ For Anthropic-hosted models, use the aliases `haiku` / `sonnet` / `opus` (resolve to the latest
134
+ pinned IDs), or pass a raw ID like `claude-sonnet-4-6` verbatim.
135
+
136
+ For a **local model**, prefix with `local:` โ€” e.g. `local:qwen2.5-coder:32b`. Cronfish still
137
+ spawns the same `claude` CLI, but with `ANTHROPIC_BASE_URL` pointed at a local
138
+ Anthropic-Messages-compatible endpoint. Ollama 0.14+ speaks this format natively, so the default
139
+ target is `http://localhost:11434` with auth token `ollama`. The model ID is passed as
140
+ `--model` **and** as the three slot overrides (`ANTHROPIC_DEFAULT_{HAIKU,SONNET,OPUS}_MODEL`)
141
+ plus `CLAUDE_CODE_SUBAGENT_MODEL`, so any sub-agents Claude spawns also route locally.
142
+
143
+ Override the endpoint for LiteLLM, LM Studio, or a LAN box:
144
+
145
+ ```bash
146
+ export CRONFISH_LOCAL_BASE_URL="http://192.168.1.50:4000"
147
+ export CRONFISH_LOCAL_AUTH_TOKEN="sk-litellm-key"
148
+ ```
149
+
150
+ Caveats: small local models (โ‰ค7B) often can't follow Claude Code's tool-heavy system prompt and
151
+ will hallucinate tool calls. Use 14B+ for any agentic loop; 32B is the practical floor for
152
+ multi-step work. Local providers serve one request at a time โ€” set `concurrency: queue` on
153
+ overlapping jobs.
154
+
155
+ ## One-shot jobs โ€” `cron/one-time/`
156
+
157
+ Drop a `.md`, `.ts`, or `.sh` under `cron/one-time/` to schedule a job that
158
+ fires **exactly once** at a `run_at` timestamp, then archives itself. Same
159
+ file format as recurring jobs except `schedule:` is replaced by `run_at:`.
160
+
161
+ ```yaml
162
+ ---
163
+ run_at: 2026-06-25T15:00:00-04:00 # absolute ISO, OR
164
+ run_at: "+30s" # relative to file mtime (s|m|h|d)
165
+ grace_seconds: 300 # optional override; default 300 (5 min)
166
+ ---
167
+ ```
168
+
169
+ Sync behavior:
170
+
171
+ | `run_at` vs. now | What happens |
172
+ | ----------------------------- | ------------------------------------------------- |
173
+ | Future | plist installed with `StartCalendarInterval` for the exact minute |
174
+ | Within `grace_seconds` of now | plist installed with `RunAtLoad: true` โ€” fires on bootstrap |
175
+ | Past `grace_seconds` | **refused**; sentinel written to `cron/.errors/` |
176
+ | `executed_at:` already set | skipped (file should already be archived) |
177
+
178
+ After firing, the runner stamps `executed_at: <ISO>` and moves the file to
179
+ `~/Library/Application Support/cronfish/done/` โ€” outside the repo, so the
180
+ audit trail doesn't bloat git. A `flock` plus the `executed_at` re-check guard
181
+ against double-fires. The next `cronfish sync` removes the orphaned plist.
182
+
183
+ **One-time jobs must be idempotent.** launchd can re-fire on machine restart,
184
+ system unsleep, or load spikes; the flock + `executed_at` guard catches the
185
+ double-fire, but only after the file is stamped. Anything destructive between
186
+ "start" and "stamp" can repeat. Write handlers that tolerate two invocations.
187
+
188
+ **Failure surface โ€” `cron/.errors/`.** Any sync-time refusal (past-grace, bad
189
+ YAML, missing `run_at`) writes a sentinel file there with the slug, timestamp,
190
+ and reason. Runner-side failures (archive failed, executed_at write failed)
191
+ also land here. Wire a heartbeat cron to alert on non-empty.
192
+
193
+ Smoke-test template: `templates/_examples/one-time/echo-at.md`.
194
+
195
+ ## `schedule:` โ€” one key, five shapes
196
+
197
+ | Input | Meaning |
198
+ | ------------------------- | --------------------------------------------- |
199
+ | `"0 9 * * *"` | cron (5 fields, integers or `*`) |
200
+ | `"every 5 minutes"` | human (`every minute`, `every N hours`, etc.) |
201
+ | `60` | bare number โ†’ seconds |
202
+ | `"60s"` / `"5m"` / `"1d"` | compact unit suffix |
203
+ | `"manual"` | no autoschedule; run only via `cronfish run` |
204
+
205
+ `manual` jobs are discovered, validated, and listed, but no plist is installed and no calendar
206
+ fires them. Use it for scheduling candidates โ€” jobs you're staging in `cron/` before flipping on
207
+ a real schedule. Pure on-demand scripts that aren't scheduling candidates belong outside `cron/`.
208
+
209
+ ## Config โ€” `.cronfish.json` (optional, at repo root)
210
+
211
+ ```json
212
+ {
213
+ "bundle_prefix": "com.example.myapp",
214
+ "bun_path": "/opt/homebrew/bin/bun",
215
+ "ui": { "public_url": "https://mini.tail-xxx.ts.net:4747" },
216
+ "alerts": {
217
+ "on_failure": { "notify": "slack" },
218
+ "default": "slack",
219
+ "slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
220
+ "shell": { "command": "/Users/you/bin/cronfish-pushover.sh" }
221
+ }
222
+ }
223
+ ```
224
+
225
+ - **`bundle_prefix`** โ€” launchd plist label prefix; cronfish appends `.<slug>` per job. Defaults to
226
+ `com.cronfish.<basename(cwd)>`.
227
+ - **`bun_path`** โ€” optional absolute path to the `bun` binary baked into every plist's PATH.
228
+ Use when you want to pin a specific install (multiple bun copies, version managers, non-standard
229
+ prefix). When unset, cronfish resolves bun in this order: `$BUN_INSTALL/bin` โ†’ `/opt/homebrew/bin`
230
+ โ†’ `~/.bun/bin` โ†’ `/usr/local/bin` โ†’ `which bun`. Homebrew and the official installer
231
+ (`~/.bun`) work out of the box; for asdf/mise/proto, set `bun_path` explicitly.
232
+ - **`ui.public_url`** โ€” base URL used to build links in alert payloads (e.g. `<base>/runs/<id>`).
233
+ Explicit only; no Tailscale auto-detect.
234
+ - **`alerts`** โ€” see [Alerts](#alerts) below.
235
+
236
+ ## CLI
237
+
238
+ ```
239
+ cronfish init scaffold cron/hello.md + cron/touch.ts + cron/ping.sh + cron/watchdog.sh
240
+ cronfish list every job + state
241
+ cronfish next [slug] [N] preview the next N fire times (default 5)
242
+ cronfish sync reconcile cron/ โ†” launchd (idempotent)
243
+ cronfish enable <slug> flip enabled, then sync
244
+ cronfish disable <slug> flip disabled, then sync
245
+ cronfish delete <slug> --yes bootout + remove plist + job file
246
+ cronfish status [slug] launchctl print + tail of latest log
247
+ cronfish run <slug> invoke runner directly (no launchd) โ€” for testing
248
+ cronfish watchdog detect missed schedules โ†’ fire alerts
249
+ cronfish alerts test [adapter] send a test alert via the named (or default) adapter
250
+ cronfish ui [--port N] [--no-open] local web dashboard (default 127.0.0.1:4747)
251
+ cronfish ui install [--port N] install dashboard as a launchd daemon (auto-restart, runs at login)
252
+ cronfish ui uninstall bootout + remove dashboard daemon
253
+ cronfish ui status show dashboard daemon state
254
+ cronfish --version
255
+ ```
256
+
257
+ ## Alerts
258
+
259
+ Every failed (`fail` / `timeout` / `crashed`) scheduled run pings the configured adapter, and the first `ok` after a failure pings once as `recovered`. Missed schedules are caught by `cronfish watchdog` (scaffolded as `cron/watchdog.sh`, scheduled `every 5 minutes`, disabled by default โ€” flip on after configuring `alerts`).
260
+
261
+ Adapters ship with cronfish:
262
+
263
+ - **`slack`** โ€” POSTs Block Kit to an incoming webhook. Reads the URL from the env var named in `alerts.slack.webhook_url_env` (default `CRONFISH_SLACK_WEBHOOK`).
264
+ - **`shell`** โ€” runs an arbitrary command from `alerts.shell.command` with the payload as env vars (`CRONFISH_ALERT_SLUG`, `โ€ฆ_STATUS`, `โ€ฆ_EXIT_CODE`, `โ€ฆ_DURATION_MS`, `โ€ฆ_STARTED_AT`, `โ€ฆ_UI_URL`, `โ€ฆ_LOG_TAIL`) plus the JSON payload on stdin. Use this for Pushover/ntfy/osascript.
265
+
266
+ Two knobs in `.cronfish.json`, two distinct jobs:
267
+
268
+ - **`alerts.on_failure: { notify: "slack" }`** โ€” fleet-wide default. When set, every scheduled job alerts via that adapter on failure unless its frontmatter says otherwise. When unset, jobs are silent by default.
269
+ - **`alerts.default: "slack"`** โ€” picks which adapter `cronfish alerts test` uses when invoked without an arg. Adapter-selection only; does NOT cause jobs to alert.
270
+
271
+ Per-job overrides via frontmatter:
272
+
273
+ ```yaml
274
+ schedule: "every 5 minutes"
275
+ on_failure:
276
+ notify: slack # opt in / pick a specific adapter for this job
277
+ missed_after: 30m # optional override of the watchdog's grace window
278
+ ```
279
+
280
+ ```yaml
281
+ on_failure:
282
+ notify: none # opt OUT of the fleet default for this job
283
+ ```
284
+
285
+ Resolution order: per-job `notify` (including the `"none"` opt-out) โ†’ `alerts.on_failure.notify` โ†’ no alert (silent skip โ€” recorded in the ledger as `alert_status='skipped'`).
286
+
287
+ Failures inside the adapter never block the run: `alert_status='error'` and `alert_error` capture the reason; stderr gets one line. Manual `cronfish run <slug>` invocations do **not** fire alerts โ€” that's the debugging path.
288
+
289
+ Sanity check:
290
+
291
+ ```
292
+ export CRONFISH_SLACK_WEBHOOK=https://hooks.slack.com/services/...
293
+ cronfish alerts test slack
294
+ ```
295
+
296
+ ## Always-on dashboard
297
+
298
+ `cronfish ui` runs the dashboard in the foreground. To keep it up across reboots and crashes, install it as a launchd daemon:
299
+
300
+ ```
301
+ cronfish ui install # one-time, default port 4747
302
+ cronfish ui status # label + plist + pid
303
+ cronfish ui uninstall # bootout + remove
304
+ ```
305
+
306
+ `install` writes `~/Library/LaunchAgents/<prefix>.ui.plist` with `KeepAlive` + `RunAtLoad`, logs to `<consumer>/.cronfish/logs/ui.log`, and dispatches it via `launchctl bootstrap`. Default bind is `127.0.0.1` (no auth, not exposed). To reach the dashboard from another machine on your LAN, install with `--host 0.0.0.0`:
307
+
308
+ ```
309
+ cronfish ui install --host 0.0.0.0
310
+ ```
311
+
312
+ `ui status` then prints the LAN URL.
313
+
314
+ ## Files cronfish writes
315
+
316
+ ```
317
+ cron/<slug>.{md,ts,sh} # job files (you write these)
318
+ ~/Library/LaunchAgents/<prefix>.<slug>.plist # launchd registration
319
+ <consumer>/.cronfish/logs/<slug>/<id>.log # per-run log
320
+ <consumer>/.cronfish/locks/<slug>/runner.pid # concurrency lock
321
+ <consumer>/.cronfish/db.sqlite # run ledger (history, metrics)
322
+ ```
323
+
324
+ `.cronfish/` is created automatically; add it to `.gitignore`.
325
+
326
+ ### Log retention
327
+
328
+ Per-run logs accumulate forever โ€” on an always-on machine that grows unbounded. `cronfish prune`
329
+ deletes old ones:
330
+
331
+ ```
332
+ cronfish prune # prune every slug per retention config
333
+ cronfish prune <slug> # prune one slug
334
+ cronfish prune --dry-run # show what would go, delete nothing
335
+ cronfish prune --max-age-days 14 # ad-hoc override (ignores config)
336
+ cronfish prune --max-runs 50 # keep only the 50 newest logs per slug
337
+ ```
338
+
339
+ With no config and no flags, `prune` falls back to `max_age_days: 30`. Configure retention in
340
+ `.cronfish.json` to set a policy โ€” and `sync` will then auto-prune on every run:
341
+
342
+ ```json
343
+ {
344
+ "retention": {
345
+ "max_age_days": 30,
346
+ "max_runs": 100,
347
+ "per_slug": {
348
+ "noisy-job-md": { "max_runs": 20 }
349
+ }
350
+ }
351
+ }
352
+ ```
353
+
354
+ `max_age_days` deletes logs older than N days; `max_runs` keeps only the N newest per slug; set both
355
+ and a log is pruned if it fails *either*. A `per_slug` entry fully replaces the global policy for
356
+ that slug. Auto-prune on `sync` is opt-in: it runs only when `retention` is set, so an unconfigured
357
+ repo never silently loses logs. The run ledger (`db.sqlite`) and the dashboard's `ui.log` are left
358
+ untouched โ€” only per-run log files are pruned.
359
+
360
+ ## Retries & concurrency
361
+
362
+ - `retries:` โ€” on non-zero exit, retry up to N more times with exponential backoff (5s, 15s, 45s,
363
+ capped at 60s). Retry lines append to the same log.
364
+ - `concurrency: skip` โ€” if a prior run is still in flight, exit 0 immediately.
365
+ - `concurrency: queue` โ€” poll every 2s for the lock, up to the job's `timeout`.
366
+
367
+ ## Security
368
+
369
+ cronfish runs three kinds of job, and they don't carry the same risk:
370
+
371
+ - **`.ts` and `.sh`** run code *you* wrote โ€” the standard cron trust model.
372
+ Nothing here is more dangerous than the cron line you'd write by hand.
373
+ - **`.md`** runs an agent (Claude Code) that decides for itself which tools to
374
+ call. That's the tier worth fencing, and the controls below are aimed at it.
375
+
376
+ The default for a `.md` job is `--dangerously-skip-permissions` (every tool
377
+ allowed) so existing setups keep working. Treat that as fine for jobs you
378
+ fully trust and dial in the controls โ€” **scope secrets, fence tools, cap
379
+ spend, go read-only** โ€” for anything open-ended or untrusted. For true
380
+ isolation, run the job in a [container](#container-escape-hatch).
381
+
382
+ | Control | Knob | Tier |
383
+ | ----------------------------- | ----------------- | ----------- |
384
+ | Inject only the secrets needed | `env:` | `.md`/`.sh` |
385
+ | Allow only specific tools | `allowed_tools:` | `.md` |
386
+ | Cap dollars per run | `max_cost:` | `.md` |
387
+ | Deny mutating tools | `read_only:` | `.md` |
388
+ | Network egress + filesystem | container | any |
389
+
390
+ ### Secrets in plists
391
+
392
+ At `cronfish sync`, every plist's `EnvironmentVariables` block is populated
393
+ with the consumer's `.env` plus the required keys (`HOME`,
394
+ `CRONFISH_CONSUMER_ROOT`, `PATH`). This is what lets `.md` and `.sh` runs
395
+ (which bypass bun's auto-`.env` loader) reach postgres, Linear, Slack, etc.
396
+
397
+ Required keys win on collision. Quoted values are unquoted; `#` is treated as
398
+ an inline comment only on unquoted values. Re-run `cronfish sync` after
399
+ editing `.env` so the plists pick up the new values.
400
+
401
+ ### Scoped secrets โ€” `env:`
402
+
403
+ By default a job's plist carries the **whole** `.env`. Declare an `env:`
404
+ allowlist in frontmatter to inject only the keys a job actually needs:
405
+
406
+ ```
407
+ ---
408
+ schedule: every 30 minutes
409
+ env: [LINEAR_TOKEN, DATABASE_URL]
410
+ ---
411
+ ```
412
+
413
+ The job sees those two keys, not all forty. `env: []` injects no consumer
414
+ secrets at all; omitting `env:` keeps the full-`.env` default (backward
415
+ compatible). A declared key missing from `.env` is skipped with a warning.
416
+
417
+ This fences the `.md` (Claude Code) and `.sh` tiers, which read secrets from
418
+ the plist block. **`.ts` jobs also read `.env` directly via bun's auto-loader,
419
+ so `env:` does not constrain them** โ€” for a `.ts` job, keep secrets out of
420
+ `.env` or isolate the job in a container.
421
+
422
+ ### Permission fence โ€” `allowed_tools:` (`.md` jobs)
423
+
424
+ By default the Claude Code runner runs with `--dangerously-skip-permissions`
425
+ (every tool allowed). Declare an `allowed_tools:` list to swap that blanket
426
+ bypass for a capability fence:
427
+
428
+ ```
429
+ ---
430
+ schedule: every 30 minutes
431
+ allowed_tools: [Read, "Bash(git status)", mcp__linear__*]
432
+ ---
433
+ ```
434
+
435
+ The run then uses `--permission-mode default --allowedTools <list>`; in
436
+ headless mode any tool **not** on the list can't prompt, so it auto-denies โ€”
437
+ the job never hangs. Omitting `allowed_tools:` keeps the skip-permissions
438
+ default (backward compatible). `.md` jobs only โ€” `.ts`/`.sh` run your own code.
439
+
440
+ ### Budget cap โ€” `max_cost:` (`.md` jobs)
441
+
442
+ Cap the dollars an LLM job may spend in a single run:
443
+
444
+ ```
445
+ ---
446
+ schedule: every 5 minutes
447
+ max_cost: 0.50
448
+ ---
449
+ ```
450
+
451
+ Passed to the CLI as `--max-budget-usd`; the run stops making API calls once
452
+ the cap is hit. Backstops a runaway loop or an LLM quietly billing on a short
453
+ cron. Accepts a fraction (`0.50`) or a whole number (`2`). Unset โ†’ no cap.
454
+
455
+ ### Read-only โ€” `read_only:` (`.md` jobs)
456
+
457
+ "Draft but don't send." Denies the mutating built-in tools so a job can
458
+ read, search, and draft but never edit files or shell out:
459
+
460
+ ```
461
+ ---
462
+ schedule: every morning at 8
463
+ read_only: true
464
+ ---
465
+ ```
466
+
467
+ Passed as `--disallowedTools Write Edit NotebookEdit Bash`, which holds under
468
+ both the skip-permissions default and an `allowed_tools` fence (deny wins on
469
+ overlap). **MCP sends aren't auto-detected** โ€” cronfish can't tell a reading
470
+ MCP tool from a sending one by name, so pair `read_only:` with an
471
+ `allowed_tools:` list to fence Gmail/Linear mutations.
472
+
473
+ ### Container escape hatch
474
+
475
+ The frontmatter controls fence *which tools* a job may call; they don't
476
+ sandbox the filesystem or the network. For an untrusted or wide-open job, run
477
+ it inside an ephemeral container โ€” the only option on macOS that gets you real
478
+ network-egress control. The job file is a plain `.sh` that shells out to
479
+ Docker / [OrbStack](https://orbstack.dev):
480
+
481
+ ```bash
482
+ #!/bin/bash
483
+ # ---
484
+ # schedule: every day at 3am
485
+ # env: [ANTHROPIC_API_KEY, DATABASE_URL]
486
+ # ---
487
+ set -euo pipefail
488
+
489
+ docker run --rm \
490
+ --network cronfish-egress \
491
+ --mount type=bind,src="$PWD/cron/_work",dst=/work \
492
+ --env ANTHROPIC_API_KEY --env DATABASE_URL \
493
+ --memory 1g --cpus 1 \
494
+ my-claude-runner:latest /work/task.md
495
+ ```
496
+
497
+ Each flag earns its place:
498
+
499
+ - `--rm` โ€” the container dies on exit; nothing persists between runs.
500
+ - `--network cronfish-egress` โ€” a Docker network whose policy allows only
501
+ `api.anthropic.com` + your DB, so a compromised job can't phone home.
502
+ - `--mount โ€ฆsrc=cron/_work` โ€” the job sees **only** that directory, not your
503
+ whole repo or home dir.
504
+ - `--env ANTHROPIC_API_KEY --env DATABASE_URL` โ€” forwards just the scoped
505
+ secrets; pair with the `env:` frontmatter so the launchd plist hands the
506
+ wrapper only those keys to begin with.
507
+
508
+ Build the egress network once (then add your allow rules to it):
509
+
510
+ ```bash
511
+ docker network create --internal cronfish-egress
512
+ ```
513
+
514
+ This is opt-in and heavier than the frontmatter knobs โ€” reach for it when a
515
+ job is genuinely untrusted or you need hard network/filesystem boundaries, not
516
+ for everyday jobs.
517
+
518
+ ## How cronfish finds bun
519
+
520
+ Plists invoke `/usr/bin/env bun <runner.ts>`. At `cronfish sync`, cronfish resolves your current
521
+ `bun` binary and bakes its directory into the plist's `PATH` (the `bun_path` config option above
522
+ covers the resolution order and how to pin it). Bun auto-loads `.env` from the consumer root (set
523
+ via plist `WorkingDirectory`), so no shell wrapper is needed.
524
+
525
+ - After `bun upgrade` (in place) or a `brew upgrade bun` (same dir) โ€” no re-sync needed.
526
+ - After moving bun to a different directory โ€” re-run `cronfish sync` so the plist PATH picks up
527
+ the new location.
528
+
529
+ ## Requirements
530
+
531
+ - macOS (launchd). Linux (systemd) and Windows (Task Scheduler) are on the backlog.
532
+ - Bun โ‰ฅ 1.0.
533
+
534
+ ## Status
535
+
536
+ v0.11 โ€” used in production by the author. API may still break before v1. File issues if you hit
537
+ something rough.
538
+
539
+ ## License
540
+
541
+ MIT. See `LICENSE`.
@@ -0,0 +1,17 @@
1
+ {
2
+ "$comment": "Optional. Drop this at your repo root (next to cron/). All keys are optional.",
3
+
4
+ "bundle_prefix": "com.example.myapp",
5
+ "bun_path": "/opt/homebrew/bin/bun",
6
+
7
+ "ui": {
8
+ "public_url": "http://127.0.0.1:4747"
9
+ },
10
+
11
+ "alerts": {
12
+ "on_failure": { "notify": "slack" },
13
+ "default": "slack",
14
+ "slack": { "webhook_url_env": "CRONFISH_SLACK_WEBHOOK" },
15
+ "shell": { "command": "/usr/local/bin/my-notify.sh" }
16
+ }
17
+ }
@@ -0,0 +1,28 @@
1
+ # cronfish examples
2
+
3
+ Copy any of these into your own `cron/` directory, tweak the frontmatter, and run
4
+ `cronfish sync`. They all ship **`enabled: false`** so nothing fires until you flip it on
5
+ with `cronfish enable <slug>`.
6
+
7
+ | File | Kind | Shows |
8
+ | --- | --- | --- |
9
+ | [`hello.md`](./hello.md) | recurring ยท Markdown agent | An agentic Claude job โ€” the whole body is the prompt |
10
+ | [`healthcheck.ts`](./healthcheck.ts) | recurring ยท TypeScript | A deterministic Bun job with typed `config` |
11
+ | [`disk-space.sh`](./disk-space.sh) | recurring ยท Bash | A shell job with a `# ---` frontmatter block |
12
+ | [`one-time/reminder.md`](./one-time/reminder.md) | one-shot ยท Markdown | Fire-once at a `run_at` timestamp |
13
+ | [`one-time/cleanup.ts`](./one-time/cleanup.ts) | one-shot ยท TypeScript | Idempotent one-shot with the flock + `executed_at` pattern |
14
+ | [`.cronfish.json`](./.cronfish.json) | config | Bundle prefix, bun path, UI URL, Slack + shell alerts |
15
+
16
+ ## The 60-second loop
17
+
18
+ ```bash
19
+ cp examples/healthcheck.ts cron/healthcheck.ts # drop a file
20
+ cronfish list # it shows up as `healthcheck-ts` (disabled)
21
+ cronfish enable healthcheck-ts # flip on + sync to launchd
22
+ cronfish next healthcheck-ts # preview the next 5 fire times
23
+ cronfish run healthcheck-ts # run it now, no launchd, for testing
24
+ ```
25
+
26
+ No code change registers a job โ€” the file *is* the registration. See the
27
+ [root README](../README.md) for the full job spec, schedule shapes, alerts, and the
28
+ one-time `run_at` semantics.
@@ -0,0 +1,25 @@
1
+ #!/bin/bash
2
+ # ---
3
+ # schedule: every 6 hours
4
+ # enabled: false
5
+ # timeout: 30
6
+ # concurrency: skip
7
+ # ---
8
+ #
9
+ # Recurring bash job. Config lives in the `# ---` block above (after the shebang).
10
+ # Cronfish invokes this as `/bin/bash <path>` with cwd = your repo root; stdout and
11
+ # stderr go to the per-run log. A `.sh` file with NO frontmatter block fails at
12
+ # discovery on purpose โ€” cronfish surfaces the error in `list`/`sync`.
13
+
14
+ set -euo pipefail
15
+
16
+ # Warn (non-zero exit โ†’ alert) when the root volume crosses this fullness threshold.
17
+ THRESHOLD=90
18
+
19
+ used=$(df -P / | awk 'NR==2 {gsub(/%/,"",$5); print $5}')
20
+ echo "root volume ${used}% used (threshold ${THRESHOLD}%)"
21
+
22
+ if [ "$used" -ge "$THRESHOLD" ]; then
23
+ echo "disk usage ${used}% >= ${THRESHOLD}% โ€” failing so the alert fires" >&2
24
+ exit 1
25
+ fi
@@ -0,0 +1,27 @@
1
+ // Recurring TypeScript job. Cronfish runs this with `bun`, cwd = your repo root,
2
+ // and captures stdout/stderr to the per-run log. Bun auto-loads your `.env`, so
3
+ // process.env.* is populated. Export a `config` and a default async function.
4
+
5
+ export const config = {
6
+ schedule: "every 15 minutes", // human shape; also accepts cron / "15m" / 900
7
+ enabled: false, // flip on with `cronfish enable healthcheck-ts`
8
+ timeout: 30, // seconds
9
+ retries: 1, // retry once on non-zero exit (5s backoff)
10
+ concurrency: "skip" as const, // if a prior run is still going, exit 0
11
+ };
12
+
13
+ // Point this at whatever you want to watch. Override via env without editing the file.
14
+ const TARGET = process.env.HEALTHCHECK_URL ?? "https://example.com";
15
+
16
+ export default async function run(): Promise<void> {
17
+ const started = Date.now();
18
+ const res = await fetch(TARGET, { method: "GET", signal: AbortSignal.timeout(10_000) });
19
+ const ms = Date.now() - started;
20
+
21
+ if (!res.ok) {
22
+ // A non-zero exit marks the run `fail` โ€” your configured alert adapter fires.
23
+ throw new Error(`${TARGET} returned ${res.status} ${res.statusText} (${ms}ms)`);
24
+ }
25
+
26
+ console.log(`ok ${res.status} ${TARGET} ${ms}ms`);
27
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ schedule: "0 9 * * *" # every day at 9:00am (5-field cron, or try "every day")
3
+ model: haiku # haiku | sonnet | opus | a raw ID | local:<name>
4
+ enabled: false # flip on with `cronfish enable hello-md`
5
+ timeout: 120 # seconds; the runner kills the job past this
6
+ retries: 0
7
+ concurrency: skip
8
+ ---
9
+
10
+ You are a tiny daily-greeting job.
11
+
12
+ Append one line to `~/cronfish-hello.log` in this exact format:
13
+
14
+ ```
15
+ <ISO 8601 timestamp> โ€” <a single cheerful sentence, different each day>
16
+ ```
17
+
18
+ Create the file if it does not exist. Do not read or modify anything else. Keep it to one line.