Files
agent-tools/cronr/main.go
claude 78c7b32eb2 cronr: recurring/one-shot jobs as systemd user timers
add (schedule validated by systemd-analyze, units + script printed),
list (next elapse + last result), run, logs, rm. Command stored as a
script so quoting never meets ExecStart; PATH includes ~/.local/bin
so agent tools resolve. cronr-* namespace guards foreign units.
Smoked live: add -> run -> journal -> rm. Spec: doc/tool-parity.md 3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
2026-08-07 00:42:57 +02:00

301 lines
8.4 KiB
Go

// cronr schedules recurring or one-shot jobs as systemd user timers,
// so an agent can set up work that survives session exit and reboot.
// No daemon of its own — systemd does the running. See
// doc/tool-parity.md §3.1.
package main
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"gitea.brasse-pc.eu/brasse/agent-tools/cronr/unit"
)
var version = "dev"
const usage = `cronr - agent-friendly scheduling via systemd user timers
Usage:
cronr add <name> --schedule "OnCalendar spec" --cmd "shell command"
cronr add <name> --at "YYYY-MM-DD HH:MM" --cmd "shell command" one-shot
cronr list all cronr jobs: schedule, next run, last result
cronr run <name> run the job now (does not touch the timer)
cronr logs <name> [--lines N] journal for the job
cronr rm <name> disable and delete the job
cronr version
Schedule examples (systemd OnCalendar; validated with systemd-analyze):
"*-*-* 07:00" every morning at 07:00
"Mon *-*-* 09:00" mondays 09:00
"*:0/15" every 15 minutes
The job's command is stored as a script in ~/.local/share/cronr/ and
runs with ~/.local/bin on PATH, so agent tools (notifyr, svgc, agy)
work as in a login shell. Everything cronr creates is namespaced
cronr-<name> — it never touches other units.
Example:
cronr add nightly-ci --schedule "*-*-* 07:00" --cmd 'agy -p "check CI and notify"'
`
func main() {
if len(os.Args) < 2 {
fmt.Print(usage)
os.Exit(2)
}
switch os.Args[1] {
case "add":
cmdAdd(os.Args[2:])
case "list":
cmdList()
case "run":
requireName(os.Args[2:], "run")
sh("systemctl", "--user", "start", unit.Prefix+os.Args[2]+".service")
fmt.Printf("started %s — see: cronr logs %s\n", os.Args[2], os.Args[2])
case "logs":
cmdLogs(os.Args[2:])
case "rm":
cmdRm(os.Args[2:])
case "version", "--version", "-v":
fmt.Println("cronr", version)
case "help", "--help", "-h":
fmt.Print(usage)
default:
die("unknown command %q — run 'cronr help'", os.Args[1])
}
}
func unitDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "systemd", "user")
}
func scriptDir() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".local", "share", "cronr")
}
func cmdAdd(args []string) {
fs := flag.NewFlagSet("add", flag.ExitOnError)
schedule := fs.String("schedule", "", "OnCalendar spec for recurring jobs")
at := fs.String("at", "", `one-shot time "YYYY-MM-DD HH:MM"`)
cmd := fs.String("cmd", "", "shell command the job runs (required)")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("add needs exactly one job name")
}
name := pos[0]
if !unit.ValidName(name) {
die("invalid job name %q (letters, digits, - and _)", name)
}
if *cmd == "" {
die("--cmd is required")
}
if (*schedule == "") == (*at == "") {
die("give exactly one of --schedule (recurring) or --at (one-shot)")
}
spec := *schedule
oneshot := false
if *at != "" {
var err error
spec, err = unit.AtToCalendar(*at)
if err != nil {
die("%v", err)
}
oneshot = true
}
// systemd itself is the authority on calendar specs
if out, err := exec.Command("systemd-analyze", "calendar", spec).CombinedOutput(); err != nil {
die("systemd rejects the schedule %q:\n%s", spec, strings.TrimSpace(string(out)))
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err == nil {
die("job %s already exists (cronr rm %s first)", name, name)
}
scriptPath := filepath.Join(scriptDir(), name+".sh")
if err := os.MkdirAll(scriptDir(), 0o755); err != nil {
die("%v", err)
}
if err := os.MkdirAll(unitDir(), 0o755); err != nil {
die("%v", err)
}
writes := []struct{ path, content string }{
{scriptPath, unit.Script(*cmd)},
{filepath.Join(unitDir(), svcName), unit.Service(name, scriptPath)},
{filepath.Join(unitDir(), tmrName), unit.Timer(name, spec, oneshot)},
}
for _, w := range writes {
mode := os.FileMode(0o644)
if strings.HasSuffix(w.path, ".sh") {
mode = 0o755
}
if err := os.WriteFile(w.path, []byte(w.content), mode); err != nil {
die("%v", err)
}
}
sh("systemctl", "--user", "daemon-reload")
sh("systemctl", "--user", "enable", "--now", tmrName)
fmt.Printf("job %s created and enabled\n\n", name)
for _, w := range writes {
fmt.Printf("--- %s ---\n%s\n", w.path, w.content)
}
fmt.Print(nextRun(spec))
}
func cmdList() {
matches, _ := filepath.Glob(filepath.Join(unitDir(), unit.Prefix+"*.timer"))
if len(matches) == 0 {
fmt.Println("no cronr jobs")
return
}
sort.Strings(matches)
fmt.Printf("%-24s %-22s %-26s %s\n", "NAME", "SCHEDULE", "NEXT", "LAST RESULT")
for _, m := range matches {
name := unit.JobName(m)
if name == "" {
continue
}
spec := ""
if data, err := os.ReadFile(m); err == nil {
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "OnCalendar=") {
spec = strings.TrimPrefix(line, "OnCalendar=")
}
}
}
next := strings.TrimPrefix(nextRun(spec), "next run: ")
svcName, _ := unit.UnitNames(name)
last := lastResult(svcName)
fmt.Printf("%-24s %-22s %-26s %s\n", name, spec, strings.TrimSpace(next), last)
}
}
// nextRun asks systemd-analyze when the spec fires next.
func nextRun(spec string) string {
out, err := exec.Command("systemd-analyze", "calendar", spec).Output()
if err != nil {
return "next run: ?\n"
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Next elapse:") {
return "next run: " + strings.TrimSpace(strings.TrimPrefix(line, "Next elapse:")) + "\n"
}
}
return "next run: never (already elapsed?)\n"
}
func lastResult(svcName string) string {
out, err := exec.Command("systemctl", "--user", "show", svcName,
"-p", "ExecMainStatus", "-p", "ExecMainExitTimestamp").Output()
if err != nil {
return "?"
}
status, when := "?", ""
for _, line := range strings.Split(string(out), "\n") {
if v, ok := strings.CutPrefix(line, "ExecMainStatus="); ok {
status = v
}
if v, ok := strings.CutPrefix(line, "ExecMainExitTimestamp="); ok {
when = v
}
}
if when == "" {
return "never ran"
}
if status == "0" {
return "ok (" + when + ")"
}
return "exit " + status + " (" + when + ")"
}
func cmdLogs(args []string) {
fs := flag.NewFlagSet("logs", flag.ExitOnError)
lines := fs.Int("lines", 50, "number of journal lines")
pos := parseInterspersed(fs, args)
if len(pos) != 1 {
die("logs needs exactly one job name")
}
svcName, _ := unit.UnitNames(pos[0])
c := exec.Command("journalctl", "--user", "-u", svcName, "-n", fmt.Sprint(*lines), "--no-pager")
c.Stdout, c.Stderr = os.Stdout, os.Stderr
c.Run()
}
func cmdRm(args []string) {
requireName(args, "rm")
name := args[0]
if !unit.ValidName(name) {
die("invalid job name %q", name)
}
svcName, tmrName := unit.UnitNames(name)
if _, err := os.Stat(filepath.Join(unitDir(), tmrName)); err != nil {
die("no such job %s", name)
}
sh("systemctl", "--user", "disable", "--now", tmrName)
for _, p := range []string{
filepath.Join(unitDir(), tmrName),
filepath.Join(unitDir(), svcName),
filepath.Join(scriptDir(), name+".sh"),
} {
os.Remove(p)
}
sh("systemctl", "--user", "daemon-reload")
fmt.Printf("job %s removed\n", name)
}
func requireName(args []string, cmd string) {
if len(args) < 1 || strings.HasPrefix(args[0], "-") {
die("%s needs a job name", cmd)
}
}
// sh runs a command, dying with its output on failure — every call
// here is a systemctl whose failure should stop the operation.
func sh(name string, args ...string) {
out, err := exec.Command(name, args...).CombinedOutput()
if err != nil {
die("%s %s: %v\n%s", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
}
// parseInterspersed lets flags appear before or after positional args.
func parseInterspersed(fs *flag.FlagSet, args []string) []string {
var flags, pos []string
for i := 0; i < len(args); i++ {
a := args[i]
if len(a) > 1 && a[0] == '-' {
flags = append(flags, a)
name := strings.TrimLeft(a, "-")
if eq := strings.Index(name, "="); eq >= 0 {
continue
}
if f := fs.Lookup(name); f != nil {
if bf, ok := f.Value.(interface{ IsBoolFlag() bool }); ok && bf.IsBoolFlag() {
continue
}
}
if i+1 < len(args) {
i++
flags = append(flags, args[i])
}
} else {
pos = append(pos, a)
}
}
fs.Parse(flags)
return pos
}
func die(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "cronr: "+format+"\n", args...)
os.Exit(1)
}