Get notified when stuff changes.
Every release ships a binary per target; grab the one matching your machine — checker-aarch64-apple-darwin for Apple
Silicon, checker-x86_64-unknown-linux-gnu or checker-aarch64-unknown-linux-gnu for Linux.
mkdir -p ~/.local/bin
curl -fsSL -o ~/.local/bin/checker https://github.com/cameronmurphy/checker/releases/latest/download/checker-aarch64-apple-darwin
chmod +x ~/.local/bin/checkerchecker self-updateThe newest release for this platform replaces the installed binary. The download is checked against the SHA256SUMS
published with the release and discarded if it doesn't match, the previous binary is kept alongside as
checker.previous, and nothing happens at all when the installed version is already the newest.
Where a checker is running as a service, it does the work itself rather than the CLI
doing it behind its back: the CLI reaches it over a socket beside the config file, so the two can't disagree about which
binary is installed, and it exits once the swap is done so KeepAlive restarts it on the new one. The swap is a rename,
so the running process keeps the binary it started from until that moment.
With nothing running there's nothing to co-ordinate with, so the CLI swaps the binary itself and says so — the new one
is picked up whenever checker next starts. Running from a source checkout it refuses outright, since the binary it would
replace is deno itself.
Checker reads ~/.config/checker/config.yml. Starting it without one writes the example config there and stops, so the
first run is the setup step:
checker # Writes ~/.config/checker/config.yml
vim ~/.config/checker/config.yml # Set up what you want to watch
checker # Starts monitoringThat config runs as it stands, with no credentials to fill in first: it follows the International Space Station and
appends a line to /tmp/checker-notifications.log every time it moves, so a fresh install does something you can see
and nothing you have to clean up. Every other built-in source and destination is in there too, commented out and already
indented where it belongs, so swapping the ISS for what you actually care about is mostly uncommenting. Nothing needs to
be cloned or copied by hand — the binary carries the example. Pointing --config-file somewhere else works the same
way: checker writes it at whatever path it was told to read.
A context pairs the things you watch with where their updates go. Configs don't have to mention them: everything under
sources and destinations belongs to a context named default, which is all most setups need.
Reach for contexts when one group of sources should reach somewhere different from another — say a project's own dependencies driving a Claude Code routine for that project, while general-interest sources only reach your phone:
config:
contexts:
default:
sources:
sheeran:
items: ['Australia']destinations:
pushover: &pushover # An anchor, so other contexts can reuse these credentialstoken: 'your-pushover-token'user_key: 'your-user-key'myapp:
sources:
docker:
items: ['nginx'] # The same plugin can watch different things in each contextdestinations:
pushover: *pushoverclaude_code:
routine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'Use one shape or the other — a config with contexts alongside top-level sources/destinations is rejected.
default is only the name used when contexts is absent, so writing it out explicitly changes nothing, including the
state already on disk.
Each context keeps its own state, so two contexts watching the same thing notify independently. That also means moving a source between contexts re-reads its items as first seen, and it will notify once more from its new context.
Within a context, a source notifies every destination unless it names a subset with destinations.
Each key under sources and destinations names one of them, and by default that name is the plugin's. Add plugin:
to name it something else, which is what lets one plugin back several:
config:
sources:
filepond: # A new stable release opens an upgrade PRplugin: npmitems: ['filepond']destinations: [claude_code]filepond_beta: # A beta is just worth knowing aboutplugin: npmitems: ['filepond@beta']destinations: [pushover]destinations:
pushover:
token: 'your-pushover-token'user_key: 'your-user-key'claude_code:
routine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'The name is what everything else refers to — a source's destinations list, and the errors block below — and
referring to one that isn't configured is rejected rather than quietly notifying nobody. A source's state is stored
under its name too, so renaming a source re-reads its items as first seen, the same as moving it between contexts.
A YAML anchor is the way to share one set of credentials across contexts, and an alias of it doesn't need
plugin: even where the name it lands under isn't the plugin's. The definition the anchor sits on is keyed by the
plugin name, and that travels with it:
config:
contexts:
wrx:
destinations:
claude_code: &claude_code_wrx # Keyed by the plugin, so the alias below knows what it isroutine_id: 'trig_01ABCDEFGHJKLMNOPQRSTUVW'token: 'sk-ant-oat01-...'node:
destinations:
claude_code_wrx: *claude_code_wrx # The same routine, under the name this context usesThat only applies to an alias of a definition keyed by a plugin name. One written out under a name of your own still
needs plugin:, since there's nothing for it to have come from.
Failures anywhere in the daemon — a source that threw, a plugin that failed to load, a config that wouldn't parse — go
to the daemon's log. Adding an errors key to a context sends them somewhere you'll actually see:
config:
contexts:
default:
destinations:
updates:
plugin: log_filepath: '~/Library/Logs/checker-updates.log'failures: # A second log file, so failures aren't buried in the updatesplugin: log_filepath: '~/Library/Logs/checker-errors.log'errors:
destinations: [failures] # Optional, by default notify every destination in the contextmyapp:
errors:
destinations: [pushover]The default context's errors is the fallback: it covers every context that doesn't declare its own, plus the failures
nothing can be attributed to. Above, myapp's failures reach your phone and everything else lands in the log file. Each
failure is reported once, so a source that's been down for a week doesn't notify on every check.
Most destinations announce an update. The script destination acts on one, by running a local command:
config:
sources:
virtualbox:
items: ['latest']destinations: [pushover, vbox_upgrade]destinations:
pushover:
token: 'your-pushover-token'user_key: 'your-user-key'vbox_upgrade:
plugin: scriptcommand: '~/.local/bin/vbox-upgrade.sh'timeout: 2700The message arrives on stdin and in $CHECKER_MESSAGE, the destination's own name is in $CHECKER_DESTINATION, and
{{message}} in any argument is substituted with it. The script's output is inherited rather than captured, so a long
upgrade reports progress into checker's log while it runs.
A zero exit status counts as delivered. Exit non-zero and the source keeps its old value, so the next check hands
the script the same update again rather than losing an upgrade to a download that happened to fail. That retry is per
destination only in the sense that the value is kept — a source whose other destinations succeeded has already been
delivered, so pair a script with pushover and a failed upgrade won't be retried. Give the script its own source entry
if you want both the notification and the retry.
Nothing about the script is run through a shell: command is executed directly, so there's no quoting to get wrong, and
no interpolation of the message anywhere it could be treated as syntax. A script still outliving its timeout is sent
SIGTERM, defaulting to 900 seconds.
Source plugins by default go in ~/.config/checker/plugins/source. Here's an example, sheeran.ts, which is a plugin
that checks whether Ed Sheeran is playing in certain countries any time soon.
importBaseSourcePlugin,{SourceConfigSchema}from'checker/plugins/source';importCaseInsensitiveComparatorfrom'checker/comparator/case-insensitive';import{DOMParser}from'checker/parse';import{z}from'zod';constSheeranConfigSchema=SourceConfigSchema.extend({items: z.array(z.string()).min(1,'Sheeran plugin requires at least one country name'),});typeSheeranConfig=z.infer<typeofSheeranConfigSchema>;exportdefaultclassSheeranSourceextendsBaseSourcePlugin<SheeranConfig>{privatereadonlycomparator=newCaseInsensitiveComparator();publicoverridegetSchema(){returnSheeranConfigSchema;}publicoverrideasyncread(item: string){constresponse=awaitfetch('https://www.edsheeran.com/');consttext=awaitresponse.text();constdoc=newDOMParser().parseFromString(text,'text/html');constlocations=Array.from(doc?.querySelectorAll('.event_location')||[]);constrelevantLocations=locations.filter((el)=>el.textContent?.includes(item));constdates=relevantLocations.map((el)=>el.parentElement?.querySelector('.event_date')?.textContent?.replace(/\s+/g,' ').trim());returndates.filter(Boolean).join(', ');}publicoverrideupdated(before: string,after: string){returnthis.comparator.updated(before,after);}publicoverridemessage(_before: string,after: string,item: string){return`Ed Sheeran is playing in ${item} on ${after}!`;}}The class name determines the default config key: SheeranSource has its Source suffix stripped and the rest
snake-cased, giving sheeran.
Then you would configure this plugin like so:
config:
sources:
sheeran:
items:
- 'Australia'Plugins are loaded at runtime, which means they can only import what checker was built with — a distributed binary has
no module cache to fetch from and no network access at load time. Import from these specifiers rather than jsr: URLs,
and everything resolves offline:
| Specifier | What you get |
|---|---|
checker/plugins/source | BaseSourcePlugin (default), SourceConfigSchema |
checker/plugins/destination | BaseDestinationPlugin (default), DestinationConfigSchema |
checker/comparator | BaseComparator, to write your own |
checker/comparator/case-insensitive | CaseInsensitiveComparator — any change in text, ignoring case |
checker/comparator/debian | DebianComparator — a higher Debian package version, epochs, revisions and ~ included |
checker/comparator/int | IntComparator — a bigger number than last time |
checker/comparator/semver | SemverComparator — a higher version, falling back to inequality for tags that aren't semver |
checker/comparator/strlen | StrlenComparator — the text got longer |
checker/parse | DOMParser, parseXml, parseYaml, parseToml, parseCsv, parseJsonc, unescapeHtml |
zod | z, for the config schema |
Importing anything else fails with Module not found; checker logs that and carries on without the plugin, so one bad
plugin can't stop the rest from running.
Adding a plugin file only needs a config save to pick it up, but editing one needs a restart — the runtime caches modules it has already loaded.
Install Homebrew.
brew bundleEnsure mise activate is in your shell rc/profile. If it needed to be added,
restart your terminal session.
mise installRun the app and automatically reload when the code changes.
deno task devRun the app.
deno task runRun the app against a different config file.
deno task run --config-file /usr/local/etc/checker/config.ymlTo check for outdated dependencies:
deno outdatedTo update:
deno outdated --updateChecker polls on its own schedule, so it wants to stay resident rather than be re-launched on a timer. The service runs
the installed binary against a config you've already set up — a service that starts with no config writes
the example one and exits, and KeepAlive will keep restarting it into the placeholders.
Install the launch agent, substituting your home directory for the placeholder. Run it from the repository root so the template path resolves.
mkdir -p ~/Library/LaunchAgents
sed -e "s|__HOME__|$HOME|g" contrib/launchd/com.camurphy.checker.plist \
>~/Library/LaunchAgents/com.camurphy.checker.plist
launchctl load ~/Library/LaunchAgents/com.camurphy.checker.plistThe launch agent redirects checker's output to ~/Library/Logs/checker.log; change StandardOutPath and
StandardErrorPath in the plist to put it elsewhere. That file is the record of what the daemon did, not a place to
watch for trouble — configure errors to have failures notify a destination.
Config changes don't need a restart. Checker watches the config file and re-reads it on save, so adding a source or an item takes effect within a second; sources whose config didn't change keep their existing schedule and aren't re-checked. A config that fails to parse is logged and ignored, leaving the running config in place.
Dropping a new plugin into the plugin directory also takes effect on the next config save. Editing an existing plugin needs the service restarted, as does installing a new binary.
Updating the binary is checker self-update, which the running service handles itself.