diff --git a/.githooks/pre-commit b/.githooks/pre-commit
index 39e69cc..c56f7af 100755
--- a/.githooks/pre-commit
+++ b/.githooks/pre-commit
@@ -27,11 +27,24 @@ fi
echo "Running tests..."
if output=$(./test.sh 2>&1); then
echo "$output"
- count=$(grep -oP '\d+ passed' <<< "$output" | tail -1)
- write_badge "${count:-passing}" "brightgreen"
+ test_count=$(grep -oP '\d+(?= passed)' <<< "$output" | tail -1)
else
echo "$output"
write_badge "failing" "red"
echo "Tests failed — commit blocked."
exit 1
fi
+
+echo "Running demo tape tests..."
+if tape_output=$(./demo/test-tapes.sh 2>&1); then
+ echo "$tape_output"
+ tape_count=$(grep -oP '\d+(?= passed)' <<< "$tape_output" | tail -1)
+else
+ echo "$tape_output"
+ write_badge "failing" "red"
+ echo "Demo tape tests failed — commit blocked."
+ exit 1
+fi
+
+total_count=$(( ${test_count:-0} + ${tape_count:-0} ))
+write_badge "${total_count} passed" "brightgreen"
diff --git a/README.md b/README.md
index 1ba3a74..80d067f 100644
--- a/README.md
+++ b/README.md
@@ -15,18 +15,16 @@ Because you want to use systemd, but miss the ease of ~crontab~`systab -e`!
- 📋 access the logs of any job
- 💪 enable and disable timers and services
+

