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.
- package/LICENSE +21 -0
- package/README.md +541 -0
- package/examples/.cronfish.json +17 -0
- package/examples/README.md +28 -0
- package/examples/disk-space.sh +25 -0
- package/examples/healthcheck.ts +27 -0
- package/examples/hello.md +18 -0
- package/examples/one-time/cleanup.ts +25 -0
- package/examples/one-time/reminder.md +22 -0
- package/package.json +44 -0
- package/src/alerts/dispatch.ts +134 -0
- package/src/alerts/index.ts +21 -0
- package/src/alerts/registry.ts +34 -0
- package/src/alerts/safe.ts +26 -0
- package/src/alerts/shell.ts +63 -0
- package/src/alerts/slack.ts +98 -0
- package/src/alerts/types.ts +34 -0
- package/src/cli.ts +1097 -0
- package/src/db.ts +365 -0
- package/src/frontmatter.ts +654 -0
- package/src/jobs.ts +536 -0
- package/src/models.ts +54 -0
- package/src/oneTime.ts +259 -0
- package/src/parsers/friendly.ts +53 -0
- package/src/platform/index.ts +14 -0
- package/src/platform/launchd.ts +564 -0
- package/src/prune.ts +155 -0
- package/src/result.ts +111 -0
- package/src/runner.ts +827 -0
- package/src/schedule.ts +188 -0
- package/src/state.ts +55 -0
- package/src/ts-shim.ts +44 -0
- package/src/ui/server.ts +469 -0
- package/src/watchdog.ts +201 -0
- package/templates/_examples/one-time/echo-at.md +10 -0
- package/templates/plist.template +35 -0
- package/ui/README.md +73 -0
- package/ui/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/ui/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/ui/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/ui/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/ui/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/ui/dist/assets/index-DNE046Zp.js +9 -0
- package/ui/dist/assets/index-DmWTmu9X.css +2 -0
- package/ui/dist/favicon.svg +1 -0
- package/ui/dist/icons.svg +24 -0
- 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.
|