Implement full -E edit mode with short IDs

Add 6-char hex short IDs (SYSTAB_ID) embedded in unit files for
human-friendly job identification. The -E flag now opens a real
crontab-like editor where jobs can be created, updated, and deleted
by editing tab-separated ID/SCHEDULE/COMMAND lines. Legacy jobs
without IDs get one auto-assigned on first edit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Johnson 2026-02-13 23:01:17 -07:00
parent bfb273dc66
commit 456111627d

309
systab
View file

@ -1,20 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
# catbsysd - A cron/at/batch-like interface for systemd
# Managed jobs are marked with: # CATBSYSD_MANAGED
# systab - A cron/at/batch-like interface for systemd
# Managed jobs are marked with: # SYSTAB_MANAGED
readonly SCRIPT_NAME="systab"
readonly SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
readonly MARKER="# CATBSYSD_MANAGED"
readonly CRONTAB_EXAMPLE="# Example crontab format (for reference):
# Min Hour Day Month DayOfWeek Command
# * * * * * /path/to/command
# 0 2 * * * /path/to/nightly-job
# */15 * * * * /path/to/every-15-min
#
# Actual systemd timers below (OnCalendar format):
"
readonly MARKER="# SYSTAB_MANAGED"
# Global variables for options
opt_time=""
@ -128,6 +120,56 @@ generate_job_name() {
echo "${SCRIPT_NAME}_${timestamp}_$$"
}
# Generate a random 6-char hex short ID
generate_short_id() {
od -An -tx1 -N3 /dev/urandom | tr -d ' \n'
}
# Extract SYSTAB_ID from a job's timer file; empty if missing
get_job_id() {
local job_name="$1"
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
if [[ -f "$timer_file" ]]; then
sed -n 's/^# SYSTAB_ID=//p' "$timer_file"
fi
}
# Find job name by short ID; empty if not found
get_job_by_id() {
local target_id="$1"
local job
while IFS= read -r job; do
[[ -z "$job" ]] && continue
local jid
jid=$(get_job_id "$job")
if [[ "$jid" == "$target_id" ]]; then
echo "$job"
return
fi
done < <(get_managed_timers)
}
# Ensure a managed job has a SYSTAB_ID; assign one if missing
ensure_job_id() {
local job_name="$1"
local existing_id
existing_id=$(get_job_id "$job_name")
if [[ -n "$existing_id" ]]; then
echo "$existing_id"
return
fi
local new_id
new_id=$(generate_short_id)
# Insert SYSTAB_ID line after the MARKER in both files
for ext in timer service; do
local unit_file="$SYSTEMD_USER_DIR/${job_name}.${ext}"
if [[ -f "$unit_file" ]]; then
sed -i "s/^$MARKER$/&\n# SYSTAB_ID=$new_id/" "$unit_file"
fi
done
echo "$new_id"
}
# Get all managed service files
get_managed_services() {
if [[ ! -d "$SYSTEMD_USER_DIR" ]]; then
@ -195,10 +237,15 @@ create_job() {
# Create systemd user directory if needed
mkdir -p "$SYSTEMD_USER_DIR"
# Generate short ID for human-friendly display
local short_id
short_id=$(generate_short_id)
# Create service file
local service_file="$SYSTEMD_USER_DIR/${job_name}.service"
cat > "$service_file" <<EOF
$MARKER
# SYSTAB_ID=$short_id
[Unit]
Description=$SCRIPT_NAME job: $job_name
@ -238,6 +285,7 @@ EOF
local timer_file="$SYSTEMD_USER_DIR/${job_name}.timer"
cat > "$timer_file" <<EOF
$MARKER
# SYSTAB_ID=$short_id
[Unit]
Description=Timer for $SCRIPT_NAME job: $job_name
@ -260,20 +308,83 @@ EOF
systemctl --user enable "$job_name.timer"
systemctl --user start "$job_name.timer"
echo "Job created: $job_name"
echo "Job created: $short_id ($job_name)"
echo "Next run: $(systemctl --user list-timers "$job_name.timer" --no-pager | tail -n 2 | head -n 1 | awk '{print $1, $2, $3}')"
}
# Remove a managed job (stop, disable, delete unit files)
remove_job() {
local job_name="$1"
systemctl --user stop "${job_name}.timer" 2>/dev/null || true
systemctl --user disable "${job_name}.timer" 2>/dev/null || true
rm -f "$SYSTEMD_USER_DIR/${job_name}.service" "$SYSTEMD_USER_DIR/${job_name}.timer"
}
# Create a job from edit mode (schedule + command, returns short ID)
create_job_from_edit() {
local schedule="$1" command_to_run="$2"
local job_name short_id
job_name=$(generate_job_name)
short_id=$(generate_short_id)
mkdir -p "$SYSTEMD_USER_DIR"
# Service file
cat > "$SYSTEMD_USER_DIR/${job_name}.service" <<EOF
$MARKER
# SYSTAB_ID=$short_id
[Unit]
Description=$SCRIPT_NAME job: $job_name
[Service]
Type=oneshot
ExecStart=${SHELL:-/bin/bash} -c '$command_to_run'
EOF
# Timer file
cat > "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
$MARKER
# SYSTAB_ID=$short_id
[Unit]
Description=Timer for $SCRIPT_NAME job: $job_name
[Timer]
OnCalendar=$schedule
EOF
if ! is_recurring "$schedule"; then
echo "Persistent=false" >> "$SYSTEMD_USER_DIR/${job_name}.timer"
fi
cat >> "$SYSTEMD_USER_DIR/${job_name}.timer" <<EOF
[Install]
WantedBy=timers.target
EOF
systemctl --user enable "$job_name.timer"
systemctl --user start "$job_name.timer"
echo "$short_id ($job_name)"
}
# Edit jobs in crontab-like format
edit_jobs() {
local temp_file
temp_file="${TMPDIR:-/dev/shm}/catbsysd_edit_$$"
trap 'rm -f "$temp_file"' EXIT
local temp_file orig_file
temp_file="${TMPDIR:-/dev/shm}/systab_edit_$$"
orig_file="${TMPDIR:-/dev/shm}/systab_orig_$$"
trap 'rm -f "$temp_file" "$orig_file"' EXIT
# Create temp file with header
echo "$CRONTAB_EXAMPLE" > "$temp_file"
# Build crontab content
{
cat <<'HEADER'
# systab jobs — edit schedule/command, add/remove lines
# Format: ID SCHEDULE COMMAND
# Lines starting with # are ignored. Remove a line to delete a job.
# Add a line with "new" as ID to create a job: new daily /path/to/cmd
HEADER
# Add existing jobs
local job
while IFS= read -r job; do
[[ -z "$job" ]] && continue
@ -282,24 +393,150 @@ edit_jobs() {
local service_file="$SYSTEMD_USER_DIR/${job}.service"
if [[ -f "$timer_file" && -f "$service_file" ]]; then
local schedule command
local short_id schedule command
short_id=$(ensure_job_id "$job")
schedule=$(grep "^OnCalendar=" "$timer_file" | cut -d= -f2-)
command=$(grep "^ExecStart=" "$service_file" | sed 's/^ExecStart=.*-c //' | sed "s/^'//" | sed "s/'$//")
printf '%s\t%s\t%s\n' "$short_id" "$schedule" "$command"
fi
done < <(get_managed_timers)
} > "$temp_file"
echo "# Job: $job"
echo "# Schedule: $schedule"
echo "# Command: $command"
echo ""
# Save original for diffing
cp "$temp_file" "$orig_file"
# Open in editor
if ! "${EDITOR:-vi}" "$temp_file"; then
echo "Editor exited with error, aborting."
return 1
fi
# Check for no changes
if diff -q "$orig_file" "$temp_file" &>/dev/null; then
echo "No changes."
return
fi
# Parse files into associative arrays: id -> "schedule\tcmd"
declare -A orig_jobs edited_jobs
# Also track id order for original (to map id -> job_name)
declare -A id_to_jobname
# Build id -> jobname mapping from current managed timers
local job
while IFS= read -r job; do
[[ -z "$job" ]] && continue
local jid
jid=$(get_job_id "$job")
if [[ -n "$jid" ]]; then
id_to_jobname["$jid"]="$job"
fi
done < <(get_managed_timers)
# Open in editor
"${EDITOR:-vi}" "$temp_file"
# Parse original file
while IFS= read -r line; do
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
local id sched cmd
id=$(printf '%s' "$line" | cut -f1)
sched=$(printf '%s' "$line" | cut -f2)
cmd=$(printf '%s' "$line" | cut -f3-)
[[ -n "$id" && -n "$sched" ]] || continue
orig_jobs["$id"]="${sched} ${cmd}"
done < "$orig_file"
# Note: Full parsing and updating would be complex
# For now, just show the jobs for reference
echo "Note: Editing support is view-only. Use -C to clean up jobs."
echo "Use command-line options to create new jobs."
# Parse edited file
while IFS= read -r line; do
[[ "$line" =~ ^#.*$ || -z "$line" ]] && continue
local id sched cmd
id=$(printf '%s' "$line" | cut -f1)
sched=$(printf '%s' "$line" | cut -f2)
cmd=$(printf '%s' "$line" | cut -f3-)
[[ -n "$id" && -n "$sched" ]] || continue
edited_jobs["$id"]="${sched} ${cmd}"
done < "$temp_file"
local created=0 deleted=0 updated=0 needs_reload=false
# Deletions: IDs in original but not in edited
for id in "${!orig_jobs[@]}"; do
if [[ -z "${edited_jobs[$id]+x}" ]]; then
local jname="${id_to_jobname[$id]:-}"
if [[ -n "$jname" ]]; then
remove_job "$jname"
echo "Deleted: $id ($jname)"
deleted=$((deleted + 1))
needs_reload=true
fi
fi
done
# Creations: IDs in edited but not in original (must be "new")
for id in "${!edited_jobs[@]}"; do
if [[ -z "${orig_jobs[$id]+x}" ]]; then
if [[ "$id" != "new" ]]; then
warn "Ignoring unknown ID '$id' — use 'new' to create jobs"
continue
fi
local entry="${edited_jobs[$id]}"
local sched cmd
sched=$(printf '%s' "$entry" | cut -f1)
cmd=$(printf '%s' "$entry" | cut -f2-)
local result
result=$(create_job_from_edit "$sched" "$cmd")
echo "Created: $result"
created=$((created + 1))
needs_reload=true
fi
done
# Updates: IDs in both but with changed values
for id in "${!edited_jobs[@]}"; do
[[ "$id" == "new" ]] && continue
if [[ -n "${orig_jobs[$id]+x}" && "${orig_jobs[$id]}" != "${edited_jobs[$id]}" ]]; then
local jname="${id_to_jobname[$id]:-}"
if [[ -z "$jname" ]]; then
warn "Cannot find job for ID '$id', skipping update"
continue
fi
local old_entry="${orig_jobs[$id]}" new_entry="${edited_jobs[$id]}"
local old_sched new_sched old_cmd new_cmd
old_sched=$(printf '%s' "$old_entry" | cut -f1)
new_sched=$(printf '%s' "$new_entry" | cut -f1)
old_cmd=$(printf '%s' "$old_entry" | cut -f2-)
new_cmd=$(printf '%s' "$new_entry" | cut -f2-)
local timer_file="$SYSTEMD_USER_DIR/${jname}.timer"
local service_file="$SYSTEMD_USER_DIR/${jname}.service"
if [[ "$old_sched" != "$new_sched" && -f "$timer_file" ]]; then
sed -i "s|^OnCalendar=.*|OnCalendar=$new_sched|" "$timer_file"
# Update Persistent line based on new schedule
if is_recurring "$new_sched"; then
sed -i '/^Persistent=false$/d' "$timer_file"
elif ! grep -q "^Persistent=false" "$timer_file"; then
sed -i "/^OnCalendar=/a Persistent=false" "$timer_file"
fi
fi
if [[ "$old_cmd" != "$new_cmd" && -f "$service_file" ]]; then
sed -i "s|^ExecStart=.*|ExecStart=${SHELL:-/bin/bash} -c '$new_cmd'|" "$service_file"
fi
systemctl --user restart "${jname}.timer" 2>/dev/null || true
echo "Updated: $id ($jname)"
updated=$((updated + 1))
needs_reload=true
fi
done
if $needs_reload; then
systemctl --user daemon-reload
fi
echo ""
echo "Summary: $created created, $updated updated, $deleted deleted"
}
# Show status of all managed jobs
@ -326,7 +563,13 @@ show_status() {
local timer_file="$SYSTEMD_USER_DIR/${job}.timer"
local service_file="$SYSTEMD_USER_DIR/${job}.service"
local short_id
short_id=$(get_job_id "$job")
if [[ -n "$short_id" ]]; then
echo "Job: $job (ID: $short_id)"
else
echo "Job: $job"
fi
# Get schedule from timer file
if [[ -f "$timer_file" ]]; then
@ -391,7 +634,7 @@ show_status() {
# Show systemctl list-timers summary
echo "Active Timers:"
echo "--------------"
systemctl --user list-timers "catbsysd_*" --no-pager 2>/dev/null || echo "none active"
systemctl --user list-timers "systab_*" --no-pager 2>/dev/null || echo "none active"
}
# List logs
@ -420,7 +663,13 @@ list_logs() {
echo ""
for job in "${job_list[@]}"; do
local short_id
short_id=$(get_job_id "$job")
if [[ -n "$short_id" ]]; then
echo "=== Logs for $job (ID: $short_id) ==="
else
echo "=== Logs for $job ==="
fi
# Show timer status
if systemctl --user is-active "${job}.timer" &>/dev/null; then