Shell commands

Feed a KPI from a shell command on your Mac: what the command must print, the environment it runs in, the 10-second timeout, and the approval model.

A shell KPI runs a command on your Mac and stores the number the command prints. It is the fallback for everything no connector covers: a local database, a git repository, a CSV in your Downloads folder, an API that needs a tool you already have installed.

This is also the most powerful source, in the literal sense. The command runs with your user account’s permissions and Pulsaria does not sandbox it. Read the approval section before you paste something you did not write yourself.

Shell KPIs work on macOS only. On any other platform the fetch fails with “Shell commands are only available on macOS”, and the buttons that trigger a run are not rendered at all.

Creating a shell KPI

  1. Dashboard → + KPI in a group header, or Create KPI on an empty dashboard.
  2. In the source picker, choose the Bash card (“Any shell command that prints a number. Runs on your Mac, needs your approval.”).
  3. Type your command into the Bash command field. It is a two-line code field with the placeholder curl -s https://api.example.com/metric | jq '.value'.
  4. Optionally set Cron schedule (optional) so the KPI fetches by itself.
  5. Press Run test. The result panel below shows the exact command, its output, the shell, the exit code and the number Pulsaria would store.
  6. Press Approve and confirm in the dialog.
  7. Fill in Name and Target in the Goal section, then Create KPI.

You can save without approving. Pulsaria then shows the toast “Saved but not approved — The scheduler will skip this KPI until you approve it”, and the KPI sits in the dashboard doing nothing until you go back and approve it.

The card is labelled Bash, but the command is handed to /bin/sh -c. Write POSIX shell, not zsh- or bash-only syntax.

What the command has to print

Pulsaria takes the whole of stdout, trims leading and trailing whitespace and parses it as a number. Anything else fails the fetch.

stdoutResult
42Stored as 42
42\n (trailing newline)Stored as 42 — whitespace is trimmed
1234Stored as 1234wc -l style padding is fine
3.5Stored as 3.5 — decimal point
3,5Fails with “Output is not a number”
42% or € 42Fails — no unit, no currency sign, no thousands separator
12\n15Fails — one number, not a list
(empty)Fails — an empty stdout counts as not-a-number

Two more rules decide whether the value is even looked at:

  • The exit code must be 0. A non-zero exit fails the fetch with “Exit code N” and nothing is stored, no matter what was printed. This bites with grep -c, which exits 1 when it matched nothing — append || true if zero is a legitimate result.
  • stderr is ignored for the value. It is recorded and shown, but a command that writes warnings to stderr and a clean number to stdout is fine.

The environment your command runs in

Shell/bin/sh -c "<your command>"
EnvironmentCleared, then exactly three variables are set: PATH, HOME, LANG
PATHInherited from the Pulsaria process; if unset, /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
HOMEInherited; an empty string if the app process has none
LANGInherited; en_US.UTF-8 if unset
Working directoryNot set by Pulsaria — do not rely on relative paths
Timeout10 seconds, then the process is killed
InputNone. The command cannot ask a question and cannot read stdin

Two consequences worth internalising:

Your shell profile is never read. /bin/sh -c runs non-interactively, so .zshrc, .zprofile and .bash_profile do not execute, and none of your aliases, functions or exported variables exist. If your command needs an API token, it has to fetch it itself — for example from the Keychain with security find-generic-password.

PATH is whatever the app process has, not what your terminal has. A tool installed by Homebrew into /opt/homebrew/bin may not be found, and the command then fails with “Exit code 127”. Use absolute paths for anything outside /usr/bin and /bin: /opt/homebrew/bin/jq instead of jq. Run test tells you immediately — it uses exactly the same environment as a scheduled run.

Date tokens

The palette under the command field inserts placeholders that are resolved at fetch time, not at approval time. That is what lets a rolling query stay approved forever: the stored template is what gets fingerprinted, so {{today}} changing its value tomorrow does not invalidate anything.

All date tokens produce YYYY-MM-DD from your local calendar day.

