Add -o flag to include job output in notifications

Fetches the last N lines of journal output (default 10) via
journalctl and includes them in desktop/email notification bodies.
Supports CLI (-o 5) and edit mode (o or o=5 flag syntax).

Also fixes systemd %s specifier expansion in email printf format
strings (must use %%s so systemd passes %s through to the shell).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Johnson 2026-02-14 16:35:52 -07:00
parent c3c534f7ce
commit 67528374cd
3 changed files with 46 additions and 12 deletions

View file

@ -18,11 +18,11 @@ No build step. The script requires `bash`, `systemctl`, and optionally `notify-s
The script has two modes controlled by CLI flags:
- **Job creation** (`-t <time> [-c <cmd> | -f <script> | stdin]`): Generates a systemd `.service` + `.timer` pair with a 6-char hex short ID, reloads the daemon, and enables/starts the timer. Time specs are parsed via `parse_time` which handles natural language (`every 5 minutes`), `date -d` relative/absolute times, and raw systemd OnCalendar values. One-time jobs get `Persistent=false` and `RemainAfterElapse=no` (auto-unload after firing). All jobs log stdout/stderr to the journal via `SyslogIdentifier`. Notifications (`-i` desktop, `-m` email) use `ExecStopPost` so they fire on both success and failure with status-aware icons/messages. Notification flags are persisted in the service file as a `# SYSTAB_FLAGS=` comment.
- **Job creation** (`-t <time> [-c <cmd> | -f <script> | stdin]`): Generates a systemd `.service` + `.timer` pair with a 6-char hex short ID, reloads the daemon, and enables/starts the timer. Time specs are parsed via `parse_time` which handles natural language (`every 5 minutes`), `date -d` relative/absolute times, and raw systemd OnCalendar values. One-time jobs get `Persistent=false` and `RemainAfterElapse=no` (auto-unload after firing). All jobs log stdout/stderr to the journal via `SyslogIdentifier`. Notifications (`-i` desktop, `-m` email, `-o <lines>` include output) use `ExecStopPost` so they fire on both success and failure with status-aware icons/messages. The `-o` flag fetches the last N lines of journal output (default 10) and includes them in the notification body. Notification flags are persisted in the service file as a `# SYSTAB_FLAGS=` comment.
- **Management** (`-P`, `-R`, `-E`, `-L`, `-S`, `-C` — mutually exclusive):
- `-P <id>` / `-R <id>`: Pause (stop+disable) or resume (enable+start) a job's timer.
- `-E`: Opens `$EDITOR` with a pipe-separated crontab (`ID[:FLAGS] | SCHEDULE | COMMAND`). Notification flags are appended to the ID with `:` (`i` = desktop, `e=addr` = email, comma-separated for both). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command/flags), and pause/resume (comment/uncomment lines).
- `-E`: Opens `$EDITOR` with a pipe-separated crontab (`ID[:FLAGS] | SCHEDULE | COMMAND`). Notification flags are appended to the ID with `:` (`i` = desktop, `e=addr` = email, `o` = output 10 lines, `o=N` = output N lines, comma-separated). On save, diffs against the original to apply creates (ID=`new`), deletes (removed lines), updates (changed schedule/command/flags), and pause/resume (comment/uncomment lines).
- `-L [id] [filter]`: Query `journalctl` logs for managed jobs (both unit messages and command output). Optional job ID to filter to a single job.
- `-S [id]`: Show timer status via `systemctl`, including short IDs and disabled state. Optional job ID to show a single job.
- `-C`: Interactively clean up elapsed one-time timers (removes unit files from disk).
@ -44,6 +44,6 @@ There are no automated tests. Test manually with systemd user timers:
## Notes
- ShellCheck can be used for linting: `shellcheck systab`.
- Edit mode uses `|` as the field delimiter (not tabs or spaces) to allow multi-word schedules. Notification flags use `:` after the ID (e.g., `a1b2c3:i,e=user@host`).
- Edit mode uses `|` as the field delimiter (not tabs or spaces) to allow multi-word schedules. Notification flags use `:` after the ID (e.g., `a1b2c3:i,o,e=user@host`).
- Notification flags are persisted as `# SYSTAB_FLAGS=...` comments in service files and as `ExecStopPost=` lines using `$SERVICE_RESULT`/`$EXIT_STATUS` for status-aware messages.
- Journal logs are queried with `USER_UNIT` OR `SYSLOG_IDENTIFIER` to capture both systemd messages and command output.

