mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
From the 2026-06-21 behavioral audit. Each verified by tests + the real binary. - exec: accept the advertised `-p`/`-p=` short flag in the exec subcommand parser; previously only `--prompt` worked even though top-level help advertises `-p`, so the documented `zero exec -p "..."` form errored (internal/cli/exec_parse.go). - version: `version --help` prints a usage line instead of the version string (internal/cli/app.go). - specialist: `specialist edit <builtin>` explains builtins are read-only instead of the misleading "specialist not found" (internal/cli/specialist.go). - cron: `pause`/`resume`/`rm` now print a success confirmation (rm's verb arg was previously dead) (internal/cli/cron.go). - update_plan: coerce non-canonical statuses (e.g. "done", "in-progress") to the nearest canonical value instead of failing the whole call, and keep at most one in_progress item (internal/tools/update_plan.go + tests). - build: add a Makefile (build/test/lint/...) backing AGENTS.MD's `make`/`make lint`. Investigated but intentionally NOT changed (guarded by existing tests): - write_stdin AdvertiseInAuto — TestCoreToolsExposeShellTools asserts it stays visible in auto mode for polling/interrupts. - --list-tools not resolving config (so tool_search is absent there) — TestRunExecListsMCPToolsWithoutProviderResolution asserts list-tools must not resolve provider config. Out of scope: LICENSE (owner decision), provider-resolve startup crash (PR #283), OAuth default storage + MCP auto-reconnect (design choices).
298 lines
8.5 KiB
Go
298 lines
8.5 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Gitlawb/zero/internal/cron"
|
|
)
|
|
|
|
// runCron is the dispatch entry for `zero cron`.
|
|
func runCron(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
now := time.Now
|
|
if deps.now != nil {
|
|
now = deps.now
|
|
}
|
|
store := cron.NewStore(cron.StoreOptions{Now: now})
|
|
return runCronWith(store, now, args, stdout, stderr)
|
|
}
|
|
|
|
// runCronWith is the testable core (store + clock injected). `run` is handled in
|
|
// cron_run.go (Task 6).
|
|
func runCronWith(store *cron.Store, now func() time.Time, args []string, stdout io.Writer, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(stderr, "Usage: zero cron <add|list|rm|pause|resume|run> ...")
|
|
return exitUsage
|
|
}
|
|
sub, rest := args[0], args[1:]
|
|
switch sub {
|
|
case "-h", "--help", "help":
|
|
writeCronHelp(stdout)
|
|
return exitSuccess
|
|
case "add":
|
|
return cronAdd(store, now, rest, stdout, stderr)
|
|
case "list", "ls":
|
|
return cronList(store, now, stdout, stderr)
|
|
case "rm", "remove":
|
|
return cronSimple(store, rest, stdout, stderr, func(id string) error { return store.Remove(id) }, "Removed")
|
|
case "pause":
|
|
return cronSetStatus(store, rest, stdout, stderr, cron.StatusPaused, "Paused")
|
|
case "resume":
|
|
return cronResume(store, now, rest, stdout, stderr)
|
|
case "run":
|
|
return cronRun(store, now, rest, stdout, stderr, Run)
|
|
default:
|
|
fmt.Fprintf(stderr, "Unknown cron subcommand: %s\n", sub)
|
|
return exitUsage
|
|
}
|
|
}
|
|
|
|
func cronAdd(store *cron.Store, now func() time.Time, args []string, stdout io.Writer, stderr io.Writer) int {
|
|
var expr, prompt, recipe, cwd, model string
|
|
runNow := false
|
|
var positional []string
|
|
for i := 0; i < len(args); i++ {
|
|
switch a := args[i]; {
|
|
case a == "--prompt":
|
|
v, n, err := nextFlagValue(args, i, a)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
prompt, i = v, n
|
|
case strings.HasPrefix(a, "--prompt="):
|
|
prompt = strings.TrimSpace(strings.TrimPrefix(a, "--prompt="))
|
|
case a == "--recipe":
|
|
v, n, err := nextFlagValue(args, i, a)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
recipe, i = v, n
|
|
case strings.HasPrefix(a, "--recipe="):
|
|
recipe = strings.TrimSpace(strings.TrimPrefix(a, "--recipe="))
|
|
case a == "--cwd":
|
|
v, n, err := nextFlagValue(args, i, a)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
cwd, i = v, n
|
|
case strings.HasPrefix(a, "--cwd="):
|
|
cwd = strings.TrimSpace(strings.TrimPrefix(a, "--cwd="))
|
|
case a == "--model":
|
|
v, n, err := nextFlagValue(args, i, a)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
model, i = v, n
|
|
case strings.HasPrefix(a, "--model="):
|
|
model = strings.TrimSpace(strings.TrimPrefix(a, "--model="))
|
|
case a == "--run-now":
|
|
runNow = true
|
|
case strings.HasPrefix(a, "-"):
|
|
fmt.Fprintf(stderr, "Unknown cron add flag: %s\n", a)
|
|
return exitUsage
|
|
default:
|
|
positional = append(positional, a)
|
|
}
|
|
}
|
|
|
|
if len(positional) > 1 {
|
|
fmt.Fprintf(stderr, "Unexpected extra arguments: %s\n", strings.Join(positional[1:], " "))
|
|
return exitUsage
|
|
}
|
|
// Consume an explicit positional expression BEFORE applying recipe defaults,
|
|
// so `cron add "0 9 * * *" --recipe X` keeps the user's schedule instead of
|
|
// silently dropping it in favor of the recipe's expr.
|
|
if len(positional) == 1 && expr == "" {
|
|
expr = positional[0]
|
|
}
|
|
if recipe != "" {
|
|
r, ok := cron.Recipe(recipe)
|
|
if !ok {
|
|
fmt.Fprintf(stderr, "Unknown recipe %q. Available: %s\n", recipe, recipeIDs())
|
|
return exitUsage
|
|
}
|
|
if expr == "" {
|
|
expr = r.Expr
|
|
}
|
|
if prompt == "" {
|
|
prompt = r.Prompt
|
|
}
|
|
}
|
|
if expr == "" {
|
|
fmt.Fprintln(stderr, "A cron expression is required (e.g. `zero cron add \"0 9 * * *\" --prompt ...`).")
|
|
return exitUsage
|
|
}
|
|
schedule, err := cron.Parse(expr)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
if prompt == "" {
|
|
home, _ := os.UserHomeDir()
|
|
jobCwd := cwd
|
|
if jobCwd == "" {
|
|
jobCwd, _ = os.Getwd()
|
|
}
|
|
prompt, err = cron.ResolveLoopPrompt(jobCwd, home)
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitCrash
|
|
}
|
|
}
|
|
// Reject an impossible schedule (e.g. "0 0 30 2 *") regardless of --run-now,
|
|
// so a job that can never advance is never persisted (it would otherwise
|
|
// re-fire every tick under `cron run`).
|
|
next := schedule.Next(now())
|
|
if next.IsZero() {
|
|
fmt.Fprintln(stderr, "That schedule never fires.")
|
|
return exitUsage
|
|
}
|
|
if runNow {
|
|
next = now()
|
|
}
|
|
job, err := store.Add(cron.Job{Expr: expr, Prompt: prompt, Cwd: cwd, Model: model, Status: cron.StatusActive, NextRunAt: next})
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitCrash
|
|
}
|
|
fmt.Fprintf(stdout, "Added cron job %s (%s); next run %s.\n", job.ID, expr, formatCronTime(job.NextRunAt))
|
|
fmt.Fprintln(stdout, "Note: the prompt is stored in plaintext on disk — do not embed secrets.")
|
|
return exitSuccess
|
|
}
|
|
|
|
func cronList(store *cron.Store, now func() time.Time, stdout io.Writer, stderr io.Writer) int {
|
|
jobs, err := store.List()
|
|
if err != nil {
|
|
// A corrupt job is surfaced as a warning; the listable jobs are still shown.
|
|
fmt.Fprintln(stderr, "warning:", err.Error())
|
|
}
|
|
if len(jobs) == 0 {
|
|
fmt.Fprintln(stdout, "No scheduled jobs.")
|
|
return exitSuccess
|
|
}
|
|
sort.Slice(jobs, func(i, j int) bool {
|
|
ai, aj := jobs[i].Status == cron.StatusActive, jobs[j].Status == cron.StatusActive
|
|
if ai != aj {
|
|
return ai // active first
|
|
}
|
|
if !jobs[i].NextRunAt.Equal(jobs[j].NextRunAt) {
|
|
return jobs[i].NextRunAt.Before(jobs[j].NextRunAt)
|
|
}
|
|
return jobs[i].ID < jobs[j].ID
|
|
})
|
|
for _, j := range jobs {
|
|
fmt.Fprintf(stdout, "%s · %s · %s (next: %s) · #%d · %s\n",
|
|
j.ID, j.Status, j.Expr, formatCronTime(j.NextRunAt), j.FireCount, promptExcerpt(j.Prompt))
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func cronSimple(store *cron.Store, args []string, stdout io.Writer, stderr io.Writer, fn func(string) error, verb string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(stderr, "An id is required.")
|
|
return exitUsage
|
|
}
|
|
if err := fn(args[0]); err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
fmt.Fprintf(stdout, "%s cron job %s.\n", verb, args[0])
|
|
return exitSuccess
|
|
}
|
|
|
|
func cronSetStatus(store *cron.Store, args []string, stdout io.Writer, stderr io.Writer, status string, verb string) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(stderr, "An id is required.")
|
|
return exitUsage
|
|
}
|
|
job, err := store.Get(args[0])
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
job.Status = status
|
|
if err := store.Update(job); err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitCrash
|
|
}
|
|
fmt.Fprintf(stdout, "%s cron job %s.\n", verb, job.ID)
|
|
return exitSuccess
|
|
}
|
|
|
|
// cronResume sets a job active and recomputes NextRunAt.
|
|
func cronResume(store *cron.Store, now func() time.Time, args []string, stdout io.Writer, stderr io.Writer) int {
|
|
if len(args) == 0 {
|
|
fmt.Fprintln(stderr, "An id is required.")
|
|
return exitUsage
|
|
}
|
|
job, err := store.Get(args[0])
|
|
if err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitUsage
|
|
}
|
|
job.Status = cron.StatusActive
|
|
// Don't reactivate a job whose schedule can't advance (corrupt expr or an
|
|
// impossible spec) — it would leave a stale/zero NextRunAt for the runner.
|
|
sched, perr := cron.Parse(job.Expr)
|
|
if perr != nil {
|
|
fmt.Fprintln(stderr, perr.Error())
|
|
return exitUsage
|
|
}
|
|
next := sched.Next(now())
|
|
if next.IsZero() {
|
|
fmt.Fprintln(stderr, "That schedule never fires.")
|
|
return exitUsage
|
|
}
|
|
job.NextRunAt = next
|
|
if err := store.Update(job); err != nil {
|
|
fmt.Fprintln(stderr, err.Error())
|
|
return exitCrash
|
|
}
|
|
fmt.Fprintf(stdout, "Resumed cron job %s (next run %s).\n", job.ID, next.Format(time.RFC3339))
|
|
return exitSuccess
|
|
}
|
|
|
|
func recipeIDs() string {
|
|
var ids []string
|
|
for _, r := range cron.Recipes() {
|
|
ids = append(ids, r.ID)
|
|
}
|
|
return strings.Join(ids, ", ")
|
|
}
|
|
|
|
func formatCronTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
return t.UTC().Format("2006-01-02 15:04 MST")
|
|
}
|
|
|
|
func promptExcerpt(p string) string {
|
|
p = strings.TrimSpace(strings.ReplaceAll(p, "\n", " "))
|
|
if len(p) > 48 {
|
|
return cutRuneBoundary(p, 47) + "…"
|
|
}
|
|
return p
|
|
}
|
|
|
|
func writeCronHelp(w io.Writer) {
|
|
fmt.Fprint(w, `zero cron — schedule agent jobs (foreground, file-backed)
|
|
|
|
Usage:
|
|
zero cron add <cron-expr> [--prompt P | --recipe R] [--cwd D] [--model M] [--run-now]
|
|
zero cron list
|
|
zero cron pause <id> | resume <id> | rm <id>
|
|
zero cron run [--once] [--catch-up] [id...]
|
|
|
|
Cron expression: standard 5 fields "minute hour day-of-month month day-of-week".
|
|
`)
|
|
}
|