2026-07-02 19:36:24 +02:00
|
|
|
{
|
|
|
|
|
lib,
|
|
|
|
|
stdenv,
|
|
|
|
|
writeShellScriptBin,
|
|
|
|
|
coreutils,
|
|
|
|
|
bash,
|
|
|
|
|
}: let
|
|
|
|
|
script = writeShellScriptBin "crunch" ''
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
# crunch — Voice-notified reminders & AI crunch jobs
|
|
|
|
|
# via systemd transient timers.
|
|
|
|
|
#
|
|
|
|
|
# Requires: talk (ElevenLabs TTS) in PATH, ELEVENLABS_API_KEY env.
|
|
|
|
|
# Optional: pi, opencode for AI crunch mode.
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
TALK="''${TALK:-talk}"
|
|
|
|
|
SELF="$(readlink -f "''${BASH_SOURCE[0]:-$0}")"
|
|
|
|
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
usage() {
|
|
|
|
|
cat <<'EOF'
|
|
|
|
|
crunch — voice-notified reminders and AI jobs via systemd timers
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
crunch at <time> <message> One-shot (14:30 or "2026-07-03 14:30")
|
|
|
|
|
crunch in <duration> <message> One-shot after delay (30m, 2h, 1h30m)
|
|
|
|
|
crunch daily <time> <message> Recurring daily at time
|
|
|
|
|
crunch weekly <day> <time> <message> Recurring weekly (Mon or Montag)
|
|
|
|
|
crunch ai <engine> <type> <time> <prompt> AI crunch (pi|opencode)
|
2026-07-02 19:54:59 +02:00
|
|
|
crunch script <type> <time> <script.sh> [args] Custom script → talk
|
2026-07-02 19:36:24 +02:00
|
|
|
crunch list List active jobs
|
|
|
|
|
crunch cancel <name> Cancel a job
|
|
|
|
|
crunch purge Remove fired one-shot jobs
|
|
|
|
|
|
|
|
|
|
Examples:
|
|
|
|
|
crunch at "14:30" "Müll rausbringen"
|
|
|
|
|
crunch at "2026-07-03 10:00" "Zahnarzttermin"
|
|
|
|
|
crunch in "30m" "Build checken"
|
|
|
|
|
crunch daily "09:00" "Daily standup"
|
|
|
|
|
crunch weekly "Mon 09:00" "Weekly review"
|
|
|
|
|
crunch weekly "Montag 09:00" "Weekly review"
|
|
|
|
|
crunch ai pi in "1h" "Fasse neueste Commits zusammen"
|
|
|
|
|
crunch ai opencode daily "08:00" "Review offene Issues"
|
2026-07-02 19:54:59 +02:00
|
|
|
crunch script daily "08:00" ~/scripts/basecamp-daily.sh
|
2026-07-02 19:36:24 +02:00
|
|
|
crunch list
|
|
|
|
|
crunch cancel mll-rausbringen-12345
|
|
|
|
|
|
|
|
|
|
Environment:
|
|
|
|
|
VOICE_NOTIFY=0 Silence voice output (passed to talk)
|
|
|
|
|
ELEVENLABS_API_KEY Required by talk
|
|
|
|
|
TALK Override path to talk binary
|
|
|
|
|
EOF
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# German → systemd day abbreviations
|
|
|
|
|
norm_day() {
|
|
|
|
|
local d="''${1,,}"
|
|
|
|
|
d="''${d%%.*}" # strip trailing dot (German abbreviation)
|
|
|
|
|
case "$d" in
|
|
|
|
|
mo|mon|monday|montag) echo "Mon" ;;
|
|
|
|
|
di|tue|tues|tuesday|dienstag) echo "Tue" ;;
|
|
|
|
|
mi|wed|weds|wednesday|mittwoch) echo "Wed" ;;
|
|
|
|
|
do|thu|thur|thurs|thursday|donnerstag) echo "Thu" ;;
|
|
|
|
|
fr|fri|friday|freitag) echo "Fri" ;;
|
|
|
|
|
sa|sat|saturday|samstag) echo "Sat" ;;
|
|
|
|
|
so|su|sun|sunday|sonntag) echo "Sun" ;;
|
|
|
|
|
*) echo "''${1^}" ;;
|
|
|
|
|
esac
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
make_name() {
|
|
|
|
|
local msg="''${1:?No message}"
|
|
|
|
|
local slug
|
|
|
|
|
slug="''${msg,,}"
|
|
|
|
|
slug="''${slug//[^a-z0-9 ]/}"
|
|
|
|
|
slug="''${slug// /-}"
|
|
|
|
|
slug="''${slug:0:25}"
|
|
|
|
|
slug="''${slug%-}" # trim trailing dash
|
|
|
|
|
echo "crunch-''${slug:-job}-$RANDOM"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# schedule <type> <time_spec> <exec_cmd> <name>
|
|
|
|
|
schedule() {
|
|
|
|
|
local type="$1" time_spec="$2" exec_cmd="$3" name="$4"
|
|
|
|
|
local timer_flag=""
|
|
|
|
|
|
|
|
|
|
case "$type" in
|
|
|
|
|
at)
|
|
|
|
|
if [[ "$time_spec" == *' '* ]]; then
|
|
|
|
|
# "2026-07-03 14:30" → specific date
|
|
|
|
|
timer_flag="--on-calendar=''${time_spec}:00"
|
|
|
|
|
else
|
|
|
|
|
# "14:30" → next matching time
|
|
|
|
|
timer_flag="--on-calendar=*-*-* ''${time_spec}:00"
|
|
|
|
|
fi
|
|
|
|
|
;;
|
|
|
|
|
in)
|
|
|
|
|
timer_flag="--on-active=''${time_spec}"
|
|
|
|
|
;;
|
|
|
|
|
daily)
|
|
|
|
|
timer_flag="--on-calendar=*-*-* ''${time_spec}:00"
|
|
|
|
|
;;
|
|
|
|
|
weekly)
|
|
|
|
|
local day time_part
|
|
|
|
|
day="$(norm_day "''${time_spec%% *}")"
|
|
|
|
|
time_part="''${time_spec##* }"
|
|
|
|
|
timer_flag="--on-calendar=''${day} *-*-* ''${time_part}:00"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "Unknown schedule type: $type" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
systemd-run --user \
|
|
|
|
|
--unit="''${name}" \
|
|
|
|
|
--description="crunch job" \
|
|
|
|
|
"''${timer_flag}" \
|
|
|
|
|
bash -lc "''${exec_cmd}" \
|
|
|
|
|
&& echo "✓ Scheduled: $name"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ── Internal execution (called by systemd) ────────────────────
|
|
|
|
|
|
|
|
|
|
case "''${1:-}" in
|
|
|
|
|
_say)
|
|
|
|
|
shift
|
|
|
|
|
exec "$TALK" "$*"
|
|
|
|
|
;;
|
|
|
|
|
_ai)
|
|
|
|
|
engine="$2"; shift 2
|
|
|
|
|
case "$engine" in
|
|
|
|
|
pi|pi-agent)
|
|
|
|
|
pi -p "$*" | "$TALK"
|
|
|
|
|
;;
|
|
|
|
|
opencode|oc)
|
|
|
|
|
opencode run "$*" -q | "$TALK"
|
|
|
|
|
;;
|
|
|
|
|
*)
|
|
|
|
|
echo "Unknown engine: $engine (use: pi, opencode)" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
exit $?
|
|
|
|
|
;;
|
2026-07-02 19:54:59 +02:00
|
|
|
_script)
|
|
|
|
|
script_path="$2"; shift 2
|
|
|
|
|
rc=0
|
|
|
|
|
output=$(bash "$script_path" "$@" 2>&1) || rc=$?
|
|
|
|
|
if [ -n "''${output:-}" ]; then
|
|
|
|
|
printf '%s' "$output" | "$TALK"
|
|
|
|
|
fi
|
|
|
|
|
exit "$rc"
|
|
|
|
|
;;
|
2026-07-02 19:36:24 +02:00
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
# ── CLI ───────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
[ $# -lt 1 ] && { usage; exit 0; }
|
|
|
|
|
|
|
|
|
|
cmd="$1"; shift
|
|
|
|
|
|
|
|
|
|
case "$cmd" in
|
|
|
|
|
# ── Simple voice reminders ──
|
|
|
|
|
at)
|
|
|
|
|
[ $# -lt 2 ] && { echo "Usage: crunch at <time> <message>" >&2; exit 1; }
|
|
|
|
|
time_spec="$1"; shift
|
|
|
|
|
msg="$*"
|
|
|
|
|
name="$(make_name "$msg")"
|
|
|
|
|
schedule at "$time_spec" "$SELF _say $(printf '%q' "$msg")" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
in)
|
|
|
|
|
[ $# -lt 2 ] && { echo "Usage: crunch in <duration> <message>" >&2; exit 1; }
|
|
|
|
|
duration="$1"; shift
|
|
|
|
|
msg="$*"
|
|
|
|
|
name="$(make_name "$msg")"
|
|
|
|
|
schedule in "$duration" "$SELF _say $(printf '%q' "$msg")" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
daily)
|
|
|
|
|
[ $# -lt 2 ] && { echo "Usage: crunch daily <time> <message>" >&2; exit 1; }
|
|
|
|
|
time_spec="$1"; shift
|
|
|
|
|
msg="$*"
|
|
|
|
|
name="$(make_name "$msg")"
|
|
|
|
|
schedule daily "$time_spec" "$SELF _say $(printf '%q' "$msg")" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
weekly)
|
|
|
|
|
[ $# -lt 2 ] && { echo "Usage: crunch weekly <day> <time> <message>" >&2; exit 1; }
|
|
|
|
|
day_time="$1"; shift
|
|
|
|
|
msg="$*"
|
|
|
|
|
name="$(make_name "$msg")"
|
|
|
|
|
schedule weekly "$day_time" "$SELF _say $(printf '%q' "$msg")" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
# ── AI crunch jobs ──
|
|
|
|
|
ai)
|
|
|
|
|
[ $# -lt 4 ] && { echo "Usage: crunch ai <pi|opencode> <at|in|daily|weekly> <time> <prompt>" >&2; exit 1; }
|
|
|
|
|
engine="$1"; sub_type="$2"; time_spec="$3"; shift 3
|
|
|
|
|
prompt="$*"
|
|
|
|
|
name="$(make_name "$prompt")"
|
|
|
|
|
schedule "$sub_type" "$time_spec" \
|
|
|
|
|
"$SELF _ai $engine $(printf '%q' "$prompt")" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
2026-07-02 19:54:59 +02:00
|
|
|
# ── Custom script crunch jobs ──
|
|
|
|
|
script)
|
|
|
|
|
[ $# -lt 3 ] && { echo "Usage: crunch script <at|in|daily|weekly> <time> <script.sh> [args...]" >&2; exit 1; }
|
|
|
|
|
sub_type="$1"; time_spec="$2"; script_path="$3"; shift 3
|
|
|
|
|
script_path="$(readlink -f "$script_path")"
|
|
|
|
|
[ ! -f "$script_path" ] && { echo "Script not found: $script_path" >&2; exit 1; }
|
|
|
|
|
name="$(make_name "$(basename "$script_path" .sh)")"
|
|
|
|
|
exec_cmd="$SELF _script $(printf '%q' "$script_path")"
|
|
|
|
|
if [ $# -gt 0 ]; then
|
|
|
|
|
exec_cmd+=" $(printf '%q ' "$@")"
|
|
|
|
|
fi
|
|
|
|
|
schedule "$sub_type" "$time_spec" "$exec_cmd" "$name"
|
|
|
|
|
;;
|
|
|
|
|
|
2026-07-02 19:36:24 +02:00
|
|
|
# ── Management ──
|
|
|
|
|
list)
|
|
|
|
|
echo "Active crunch jobs:"
|
|
|
|
|
echo "───────────────────────────────────"
|
|
|
|
|
systemctl --user list-timers 'crunch-*' --no-pager 2>/dev/null \
|
|
|
|
|
| grep -E 'crunch-|^NEXT|^NEXT|^$' || echo " (none)"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
cancel)
|
|
|
|
|
[ $# -lt 1 ] && { echo "Usage: crunch cancel <name>" >&2; exit 1; }
|
|
|
|
|
name="$1"
|
|
|
|
|
# Accept with or without crunch- prefix
|
|
|
|
|
[[ "$name" != crunch-* ]] && name="crunch-$name"
|
|
|
|
|
systemctl --user stop "''${name}.timer" "''${name}.service" 2>/dev/null \
|
|
|
|
|
&& echo "✓ Cancelled: $name" \
|
|
|
|
|
|| echo "✗ Not found: $name"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
purge)
|
|
|
|
|
echo "Purging fired one-shot jobs..."
|
|
|
|
|
local purged=0
|
|
|
|
|
for unit in $(systemctl --user list-units 'crunch-*' --all --plain --no-legend --no-pager 2>/dev/null | awk '{print $1}'); do
|
|
|
|
|
if systemctl --user is-failed "$unit" &>/dev/null; then
|
|
|
|
|
systemctl --user stop "$unit" 2>/dev/null || true
|
|
|
|
|
systemctl --user reset-failed "$unit" 2>/dev/null || true
|
|
|
|
|
echo " Purged: $unit"
|
|
|
|
|
purged=$((purged + 1))
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
echo "✓ Purged $purged unit(s)"
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
-h|--help|help)
|
|
|
|
|
usage
|
|
|
|
|
;;
|
|
|
|
|
|
|
|
|
|
*)
|
|
|
|
|
echo "Unknown command: $cmd" >&2
|
|
|
|
|
echo ""
|
|
|
|
|
usage
|
|
|
|
|
exit 1
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
'';
|
|
|
|
|
in
|
|
|
|
|
stdenv.mkDerivation {
|
|
|
|
|
pname = "crunch";
|
|
|
|
|
version = "0.1.0";
|
|
|
|
|
|
|
|
|
|
dontUnpack = true;
|
|
|
|
|
|
|
|
|
|
installPhase = ''
|
|
|
|
|
mkdir -p "$out/bin"
|
|
|
|
|
ln -s ${script}/bin/crunch "$out/bin/crunch"
|
|
|
|
|
'';
|
|
|
|
|
|
|
|
|
|
meta = with lib; {
|
|
|
|
|
description = "Voice-notified reminders and AI crunch jobs via systemd transient timers";
|
|
|
|
|
homepage = "https://code.m3ta.dev/m3tam3re/nixpkgs";
|
|
|
|
|
license = licenses.mit;
|
|
|
|
|
platforms = platforms.linux;
|
|
|
|
|
mainProgram = "crunch";
|
|
|
|
|
};
|
|
|
|
|
}
|