TokenResolves to
{{today}}Today’s date
{{yesterday}}Yesterday’s date
{{last7DaysAgo}}7 days ago
{{last28DaysAgo}}28 days ago
{{last30DaysAgo}}30 days ago
{{firstDayOfMonth}}First day of the current month
{{lastDayOfMonth}}Last day of the current month
{{firstDayOfLastMonth}}First day of last month
{{lastDayOfLastMonth}}Last day of last month
{{firstDayOfQuarter}}First day of the current quarter
{{lastDayOfQuarter}}Last day of the current quarter
{{firstDayOfYear}}First day of the current year
{{lastDayOfYear}}Last day of the current year
{{now}}Current timestamp, ISO 8601
{{nowUnix}}Current Unix timestamp in seconds

Click a chip to insert the token at the cursor; hovering a chip previews the value it currently resolves to. Spaces inside the braces are tolerated ({{ today }} works). An unknown name such as {{foo}} is left in the command untouched — the shell will then see the literal braces.

The command field also repairs macOS text substitution on every test, approve and save: curly quotes become straight quotes, en- and em-dashes become hyphens, becomes .... Pasting from Notes or Mail does not silently break a command.

Approval and re-approval

A shell command is arbitrary code, so Pulsaria never runs one you have not explicitly seen and confirmed.

While a Bash KPI is unapproved, an amber banner sits above the command field:

Approval required — Auto KPIs only run after you approve them. Run a test below, then click Approve. Without approval the scheduler will skip this KPI.

Pressing Approve opens a confirmation dialog titled “Confirm Bash command”. It shows the exact command in a monospace block, the sentence “This command will run locally on your Mac on every fetch — with your user permissions. Pulsaria locks the command against unnoticed changes after approval (SHA-256 fingerprint).” and the reminder “Only approve sources you trust. Later edits require re-approval.” Confirm, and the banner turns green: “Approved — The scheduler will fetch this KPI automatically on the configured cron.”

What approval stores

Pulsaria hashes the command template with SHA-256 and stores the digest with the KPI definition. Before every run it re-hashes the stored command and compares.

  • Digest matches → the command runs.
  • No digest at all → the run is skipped, logged with “Command not approved — please confirm in KPI settings”. A batch refresh counts it under “skipped” and offers an Approve shortcut.
  • Digest present but different → the run fails with “Command changed — please re-approve”.

The second case is the tamper check. It fires whether the command was changed in the KPI dialog, by a sync conflict, or by someone editing kpi-definitions.json directly.

Approval is dropped automatically when:

  • you edit the command in the KPI dialog (a single character is enough);
  • you duplicate the KPI — the copy is unapproved, with the note “The copy needs re-approval before it fetches automatically”;
  • you import a KPI from a JSON export. Imported approvals are never honoured, so every imported Bash KPI has to be approved again on the new Mac. See Backup, export & moving Macs.

The pattern check

The confirmation dialog scans the command for ten obviously dangerous patterns and lists whatever it finds under “Suspicious patterns detected:”.

PatternWarning shown
rm -r / rm -rfDeletes files recursively (rm -rf)
sudoUses sudo
dd if=Uses dd (can overwrite data)
mkfsFormats a filesystem (mkfs)
:(){ :|:& };:Fork bomb pattern
curl … | shPipes curl output straight into a shell
wget … | shPipes wget output straight into a shell
evalUses eval
chmod 777Sets 777 permissions
> /dev/sd…Writes directly to a disk

This is a hint, not a security boundary. The list is short, trivially bypassed, and a command that trips none of the patterns can still delete your files. Approval does not restrict what the command may do — it only guarantees that what runs is what you read.

Two more honest details: the Approve button does not require a successful test. You can approve an untested or failing command; after a green test the button just turns emerald and a hint appears (”✓ Test successful — you can now approve”). And approval is per KPI, not per command — the same command in two KPIs needs two approvals.

Running it: schedule, test and manual runs

A Run test in the KPI dialog is a dry run. It executes the command, renders the result and writes nothing: no KPI value, no fetch-log entry, no alert. It also ignores approval, so you can iterate freely before you commit.

