If you've ever tried to schedule a recurring job on macOS, you've met launchd. And if you've spent more than five minutes with it, you've probably thought: "Why is this so much harder than crontab?"
This is the story of why I built launchd-gen — a small Go CLI that takes a familiar cron expression and emits a valid macOS launchd property list, ready to drop into ~/Library/LaunchAgents/.
The problem with launchd
launchd is the modern replacement for cron on macOS. It's more powerful (it can react to filesystem events, network changes, system load, etc.), but for the 80% case — "run this script every weekday at 9 AM" — it's significantly more painful than crontab.
Here's what a "run every weekday at 9 AM" looks like in cron:
0 9 * * 1-5 /usr/local/bin/my-script
One line. Five fields. Done.
Here's what the equivalent looks like as a launchd plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.me.my-script</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/my-script</string>
</array>
<key>StartCalendarInterval</key>
<array>
<dict>
<key>Minute</key>
<integer>0</integer>
<key>Hour</key>
<integer>9</integer>
<key>Weekday</key>
<integer>1</integer>
</dict>
<dict>
<key>Minute</key>
<integer>0</integer>
<key>Hour</key>
<integer>9</integer>
<key>Weekday</key>
<integer>2</integer>
</dict>
<dict>
<key>Minute</key>
<integer>0</integer>
<key>Hour</key>
<integer>9</integer>
<key>Weekday</key>
<integer>3</integer>
</dict>
<dict>
<key>Minute</key>
<integer>0</integer>
<key>Hour</key>
<integer>9</integer>
<key>Weekday</key>
<integer>4</integer>
</dict>
<dict>
<key>Minute</key>
<integer>0</integer>
<key>Hour</key>
<integer>9</integer>
<key>Weekday</key>
<integer>5</integer>
</dict>
</array>
</dict>
</plist>
Five times the fields. Forty times the lines. And here's the part that really gets you:
launchd has no concept of ranges
The reason that plist needs five <dict> entries is that launchd's StartCalendarInterval only accepts single integer values per key. There is no way to say "weekdays 1 through 5" in a single dict — you have to expand the range yourself by writing one dict per day.
This is annoying for 1-5. It becomes ridiculous fast:
*/15 9-17 * * 1-5
That's "every 15 minutes during business hours on weekdays" — a perfectly reasonable cron expression. To express it as a launchd plist, you have to write 180 separate <dict> entries, because it's the cartesian product of:
- 4 minute values:
0, 15, 30, 45 - 9 hour values:
9, 10, 11, 12, 13, 14, 15, 16, 17 - 5 weekday values:
1, 2, 3, 4, 5
4 × 9 × 5 = 180.
Nobody types that out by hand. They either:
- Drop the schedule down to a coarser interval that's easier to write
- Write a script that loops
launchctlcalls, defeating the purpose of having a declarative scheduler - Use a third-party GUI tool like Lingon or LaunchControl
- Give up and stay on crontab, which Apple has been quietly trying to deprecate since 2005
I wanted option 5: a small CLI that takes a cron expression and outputs the right plist.
Enter launchd-gen
brew tap VandyTheCoder/tools
brew install --cask launchd-gen
Then:
launchd-gen --label com.me.my-script "0 9 * * 1-5" /usr/local/bin/my-script
That's it. The output is a fully-formed launchd plist on stdout, with all 5 weekday <dict> entries expanded automatically.
To install and load it in one go:
launchd-gen --install --load \
--label com.me.my-script \
--stdout /tmp/my-script.log \
--stderr /tmp/my-script.err \
"0 9 * * 1-5" \
/usr/local/bin/my-script
That writes the plist to ~/Library/LaunchAgents/com.me.my-script.plist and runs launchctl load on it. From cron expression to running scheduled job in one command.
What it supports
The cron parser handles everything you'd expect from standard 5-field crontab:
| Pattern | Example | Means |
|---|---|---|
| Single value | 0 9 * * * |
At 09:00 daily |
| List | 0 9,17 * * * |
At 09:00 and 17:00 daily |
| Range | 0 9 * * 1-5 |
At 09:00 on weekdays |
| Step | */15 * * * * |
Every 15 minutes |
| Range + step | 0 9-17/2 * * * |
At 09:00, 11:00, 13:00, 15:00, 17:00 |
| Combinations | */15 9-17 * * 1-5 |
Every 15 min, business hours, weekdays |
| Shortcuts |
@daily, @hourly, @reboot, @weekly, @monthly, @yearly
|
The shortcuts behave exactly as crontab — @reboot becomes a RunAtLoad: true flag, the rest expand to their equivalent 5-field expressions.
Under the hood
launchd-gen is under 600 lines of production Go (plus ~200 lines of tests), with zero external dependencies. The whole thing is one binary, MIT licensed, and the source is on GitHub.
The interesting bits:
-
internal/cron/parser.go— turns a cron string into a list ofIntervalstructs by computing the cartesian product of the parsed field values -
internal/plist/writer.go— hand-writes the launchd XML rather than depending on a third-party plist library, which keeps the binary tiny andgo installinstant -
main.go— the CLI entry point, with a--installflag that writes directly to~/Library/LaunchAgents/and a--loadflag that chains alaunchctl load
I deliberately avoided pulling in cobra, viper, or any of the other usual Go CLI suspects. For something this small they're overkill, and the standard library's flag package handles it just fine.
Why I built it
I run a small daily-dashboard project on my own infrastructure. It has four scheduled jobs — a news fetcher, a daily AI briefing, an activity log summarizer, and a weekly trade-config optimizer. Each one is a launchd agent, because that's how you do scheduled work on macOS in 2026.
Writing those four plists by hand was the most painful part of the whole project. The actual work the scripts do — fetching, summarizing, posting to a webhook — was easier than getting them on a schedule. That's a smell, and I wrote launchd-gen to fix it.
The four real plists from that project are now used as acceptance fixtures in the launchd-gen test suite, which is the kind of dogfooding I always look for in a tool I'm about to depend on.
What's next
v0.1.0 is shipped and it does what I built it for. The roadmap for v0.2.0:
- Day-of-week names (
MON,TUE,WED, ...) instead of numeric weekdays - A
--dry-runflag that prints the plist without writing it - Reverse mode: convert an existing plist back into a cron expression
- Environment variable injection from a file
If any of those sound useful — or if you want something else entirely — open an issue. Pull requests welcome, MIT licensed, no CLA, no committee.
Try it
brew tap VandyTheCoder/tools
brew install --cask launchd-gen
launchd-gen --help
Source, issues, and releases: github.com/VandyTheCoder/launchd-gen
If you've ever stared at a launchd plist and wondered why something this conceptually simple has to be this verbose — this is for you.
Top comments (0)