View file

@ -73,6 +73,9 @@ systab -t "in 1 hour" -c "make build" -i
# With email notification (via sendmail)
systab -t "every day at 6am" -c "df -h" -m user@example.com
# Include last 10 lines of output in notification
systab -t "every day at 6am" -c "df -h" -i -o 10
```
### Managing jobs
@ -121,7 +124,7 @@ g7h8i9:e=user@host | weekly | ~/backup.sh
- Delete a line to remove a job
- Add a line with `new` as the ID to create a job: `new | every 5 minutes | echo hello`
- Comment out a line (`#`) to pause, uncomment to resume
- Append notification flags after the ID with `:``i` for desktop, `e=addr` for email, comma-separated for both (e.g., `a1b2c3:i,e=user@host`)
- Append notification flags after the ID with `:``i` for desktop, `e=addr` for email, `o` for output (default 10 lines), `o=N` for custom count, comma-separated (e.g., `a1b2c3:i,o,e=user@host`)
### Job IDs
@ -142,6 +145,7 @@ Job Creation:
-f <script> Script file to execute (reads stdin if neither -c nor -f)
-i Send desktop notification on completion (success/failure)
-m <email> Send email notification to address (via sendmail)
-o <lines> Include last N lines of job output in notifications (default: 10)
Management:
-P <id> Pause (disable) a job

46
systab
View file