Everything else goes through the same path and does write:

  • Cron schedule (optional) in the KPI dialog. Pulsaria checks every 60 seconds while the app is open and runs the KPIs that are due, including ones missed while the app was closed. Details in Schedules & refresh.
  • Run now on the KPI detail page, or the small play button on the dashboard card next to the BASH badge.
  • Refresh in the dashboard header, which runs every automatic KPI at once.

Whatever triggers the run, the value is stored under today’s local date, one fetch-log entry is written, and a status change to at-risk or off-track raises an alert. When the trial has expired or a licence was revoked, scheduled runs pause and manual runs are refused.

What gets logged

Every run through the live path — scheduled or manual, success, failure or skip — appends an entry to the fetch log. For a Bash KPI the entry records stdout, stderr, the exit code, the command as executed (with date tokens resolved), the shell invocation /bin/sh -c and the duration.

On the Fetch Log screen (sidebar → Fetch Log), a row shows the KPI name, either Value: 42 or the error message, a bash badge, the duration in milliseconds and a relative timestamp. Expanding the row reveals the full timestamp and the stdout and stderr blocks. The exit code is not printed as its own field there — when it is the reason for the failure, it is the error message (“Exit code 127”). The full meta row with Shell, Exit code and Duration is shown in the test result panel inside the KPI dialog.

The log is a ring buffer capped at 500 entries, newest first. More on the screen itself in Fetch log.

stdout and stderr are stored verbatim in fetch-log.json. A command that prints a token or a customer name on failure puts that string into a file that also ends up in a JSON export. Keep secrets out of the output.

Example commands

Rows in a local SQLite database:

/usr/bin/sqlite3 "$HOME/data/app.db" "select count(*) from users"

Commits in a repository since the start of the month:

/usr/bin/git -C "$HOME/Projects/app" rev-list --count --since={{firstDayOfMonth}} HEAD

Data rows in an exported CSV, header excluded:

echo $(( $(wc -l < "$HOME/Exports/leads.csv") - 1 ))

A number out of a JSON API, with an absolute path to jq:

curl -s "https://api.example.com/orders?from={{firstDayOfMonth}}&to={{today}}" | /opt/homebrew/bin/jq '.total'

Occurrences in a log file — note the || true, without which an empty result would fail as “Exit code 1”:

grep -c "signup" "$HOME/Library/Logs/app.log" || true

Free disk space in whole gigabytes:

df -k / | awk 'NR==2 {print int($4/1048576)}'

For a plain JSON endpoint you do not need the shell at all. An HTTP source does the same thing without curl, without jq and without a PATH problem.

When something goes wrong

MessageCauseFix
Command not approved — please confirm in KPI settingsThe KPI was never approved; the run was skipped, not attemptedOpen the KPI → Approve
Command changed — please re-approveThe stored command no longer matches its fingerprintCheck the command, then Approve again
Output is not a numberstdout was empty, had a unit attached, or held more than one lineReduce the output to a single bare number
Exit code 127The shell could not find the programUse an absolute path, e.g. /opt/homebrew/bin/jq
Exit code 1The command itself failed, or exits non-zero on “no result”Run it in Terminal, add || true where zero is valid
Command timed out after 10sThe command needed more than 10 secondsMake it faster, or cache the number in a file with a launchd job and let Pulsaria read that file
Shell commands are only available on macOSThe KPI is running on a non-macOS buildNothing to fix — this source is macOS-only
No command configuredThe source is Bash but the command field is emptyOpen the KPI and enter a command

The Rust layer’s messages — Command timed out after 10s, Failed to spawn /bin/sh: …, Empty command — are always in English, including in the German interface. Everything else follows your interface language.

An exit code of -1 in the test panel means the process ended without a normal exit status, typically because it was killed by a signal.

Where to go next

Didn't find it?

Write to us — a real person reads every mail, usually the one who wrote the feature.

admin@one-pixel-ahead.com