DEV Community

Cover image for I got tired of writing launchd XML by hand, so I built launchd-gen
Vandy Sodanheang
Vandy Sodanheang

Posted on

I got tired of writing launchd XML by hand, so I built launchd-gen

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Drop the schedule down to a coarser interval that's easier to write
  2. Write a script that loops launchctl calls, defeating the purpose of having a declarative scheduler
  3. Use a third-party GUI tool like Lingon or LaunchControl
  4. 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
Enter fullscreen mode Exit fullscreen mode

Then:

launchd-gen --label com.me.my-script "0 9 * * 1-5" /usr/local/bin/my-script
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 of Interval structs 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 and go install instant
  • main.go — the CLI entry point, with a --install flag that writes directly to ~/Library/LaunchAgents/ and a --load flag that chains a launchctl 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-run flag 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
Enter fullscreen mode Exit fullscreen mode

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)