launchd, how to run a command every 3 minutes, during working hours on a weekday?

I know cron and that is fairly simple

*/3 9-17 * * 1-5 myCommand

How does one do the same in launchd?


TL;DR Summary: launchd is "better" than cron in many ways, but these sorts of repetitions are an example of one way where cron is easier than launchd. To do this in launchd is excessively verbose. (That link will take you to a gist because it is literally too long to post to StackExchange.)

Keyboard Maestro supports cron-style execution times. If I wanted to run something using cron's time formats, I would definitely use Keyboard Maestro.

There is an extremely helpful and friendly forum available at https://forum.keyboardmaestro.com where users (and the developer of Keyboard Maestro) are available to help.

nohillside made an excellent point that the easiest way to do this in launchd is to run a script every 3 minutes, but check at the beginning of that script whether or not it is within the desired times.

Save the script below as /usr/local/bin/mycommand.sh

#!/usr/bin/env zsh -f
# Purpose: Run a command, but only between certain hours, on certain days
#
# From: Timothy J. Luoma
# Mail: luomat at gmail dot com
# Date: 2021-02-23

    # this assumes your work-day starts at 9am
    # adjust as needed
START_HOUR='9'

    # this assumes that your work day stops at 5pm (1700 hours)
    # adjust as needed
STOP_HOUR='17'

    # adjust if needed
PATH='/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin'

NAME="$0:t:r"

    # needed for 'strftime'
zmodload zsh/datetime

    # this will check the current day of the week
DAY_OF_WEEK=`strftime "%a" "$EPOCHSECONDS"`

    # change the days listed if you
    # take off different days than Saturday or Sunday
case "$DAY_OF_WEEK" in
    Sat|Sun)
            # note that we do not want to use 'exit 1'
            # because `launchd` will interpret that
            # as a reason not to continue to run
            # this script in the future
            # which we do not want

        echo "$NAME: '$DAY_OF_WEEK' is not a work day." >>/dev/stderr
        exit 0
    ;;

esac

    # check what hour it is: 0-23
CURRENT_HOUR=`strftime "%H" "$EPOCHSECONDS"`

    # compare current hour with starting hour
if [[ "$CURRENT_HOUR" -lt "$START_HOUR" ]]
then
        echo "$NAME: '$CURRENT_HOUR' is before the start of the work day ($START_HOUR)." >>/dev/stderr
        exit 0

    # compare current hour with stopping hour
elif [[ "$CURRENT_HOUR" -gt "$STOP_HOUR" ]]
then
        echo "$NAME: '$CURRENT_HOUR' is after the end of the work day ($START_HOUR)." >>/dev/stderr
        exit 0
fi

##################################################################
###
### If we get here, it is between the appropriate hours on the appropriate day,
### and the commands below here should be executed.
###

echo "Hello World"

exit 0

Make sure the script is executable:

chmod 755 /usr/local/bin/mycommand.sh

Then save this (below) as ~/Library/LaunchAgents/local.mycommand.plist (you can call it whatever you want, just make sure that it ends with .plist. Also, you may have to create that folder if it does not exist).

<?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>local.mycommand</string>
    <key>Program</key>
    <string>/usr/local/bin/mycommand.sh</string>
    <key>RunAtLoad</key>
    <true/>
    <key>StandardErrorPath</key>
    <string>/tmp/local.mycommand.stderr.txt</string>
    <key>StandardOutPath</key>
    <string>/tmp/local.mycommand.stdout.txt</string>
    <key>StartInterval</key>
    <integer>180</integer>
</dict>
</plist>

Give it sane permissions:

chmod 644 ~/Library/LaunchAgents/local.mycommand.plist

Then either log out/log in, or just run this command:

launchctl load ~/Library/LaunchAgents/local.mycommand.plist

Errors that the script generate will be saved to /tmp/local.mycommand.stderr.txt

Other output will be saved to /tmp/local.mycommand.stdout.txt

You can change that by editing the paths in the .plist.

Conclusion

Keyboard Maestro is one of the best apps ever made for the Mac, and I think anyone who wants to do any kind of automation on the Mac should own it. This is a perfect example of just one way that it makes things easier than using cron (because it's easier to debug) or launchd because it's easier to use and invoke it when you want, even for power users who know things like launchd and cron.

The only regret I've ever had about Keyboard Maestro was not starting to use it sooner in my life. (I'm just a user of the app, not connected to it in any other way, and I {GLADLY!} pay for the app out of my own pocket, so I'm not getting paid anything by anyone to say anything.)


The proper way to implement timed jobs in launchd is the key StartCalendarInterval. Unfortunately the syntax is rather verbose:

    <key>StartCalendarInterval</key>
    <array>
        <dict>
            <key>Hour</key>
            <integer>9</integer>
            <key>Minute</key>
            <integer>0</integer>
            <key>Weekday</key>
            <integer>1</integer>
        </dict>
        <dict>
            <key>Hour</key>
            <integer>9</integer>
            <key>Minute</key>
            <integer>3</integer>
            <key>Weekday</key>
            <integer>1</integer>
        </dict>
        ...
    </array>

In your specific case this amounts to 900 dict entries.

The launchd GUI LaunchControl generates this configuration from cron-style time specifications:

enter image description here

It also allows for importing/exporting of jobs from/to crontab.