@ -21,6 +21,7 @@ opt_status=false
opt_pause=""
opt_resume=""
opt_filter=""
opt_output=""
opt_jobid=""
usage() {
@ -35,6 +36,7 @@ Job Creation Options:
-f <script> Script file to execute
-i Send desktop notification on completion (success/failure)
-m <email> Send email notification to address (via sendmail)
-o <lines> Include last N lines of job output in notifications (default: 10)
Management Options:
-P <id> Pause (disable) a job
@ -252,6 +254,10 @@ build_flags_string() {
if $opt_notify; then
flags="i"
fi
if [[ -n "$opt_output" ]]; then
[[ -n "$flags" ]] && flags+=","
if [[ "$opt_output" == "10" ]]; then flags+="o"; else flags+="o=$opt_output"; fi
fi
if [[ -n "$opt_email" ]]; then
[[ -n "$flags" ]] && flags+=","
flags+="e=$opt_email"
@ -264,31 +270,51 @@ parse_flags() {
local flags="$1"
_notify_flag=false
_email_addr=""
_output_lines=""
IFS=',' read -ra parts <<< "$flags"
for part in "${parts[@]}"; do
case "$part" in
i) _notify_flag=true ;;
o) _output_lines=10 ;;
o=*) _output_lines="${part#o=}" ;;
e=*) _email_addr="${part#e=}" ;;
esac
done
}
# Write ExecStopPost notification lines to a service file
# Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file>
# Usage: write_notify_lines <short_id> <notify_flag> <email_addr> <file> [output_lines] [job_name]
write_notify_lines() {
local short_id="$1" notify="$2" email="$3" file="$4"
local output_lines="${5-}" job_name="${6-}"
local out_cmd=""
if [[ -n "$output_lines" && -n "$job_name" ]]; then
out_cmd="out=\$(journalctl --user SYSLOG_IDENTIFIER=$job_name -n $output_lines --no-pager -o cat); "
fi
if [[ "$notify" == true ]]; then
cat >> "$file" <<EOF
if [[ -n "$out_cmd" ]]; then
cat >> "$file" <<EOF
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then icon=dialog-information; s=completed; else icon=dialog-error; s="failed (\$EXIT_STATUS)"; fi; ${out_cmd}body=\$(printf "Job $short_id: %%s\n%%s" "\$s" "\$out"); notify-send -i "\$icon" "systab" "\$body" || true'
EOF
else
cat >> "$file" <<EOF
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then icon=dialog-information; s=completed; else icon=dialog-error; s="failed (\$EXIT_STATUS)"; fi; notify-send -i "\$icon" "systab" "Job $short_id: \$s" || true'
EOF
fi
fi
if [[ -n "$email" ]]; then
local mailer
mailer=$(command -v sendmail || command -v msmtp || true)
[[ -n "$mailer" ]] || { warn "No sendmail or msmtp found, skipping email notification"; return; }
cat >> "$file" <<EOF
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; printf "Subject: systab: $short_id %s\\n\\n%s at %s\\n" "\$s" "\$s" "\$(date)" | $mailer $email'
if [[ -n "$out_cmd" ]]; then
cat >> "$file" <<EOF
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; ${out_cmd}printf "Subject: systab: $short_id %%s\\n\\n%%s at %%s\\n\\n%%s\\n" "\$s" "\$s" "\$(date)" "\$out" | $mailer $email'
EOF
else
cat >> "$file" <<EOF
ExecStopPost=/bin/sh -c 'if [ "\$SERVICE_RESULT" = success ]; then s=completed; else s="failed (\$EXIT_STATUS)"; fi; printf "Subject: systab: $short_id %%s\\n\\n%%s at %%s\\n" "\$s" "\$s" "\$(date)" | $mailer $email'
EOF
fi
fi
}
@ -322,7 +348,7 @@ EOF
if [[ -n "$flags" ]]; then
echo "# SYSTAB_FLAGS=$flags" >> "$service_file"
parse_flags "$flags"
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$service_file"
write_notify_lines "$short_id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$job_name"
fi
# Timer file
@ -417,8 +443,11 @@ edit_jobs() {
# Add a line with "new" as ID to create a job: new | daily | /path/to/cmd
# Comment out a line to disable, uncomment to re-enable.
#
# Notification flags (append to ID with ':'): i = desktop, e=addr = email
# Notification flags (append to ID with ':'): i = desktop, e=addr = email,
# o = include output (default 10 lines), o=N = include N lines of output
# a1b2c3:i | daily | cmd desktop notification
# a1b2c3:i,o | daily | cmd desktop with last 10 lines of output
# a1b2c3:i,o=5 | daily | cmd desktop with last 5 lines of output
# a1b2c3:e=user@host | daily | cmd email notification
# a1b2c3:i,e=user@host | daily | cmd both
#
@ -711,7 +740,7 @@ HEADER
if [[ -n "$new_flags" ]]; then
echo "# SYSTAB_FLAGS=$new_flags" >> "$service_file"
parse_flags "$new_flags"
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file"
write_notify_lines "$id" "$_notify_flag" "$_email_addr" "$service_file" "$_output_lines" "$jname"
fi
fi
@ -912,13 +941,14 @@ clean_jobs() {
# Parse command-line options
parse_options() {
while getopts "t:c:f:im:P:R:ELSCh" opt; do
while getopts "t:c:f:im:o:P:R:ELSCh" opt; do
case $opt in
t) opt_time="$OPTARG" ;;
c) opt_command="$OPTARG" ;;
f) opt_file="$OPTARG" ;;
i) opt_notify=true ;;
m) opt_email="$OPTARG" ;;
o) opt_output="$OPTARG" ;;
P) opt_pause="$OPTARG" ;;
R) opt_resume="$OPTARG" ;;
E) opt_edit=true ;;