+
- |
- |
- |
- |
+ |
+ |
| Quick start |
-Edit mode |
-Notifications |
-Services |
+All features |
@@ -184,7 +182,7 @@ systab -C
### Job IDs and names
-Each job gets a 6-character hex ID (e.g., `a1b2c3`) displayed on creation and in status output. You can also assign a human-readable name with `-n` at creation time. Names can be used interchangeably with hex IDs in `-D`, `-E`, `-S`, and `-L`. Names must be unique and cannot contain whitespace, pipes, or colons.
+Each job gets a 6-character hex ID (e.g., `a1b2c3`) displayed on creation and in status output. You can also assign a human-readable name with `-n` at creation time. Names can be used interchangeably with hex IDs in `-D`, `-E`, `-X`, `-R`, `-S`, and `-L`. Names must be unique and cannot contain whitespace, pipes, or colons.
## How it works
@@ -210,6 +208,8 @@ Job Creation:
Management (accept hex ID or name):
-D Disable a job
-E Enable a disabled job
+ -X Delete a job (stop, disable, and remove unit files)
+ -R Restart a job (resets timer countdown / restarts service process)
-e Edit jobs in crontab-like format
-l Print jobs in crontab-like format to stdout
-L [id|name] [filter] List job logs (optionally for a specific job and/or filtered)
@@ -220,7 +220,8 @@ Management (accept hex ID or name):
## Future feature ideas
-- [ ] `-R` flag to restart / reload
+- [x] `-R` flag to restart / reload
+- [x] `-X` flag to delete
## License
@@ -238,6 +239,15 @@ After cloning, enable the pre-commit hook (runs ShellCheck + tests):
git config core.hooksPath .githooks
```
+Common tasks via [`just`](https://github.com/casey/just):
+
+```bash
+just check # lint + unit tests + tape tests
+just test # unit tests only
+just lint # ShellCheck only
+just record # re-record demo GIFs with VHS
+```
+
## FAQ
diff --git a/badges/tests.json b/badges/tests.json
index 3e4522a..b571791 100644
--- a/badges/tests.json
+++ b/badges/tests.json
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"label": "tests",
- "message": "88 passed",
+ "message": "131 passed",
"color": "brightgreen"
}
diff --git a/demo/all-features.gif b/demo/all-features.gif
new file mode 100644
index 0000000..6bb5ab0
Binary files /dev/null and b/demo/all-features.gif differ
diff --git a/demo/all-features.tape b/demo/all-features.tape
new file mode 100644
index 0000000..da64117
--- /dev/null
+++ b/demo/all-features.tape
@@ -0,0 +1,206 @@
+# systab — All Features
+# A complete walkthrough: timers, services, status, logs,
+# disable/enable, list mode, notifications, and edit mode.
+
+Output demo/all-features.gif
+Set Shell bash
+Set Width 1200
+Set Height 700
+Set FontSize 16
+
+Set TypingSpeed 50ms
+
+Sleep 1s
+
+# Clean up any pre-existing jobs with names used in this demo
+Hide
+Type "systab -X healthcheck_home 2>/dev/null; systab -X monitor_home 2>/dev/null; systab -X backup_home 2>/dev/null; systab -X health2_home 2>/dev/null; true"
+Enter
+Sleep 1s
+Show
+
+# ── Job Creation ──────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Recurring timer with a name'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -t 'every 5 minutes' -n healthcheck_home -c 'uptime'"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Hide
+Type "./demo/note.sh 'One-time reminder with desktop notification'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -t 'in 30 minutes' -c 'echo reminder: meeting soon' -i"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Hide
+Type "./demo/note.sh 'Persistent service — starts on login, auto-restarts on failure'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -s -n monitor_home -c 'ping -c 1 localhost'"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── Status ────────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Status of all jobs and services'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -S"
+Sleep 500ms
+Enter
+Sleep 3s
+
+# ── Logs ──────────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Viewing logs for a specific job'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -L healthcheck_home"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── Disable / Enable ──────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Disabling a job by name'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -D healthcheck_home"
+Sleep 500ms
+Enter
+Sleep 1s
+
+Type "systab -S healthcheck_home"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Hide
+Type "./demo/note.sh 'Re-enabling the job'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -E healthcheck_home"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── Restart ───────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Restart a job — resets the timer countdown'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -R healthcheck_home"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── Notifications ─────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Add desktop notification with -i (fires on job completion)'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -t 'daily' -n backup_home -c '/home/user/backup.sh' -i"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── List mode ─────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'List all jobs in crontab format with -l'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -l"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# ── Edit mode ─────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Edit mode — manage jobs in your $EDITOR'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "EDITOR=nano systab -e"
+Sleep 500ms
+Enter
+Sleep 3s
+
+# Navigate to end of file in nano and add a new job with flags
+Ctrl+V
+Sleep 500ms
+Down 5
+Sleep 500ms
+Type "new:n=health2_home,i,e=admin@example.com | hourly | curl -s https://example.com/health"
+Sleep 1s
+Enter
+Sleep 500ms
+
+Ctrl+O
+Sleep 500ms
+Enter
+Sleep 500ms
+Ctrl+X
+Sleep 3s
+
+# Show updated status
+Hide
+Type "./demo/note.sh 'Status after edit — new job added with flags'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -S"
+Sleep 500ms
+Enter
+Sleep 3s
+
+# ── Clean ─────────────────────────────────────────────────────
+
+Hide
+Type "./demo/note.sh 'Clean up completed one-time timers'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "systab -C"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Sleep 2s
diff --git a/demo/editmode.gif b/demo/editmode.gif
deleted file mode 100644
index 5b9a644..0000000
Binary files a/demo/editmode.gif and /dev/null differ
diff --git a/demo/editmode.png b/demo/editmode.png
new file mode 100644
index 0000000..df075e8
Binary files /dev/null and b/demo/editmode.png differ
diff --git a/demo/editmode.tape b/demo/editmode.tape
deleted file mode 100644
index 8ddf4e9..0000000
--- a/demo/editmode.tape
+++ /dev/null
@@ -1,107 +0,0 @@
-# systab — Edit Mode with Notification Flags
-# Demonstrates the crontab-style editor with :flags syntax.
-
-Output demo/editmode.gif
-Set Shell bash
-Set Width 1200
-Set Height 600
-Set FontSize 16
-
-Set TypingSpeed 50ms
-
-Sleep 1s
-
-# First create some jobs to edit
-Hide
-Type "./demo/note.sh 'Creating some jobs to work with'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -t 'every 5 minutes' -c 'echo ping'"
-Enter
-Sleep 2s
-Type "systab -t daily -c '/home/user/backup.sh' -i"
-Enter
-Sleep 2s
-
-# List jobs in crontab format without opening editor
-Hide
-Type "./demo/note.sh 'Listing jobs in crontab format (-l)'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -l"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Open edit mode with EDITOR=nano for visibility
-Hide
-Type "./demo/note.sh 'Opening edit mode — add notifications, create and modify jobs'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "EDITOR=nano systab -e"
-Sleep 500ms
-Enter
-Sleep 3s
-
-# In nano: navigate to the end of the file
-# Ctrl+V = page down in nano, then Down to reach last line
-Ctrl+V
-Sleep 500ms
-Down 5
-Sleep 500ms
-
-# Add a new job line with notification flags
-Type "new:n=health,i,e=admin@example.com | hourly | curl -s https://example.com/health"
-Sleep 1s
-Enter
-
-Sleep 1s
-
-# Save and exit nano
-Ctrl+O
-Sleep 500ms
-Enter
-Sleep 500ms
-Ctrl+X
-Sleep 3s
-
-# Show the result
-Hide
-Type "./demo/note.sh 'Changes applied — checking status'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S | less"
-Sleep 500ms
-Enter
-Sleep 2s
-Type "/Job:"
-Enter
-Sleep 3s
-Type "q"
-Sleep 1s
-
-# Re-open edit mode to show flags are persisted
-Hide
-Type "./demo/note.sh 'Re-opening edit mode — flags are persisted'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "EDITOR=nano systab -e"
-Sleep 500ms
-Enter
-Sleep 3s
-
-# Just view and exit without changes
-Ctrl+X
-Sleep 2s
-
-Sleep 2s
diff --git a/demo/notifications.gif b/demo/notifications.gif
deleted file mode 100644
index 0c5d060..0000000
Binary files a/demo/notifications.gif and /dev/null differ
diff --git a/demo/notifications.tape b/demo/notifications.tape
deleted file mode 100644
index bd14cc6..0000000
--- a/demo/notifications.tape
+++ /dev/null
@@ -1,96 +0,0 @@
-# systab — Notifications
-# Shows status-aware desktop and email notifications.
-
-Output demo/notifications.gif
-Set Shell bash
-Set Width 1200
-Set Height 600
-Set FontSize 16
-
-Set TypingSpeed 50ms
-
-Sleep 1s
-
-# Desktop notification — success case
-Hide
-Type "./demo/note.sh 'Desktop notification on success'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -t 'in 1 minute' -c 'echo success' -i"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Desktop notification — failure case
-Hide
-Type "./demo/note.sh 'Desktop notification on failure'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -t 'in 1 minute' -c 'exit 1' -i"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Email notification
-Hide
-Type "./demo/note.sh 'Email notification via sendmail'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -t 'in 1 minute' -c 'echo test' -m user@example.com"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Both notifications
-Hide
-Type "./demo/note.sh 'Both desktop and email notifications'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -t 'every day at 9am' -n backup -c '/home/user/backup.sh' -i -m admin@example.com"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Show the generated service file to see ExecStopPost lines
-Hide
-Type "./demo/note.sh 'Inspecting generated service file'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "cat ~/.config/systemd/user/systab_*.service | less"
-Sleep 500ms
-Enter
-Sleep 2s
-Type "/ExecStopPost"
-Enter
-Sleep 3s
-Type "q"
-Sleep 1s
-
-# Check status
-Hide
-Type "./demo/note.sh 'Checking status of all jobs'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S | less"
-Sleep 500ms
-Enter
-Sleep 2s
-Type "/Job:"
-Enter
-Sleep 3s
-Type "q"
-Sleep 1s
-
-Sleep 2s
diff --git a/demo/quickstart.gif b/demo/quickstart.gif
index f815788..5f27161 100644
Binary files a/demo/quickstart.gif and b/demo/quickstart.gif differ
diff --git a/demo/quickstart.tape b/demo/quickstart.tape
index 05f3bd5..1fc4f12 100644
--- a/demo/quickstart.tape
+++ b/demo/quickstart.tape
@@ -1,5 +1,5 @@
# systab — Quick Start
-# Creates a few jobs, checks status, views logs, then cleans up.
+# Create a timer, create a service, then use edit mode to add a new job.
Output demo/quickstart.gif
Set Shell bash
@@ -11,113 +11,87 @@ Set TypingSpeed 50ms
Sleep 1s
-# Create a recurring job
+# Clean up any pre-existing jobs with names used in this demo
Hide
-Type "./demo/note.sh 'Creating a recurring health check (every 5 minutes)'"
+Type "systab -X healthcheck_home 2>/dev/null; systab -X monitor_home 2>/dev/null; systab -X diskcheck_home 2>/dev/null; true"
+Enter
+Sleep 1s
+Show
+
+# Create a recurring timer
+Hide
+Type "./demo/note.sh 'Create a recurring timer'"
Enter
Sleep 500ms
Show
Sleep 1s
-Type "systab -t 'every 5 minutes' -n healthcheck -c 'echo health check OK'"
+Type "systab -t 'every 5 minutes' -n healthcheck_home -c 'uptime'"
Sleep 500ms
Enter
Sleep 2s
-# Create a one-time job
+# Create a persistent service
Hide
-Type "./demo/note.sh 'Creating a one-time reminder (30 minutes from now)'"
+Type "./demo/note.sh 'Create a persistent service'"
Enter
Sleep 500ms
Show
Sleep 1s
-Type "systab -t 'in 30 minutes' -c 'echo reminder: meeting soon'"
+Type "systab -s -n monitor_home -c 'ping -c 1 localhost'"
Sleep 500ms
Enter
Sleep 2s
-# Check status of all jobs
+# Both visible in status
Hide
-Type "./demo/note.sh 'Checking status of all jobs'"
+Type "./demo/note.sh 'Both jobs visible in status'"
Enter
Sleep 500ms
Show
Sleep 1s
-Type "systab -S | less"
+Type "systab -S"
Sleep 500ms
Enter
Sleep 2s
-Type "/Job:"
+
+# Open edit mode: screenshot then add a new job
+Hide
+Type "./demo/note.sh 'Edit mode — add a new job'"
+Enter
+Sleep 500ms
+Show
+Sleep 1s
+Type "EDITOR=nano systab -e"
+Sleep 500ms
Enter
Sleep 3s
-Type "q"
-Sleep 1s
-# View logs
-Hide
-Type "./demo/note.sh 'Viewing job logs'"
+Screenshot demo/editmode.png
+
+# Navigate to end of file in nano and add a new job
+Ctrl+V
+Sleep 500ms
+Down 5
+Sleep 500ms
+Type "new:n=diskcheck_home | daily | df -h"
Enter
Sleep 500ms
-Show
-Sleep 1s
-Type "systab -L | less"
+
+Ctrl+O
Sleep 500ms
Enter
-Sleep 2s
-Type "/Logs for"
-Enter
+Sleep 500ms
+Ctrl+X
Sleep 3s
-Type "q"
-Sleep 1s
-# Disable a job (uses the first job ID from status)
+# Show updated status
Hide
-Type "./demo/note.sh 'Disabling a job'"
+Type "./demo/note.sh 'Status after edit — new job added'"
Enter
Sleep 500ms
Show
Sleep 1s
-Type "systab -D healthcheck"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Show it's disabled
-Hide
-Type "./demo/note.sh 'Verifying job is disabled'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S | less"
-Sleep 500ms
-Enter
-Sleep 2s
-Type "/Disabled"
-Enter
-Sleep 3s
-Type "q"
-Sleep 1s
-
-# Enable it
-Hide
-Type "./demo/note.sh 'Enabling the disabled job'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -E healthcheck"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Clean up completed one-time jobs
-Hide
-Type "./demo/note.sh 'Cleaning up completed one-time jobs'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -C"
+Type "systab -S"
Sleep 500ms
Enter
Sleep 2s
diff --git a/demo/services.gif b/demo/services.gif
deleted file mode 100644
index c4fd41e..0000000
Binary files a/demo/services.gif and /dev/null differ
diff --git a/demo/services.tape b/demo/services.tape
deleted file mode 100644
index 8d57873..0000000
--- a/demo/services.tape
+++ /dev/null
@@ -1,159 +0,0 @@
-# systab — Persistent Services
-# Creates a managed service (no timer), checks status, disables/enables,
-# inspects via edit mode, then cleans up.
-
-Output demo/services.gif
-Set Shell bash
-Set Width 1200
-Set Height 600
-Set FontSize 16
-
-Set TypingSpeed 50ms
-
-Sleep 1s
-
-# Create a persistent service
-Hide
-Type "./demo/note.sh 'Creating a persistent service job (runs on login, auto-restarts)'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -s -n monitor -c 'sleep 3600'"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Check status — should show Type: Service, Active (running)
-Hide
-Type "./demo/note.sh 'Checking status — service is running'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S monitor"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Inspect the generated unit file
-Hide
-Type "./demo/note.sh 'Inspecting the generated unit file'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "cat ~/.config/systemd/user/systab_*.service | less"
-Sleep 500ms
-Enter
-Sleep 2s
-Type "/SYSTAB_TYPE"
-Enter
-Sleep 2s
-Type "q"
-Sleep 1s
-
-# View logs
-Hide
-Type "./demo/note.sh 'Viewing service logs'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -L monitor"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Disable the service
-Hide
-Type "./demo/note.sh 'Disabling the service (stops it)'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -D monitor"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Verify disabled
-Hide
-Type "./demo/note.sh 'Verifying service is disabled/stopped'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S monitor"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Re-enable
-Hide
-Type "./demo/note.sh 'Re-enabling the service'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -E monitor"
-Sleep 500ms
-Enter
-Sleep 2s
-
-# Open edit mode — service appears with 'service' in schedule column
-Hide
-Type "./demo/note.sh 'Service jobs appear in edit mode with schedule = service'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "EDITOR=nano systab -e"
-Sleep 500ms
-Enter
-Sleep 3s
-
-# Just view and exit
-Ctrl+X
-Sleep 2s
-
-# Also show a service created via edit mode
-Hide
-Type "./demo/note.sh 'Creating a service via edit mode: new:s | service | cmd'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "EDITOR=nano systab -e"
-Sleep 500ms
-Enter
-Sleep 3s
-
-Ctrl+V
-Sleep 500ms
-Down 5
-Sleep 500ms
-Type "new:s,n=watcher | service | sleep 7200"
-Sleep 1s
-Enter
-
-Ctrl+O
-Sleep 500ms
-Enter
-Sleep 500ms
-Ctrl+X
-Sleep 3s
-
-# Final status
-Hide
-Type "./demo/note.sh 'Both services visible in status'"
-Enter
-Sleep 500ms
-Show
-Sleep 1s
-Type "systab -S"
-Sleep 500ms
-Enter
-Sleep 3s
-
-Sleep 2s
diff --git a/demo/test-tapes.sh b/demo/test-tapes.sh
new file mode 100755
index 0000000..c605bbf
--- /dev/null
+++ b/demo/test-tapes.sh
@@ -0,0 +1,135 @@
+#!/usr/bin/env bash
+# test-tapes.sh — Verify all systab commands in VHS tape files run correctly.
+# Reads each tape line-by-line, tracking Hide/Show blocks. Only runs
+# Type "systab ..." and Type "EDITOR=..." lines that are in visible (Show) blocks.
+#
+# Run from the project root: ./demo/test-tapes.sh
+
+set -uo pipefail
+
+# Make ./systab callable as 'systab', matching how tapes reference it
+PATH="$PWD:$PATH"
+export PATH
+
+TAPE_DIR="demo"
+SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
+
+passed=0
+failed=0
+total=0
+tape_job_ids=()
+
+if [[ -t 1 ]]; then
+ GREEN=$'\033[32m' RED=$'\033[31m' BOLD=$'\033[1m' RESET=$'\033[0m'
+else
+ GREEN="" RED="" BOLD="" RESET=""
+fi
+
+pass() {
+ echo "${GREEN}[PASS]${RESET} $1"
+ passed=$((passed + 1))
+ total=$((total + 1))
+}
+
+fail() {
+ echo "${RED}[FAIL]${RESET} $1 — $2"
+ failed=$((failed + 1))
+ total=$((total + 1))
+}
+
+# Stop, disable, and remove all unit files created during tape tests
+cleanup_tape_jobs() {
+ [[ ${#tape_job_ids[@]} -eq 0 ]] && return
+ for id in "${tape_job_ids[@]}"; do
+ [[ -z "$id" ]] && continue
+ for ext in service timer; do
+ local f="$SYSTEMD_USER_DIR/systab_${id}.${ext}"
+ [[ -f "$f" ]] || continue
+ systemctl --user stop "systab_${id}.${ext}" 2>/dev/null || true
+ systemctl --user disable "systab_${id}.${ext}" 2>/dev/null || true
+ rm -f "$f"
+ done
+ done
+ systemctl --user daemon-reload 2>/dev/null || true
+ tape_job_ids=()
+}
+
+trap cleanup_tape_jobs EXIT
+
+# Collect any job IDs from command output into tape_job_ids
+collect_ids() {
+ local id
+ while IFS= read -r id; do
+ [[ -n "$id" ]] && tape_job_ids+=("$id")
+ done < <(sed -n 's/^\(Job\|Service\) created: \([0-9a-f]\{6\}\).*$/\2/p' <<< "$1")
+}
+
+# Prepare a tape command for test execution:
+# - EDITOR=nano → EDITOR=cat (non-interactive; cat exits 0, no file changes)
+# - strip trailing " | less" (we capture stdout directly)
+normalize() {
+ local cmd="$1"
+ cmd="${cmd//EDITOR=nano/EDITOR=cat}"
+ cmd="${cmd% | less}"
+ echo "$cmd"
+}
+
+# Run all systab commands from one tape file in order
+run_tape() {
+ local tape="$1"
+ local tape_name
+ tape_name=$(basename "$tape" .tape)
+
+ echo ""
+ echo "${BOLD}=== $tape_name ===${RESET}"
+
+ cleanup_tape_jobs # clean up IDs from previous tape
+
+ # Pre-clean named jobs from this tape to handle leftovers from failed runs
+ local name
+ while IFS= read -r name; do
+ systab -X "$name" 2>/dev/null || true
+ done < <(grep -E '^Type "systab .*-n ' "$tape" | grep -oP '(?<=-n )\w+')
+
+ local line raw cmd output exit_code in_hide=false
+ while IFS= read -r line; do
+ # Track Hide/Show blocks — skip commands in hidden sections
+ [[ "$line" == "Hide" ]] && { in_hide=true; continue; }
+ [[ "$line" == "Show" ]] && { in_hide=false; continue; }
+ $in_hide && continue
+
+ # Only process visible Type lines with systab or EDITOR= commands
+ [[ "$line" == 'Type "systab '* ]] || [[ "$line" == 'Type "EDITOR='* ]] || continue
+
+ raw="${line#Type \"}"
+ raw="${raw%\"}"
+ cmd=$(normalize "$raw")
+ exit_code=0
+
+ # Pipe /dev/null for commands that read stdin interactively
+ if [[ "$cmd" == *"systab -C"* ]] || [[ "$cmd" == *"systab -e"* ]]; then
+ output=$(eval "$cmd" < /dev/null 2>&1) || exit_code=$?
+ else
+ output=$(eval "$cmd" 2>&1) || exit_code=$?
+ fi
+
+ collect_ids "$output"
+
+ if [[ $exit_code -eq 0 ]]; then
+ pass "$tape_name: $raw"
+ else
+ fail "$tape_name: $raw" "exit $exit_code: ${output:0:120}"
+ fi
+ done < "$tape"
+}
+
+echo "${BOLD}Testing systab commands from VHS tape files...${RESET}"
+
+for tape in "$TAPE_DIR"/*.tape; do
+ run_tape "$tape"
+done
+
+echo ""
+echo "---"
+echo "${BOLD}${total} tape commands: ${GREEN}${passed} passed${RESET}, ${RED}${failed} failed${RESET}"
+[[ $failed -eq 0 ]]
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..7cb9146
--- /dev/null
+++ b/justfile
@@ -0,0 +1,25 @@
+# systab task runner — `just` to list all recipes
+
+# List available recipes
+default:
+ @just --list
+
+# Run ShellCheck linter
+lint:
+ shellcheck systab
+
+# Run unit tests
+test:
+ ./test.sh
+
+# Run demo tape command tests (no VHS required)
+tape-test:
+ ./demo/test-tapes.sh
+
+# Run all checks: lint + unit tests + tape tests
+check: lint test tape-test
+
+# Record demo GIFs with VHS
+record:
+ vhs demo/quickstart.tape
+ vhs demo/all-features.tape
diff --git a/systab b/systab
index e21273f..0c5fb6e 100755
--- a/systab
+++ b/systab
@@ -23,6 +23,8 @@ opt_clean=false
opt_status=false
opt_disable=""
opt_enable=""
+opt_delete=""
+opt_restart=""
opt_filter=""
opt_output=""
opt_name=""
@@ -48,6 +50,8 @@ Job Creation Options:
Management Options (accept hex ID or name):
-D Disable a job
-E Enable a disabled job
+ -X Delete a job (stop, disable, and remove unit files)
+ -R Restart a job (resets timer countdown / restarts service process)
-e Edit jobs in crontab-like format
-l Print jobs in crontab-like format to stdout
-L [id|name] [filter] List job logs (optionally for a specific job and/or filtered)
@@ -89,6 +93,14 @@ EXAMPLES:
$SCRIPT_NAME -D
$SCRIPT_NAME -E backup
+ # Delete a job permanently
+ $SCRIPT_NAME -X
+ $SCRIPT_NAME -X backup
+
+ # Restart a job
+ $SCRIPT_NAME -R
+ $SCRIPT_NAME -R healthcheck_home
+
# View logs for backup jobs
$SCRIPT_NAME -L backup
@@ -333,6 +345,40 @@ toggleJobById() {
fi
}
+# Delete a job (stop, disable, remove unit files) by hex ID or name
+deleteJob() {
+ local input="$1"
+ validateJobId "$input"
+ local id="$_resolved_id"
+ local name
+ name=$(getJobName "$SYSTEMD_USER_DIR/${_job_name}.service")
+ local label
+ label=$(formatJobId "$id" "$name")
+ removeJob "$_job_name"
+ systemctl --user daemon-reload
+ echo "Deleted: $label"
+}
+
+# Restart a job by hex ID or name
+restartJob() {
+ local input="$1"
+ validateJobId "$input"
+ local id="$_resolved_id"
+ local name
+ name=$(getJobName "$SYSTEMD_USER_DIR/${_job_name}.service")
+ local label
+ label=$(formatJobId "$id" "$name")
+ if ! isJobEnabled "$_job_name"; then
+ error "Job is disabled, enable it first with -E: $label"
+ fi
+ if isJobService "$_job_name"; then
+ systemctl --user restart "${_job_name}.service" 2>/dev/null || true
+ else
+ systemctl --user restart "${_job_name}.timer" 2>/dev/null || true
+ fi
+ echo "Restarted: $label"
+}
+
# Get all managed unit files of a given type (service or timer)
getManagedUnits() {
local ext="$1"
@@ -1185,7 +1231,7 @@ cleanJobs() {
# Parse command-line options
parseOptions() {
- while getopts "t:sc:f:n:im:oD:E:elLSCh" opt; do
+ while getopts "t:sc:f:n:im:oD:E:X:R:elLSCh" opt; do
case $opt in
t) opt_time="$OPTARG" ;;
s) opt_service=true ;;
@@ -1208,6 +1254,8 @@ parseOptions() {
fi ;;
D) opt_disable="$OPTARG" ;;
E) opt_enable="$OPTARG" ;;
+ X) opt_delete="$OPTARG" ;;
+ R) opt_restart="$OPTARG" ;;
e) opt_edit=true ;;
l) opt_print_crontab=true ;;
L) opt_list=true ;;
@@ -1250,6 +1298,8 @@ parseOptions() {
local manage_count=0
[[ -n "$opt_disable" ]] && manage_count=$((manage_count + 1))
[[ -n "$opt_enable" ]] && manage_count=$((manage_count + 1))
+ [[ -n "$opt_delete" ]] && manage_count=$((manage_count + 1))
+ [[ -n "$opt_restart" ]] && manage_count=$((manage_count + 1))
$opt_edit && manage_count=$((manage_count + 1))
$opt_print_crontab && manage_count=$((manage_count + 1))
$opt_list && manage_count=$((manage_count + 1))
@@ -1257,11 +1307,11 @@ parseOptions() {
$opt_clean && manage_count=$((manage_count + 1))
if [[ $manage_count -gt 1 ]]; then
- error "Options -D, -E, -e, -l, -L, -S, and -C are mutually exclusive"
+ error "Options -D, -E, -X, -R, -e, -l, -L, -S, and -C are mutually exclusive"
fi
if [[ $manage_count -gt 0 ]] && { [[ -n "$opt_time$opt_command$opt_file" ]] || $opt_service; }; then
- error "Management options -D, -E, -e, -l, -L, -S, and -C cannot be used with job creation options"
+ error "Management options -D, -E, -X, -R, -e, -l, -L, -S, and -C cannot be used with job creation options"
fi
if [[ -n "$opt_command" && -n "$opt_file" ]]; then
@@ -1290,6 +1340,10 @@ main() {
toggleJobById "$opt_disable" disable
elif [[ -n "$opt_enable" ]]; then
toggleJobById "$opt_enable" enable
+ elif [[ -n "$opt_delete" ]]; then
+ deleteJob "$opt_delete"
+ elif [[ -n "$opt_restart" ]]; then
+ restartJob "$opt_restart"
elif $opt_edit; then
editJobs
elif $opt_print_crontab; then
diff --git a/test.sh b/test.sh
index 972d94e..8672148 100755
--- a/test.sh
+++ b/test.sh
@@ -188,6 +188,58 @@ assert_output "enable job" "Enabled:" $SYSTAB -E "$id_recurring"
assert_output "enable already enabled" "Already enabled:" $SYSTAB -E "$id_recurring"
+# ============================================================
+# Delete (-X)
+# ============================================================
+
+echo ""
+echo "${BOLD}--- Delete (-X) ---${RESET}"
+
+assert_output "create timer job for deletion" "Job created:" $SYSTAB -t "every 30 minutes" -c "echo delete_test"
+extract_id; id_del=$_extracted_id
+
+assert_output "delete timer job by ID" "Deleted:" $SYSTAB -X "$id_del"
+assert_last_output_contains "delete output contains job ID" "$id_del"
+
+if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del}.service" ]]; then
+ pass "service file removed after delete"
+else
+ fail "service file removed after delete" "file still exists"
+fi
+if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del}.timer" ]]; then
+ pass "timer file removed after delete"
+else
+ fail "timer file removed after delete" "file still exists"
+fi
+
+assert_output "create named job for deletion" "Job created:" $SYSTAB -t "every 30 minutes" -c "echo named_delete_test" -n deltarget
+extract_id; id_del_named=$_extracted_id
+
+assert_output "delete named job by name" "Deleted:" $SYSTAB -X deltarget
+assert_last_output_contains "delete-by-name output shows name" "(deltarget)"
+
+if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del_named}.service" ]]; then
+ pass "named job service file removed after delete"
+else
+ fail "named job service file removed after delete" "file still exists"
+fi
+
+assert_output "create service job for deletion" "Service created:" $SYSTAB -s -c "sleep 3600"
+extract_id; id_del_svc=$_extracted_id
+
+assert_output "delete service job by ID" "Deleted:" $SYSTAB -X "$id_del_svc"
+
+if [[ ! -f "$SYSTEMD_USER_DIR/systab_${id_del_svc}.service" ]]; then
+ pass "service-only job file removed after delete"
+else
+ fail "service-only job file removed after delete" "file still exists"
+fi
+
+assert_failure "delete nonexistent job fails" $SYSTAB -X "zzzzzz"
+assert_failure "-X and -D are mutually exclusive" $SYSTAB -X "$id_recurring" -D "$id_recurring"
+assert_failure "-X and -E are mutually exclusive" $SYSTAB -X "$id_recurring" -E "$id_recurring"
+assert_failure "-X cannot be combined with job creation" $SYSTAB -X "$id_recurring" -t daily -c "echo test"
+
# ============================================================
# Notifications
# ============================================================
@@ -450,6 +502,26 @@ assert_failure "-s and -i are mutually exclusive" $SYSTAB -s -i -c "echo test"
assert_failure "-s and -m are mutually exclusive" $SYSTAB -s -m user@example.com -c "echo test"
assert_failure "-s and -o are mutually exclusive" $SYSTAB -s -o -c "echo test"
+# ============================================================
+# Restart (-R)
+# ============================================================
+
+echo ""
+echo "${BOLD}--- Restart (-R) ---${RESET}"
+
+assert_output "restart timer job by ID" "Restarted:" $SYSTAB -R "$id_recurring"
+assert_output "restart timer job by name" "Restarted:" $SYSTAB -R mytest
+assert_last_output_contains "restart by name shows name" "(mytest)"
+assert_output "restart service job" "Restarted:" $SYSTAB -R "$id_svc"
+
+$SYSTAB -D "$id_recurring"
+assert_failure "restart disabled job fails" $SYSTAB -R "$id_recurring"
+$SYSTAB -E "$id_recurring"
+
+assert_failure "restart nonexistent job fails" $SYSTAB -R "zzzzzz"
+assert_failure "-R and -D are mutually exclusive" $SYSTAB -R "$id_recurring" -D "$id_recurring"
+assert_failure "-R cannot be combined with job creation" $SYSTAB -R "$id_recurring" -t daily -c "echo test"
+
# ============================================================
# Clean
# ============================================================