Skip to main content

sloop/
cli.rs

1use std::ffi::OsString;
2use std::fmt;
3use std::io::{BufRead, BufReader, Write};
4use std::os::unix::net::UnixStream;
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::str::FromStr;
8
9use clap::error::{ContextKind, ContextValue, ErrorKind};
10use clap::{
11    ArgGroup, Args, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
12};
13use serde_json::json;
14
15use crate::protocol::{
16    ConfidenceValue, EmptyArgs, ErrorBody, ErrorCode, EventsArgs, ListArgs, LogsArgs, NoteArgs,
17    PostArgs, PostTrigger, Request, RequestEnvelope, RequestId, ResponseEnvelope, RunArgs,
18    RunReferenceArgs, RunTrigger, ShowArgs, StopArgs, TicketReferenceArgs, VerdictArgs,
19    VerdictValue,
20};
21use crate::templates::TemplateKind;
22
23/// `ready` is the line people misread: it is a precondition, not a promise.
24/// Nothing dispatches until a trigger is queued for the ticket, so the state
25/// says that before it says anything else.
26const TICKET_STATES_HELP: &str = "Ticket states:
27  ready         Nothing is stopping it - but it runs only once a trigger is
28                queued for it and the gates are open. `sloop run` queues one.
29  held          Prevented from running by an operator; release with `sloop ready`.
30  blocked       Waiting for every ticket in `blocked_by` to be merged.
31  claimed       Owned by an active run, including recovery.
32  merged        Terminal: completed work was integrated into the default branch.
33  failed        Terminal: the run did not succeed; `sloop retry` returns it to
34                ready, and `sloop run` is what starts it again.
35  needs_review  Terminal: the run could not be merged; inspect manually.";
36
37const SHOW_LONG_ABOUT: &str =
38    "Show the daemon, tickets, runs, and projects - sloop's one read verb.
39
40REF_OR_PATTERN is resolved in this order; first match wins:
41
42  1. nothing       -> the dashboard: daemon state plus recent tickets
43  2. an exact ref  -> the full detail view for that thing
44  3. anything else -> a filter: matching tickets, newest first
45
46A ref is an exact ticket id (TICK-12), run alias (TICK-12-r1), run-id prefix
47(749048f4), ticket name, or project id. An exact match always wins over pattern
48interpretation.
49
50A pattern matches ticket ids and names case-insensitively. Plain text is a
51substring; regex metacharacters make it an unanchored regex, like grep. Quote
52regexes in your shell: 'log.*'. A pattern always renders the list view, even
53when it matches one ticket.
54
55A run's detail is its stage table: one row per stage execution, suffixed `#N`
56past the first attempt, with advisory failures marked `advisory` and any
57panel's seats listed under their stage. `sloop logs <run> --stage <name>#<N>`
58reads the output behind one of those rows.
59
60--follow streams events for the shown scope. Ticket and run scopes exit when
61they settle; dashboard, pattern, and project scopes run until interrupted.
62--follow --quiet suppresses the stream and only returns the outcome.
63
64EXIT CODES:
65  0  shown successfully, or followed scope merged
66  1  followed scope reached any other terminal outcome, or daemon error
67  2  usage error, invalid pattern, or deprecated wait-alias timeout";
68
69const SHOW_EXAMPLES: &str = "Examples:
70  sloop show                      dashboard: daemon plus recent tickets
71  sloop show -5                   dashboard with the 5 newest tickets
72  sloop show TICK-12              everything about one ticket, including runs
73  sloop show verdict              tickets mentioning \"verdict\"
74  sloop show 'flow|merge' -5      5 newest tickets matching a regex
75  sloop show TICK-12 --follow     watch a ticket until it settles
76  sloop show TICK-12 -f -q        block silently; exit code is the outcome";
77
78#[derive(Debug, Parser)]
79#[command(
80    name = "sloop",
81    version,
82    about = "Schedule coding agents",
83    color = ColorChoice::Never
84)]
85pub struct Cli {
86    #[command(subcommand)]
87    pub command: Option<Command>,
88    /// Emit JSON envelopes instead of human-readable output.
89    #[arg(long, global = true)]
90    pub json: bool,
91}
92
93/// How responses are written. Envelopes are always produced internally;
94/// `Human` translates them at the final write.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96enum OutputMode {
97    Json,
98    Human,
99}
100
101#[derive(Debug, Subcommand)]
102pub enum Command {
103    /// Scaffold a Sloop project.
104    Init,
105    /// Print a commented canonical template for a file you author.
106    ///
107    /// Static compiled-in content: no daemon is contacted or started, and
108    /// nothing is written. Redirect it where you want the file, for example
109    /// `sloop template ticket > .agents/sloop/tickets/my-ticket.md`.
110    Template {
111        /// The file kind to print.
112        #[arg(value_name = "KIND")]
113        kind: TemplateKind,
114    },
115    /// Ensure the daemon is running.
116    Daemon(DaemonCliArgs),
117    /// Register a ticket file.
118    Post(PostCliArgs),
119    /// Enqueue a run.
120    #[command(hide = true)]
121    Run(RunCliArgs),
122    /// Make a failed ticket ready to run again.
123    #[command(hide = true)]
124    Retry { ticket: String },
125    /// Prevent a ready ticket from being dispatched.
126    #[command(hide = true)]
127    Hold { ticket: String },
128    /// Release a held ticket for dispatch.
129    #[command(hide = true)]
130    Ready { ticket: String },
131    /// List ticket names, states, and why they are not running.
132    ///
133    /// Tickets are ordered by registration time, newest first, whatever their
134    /// state. Pass `--limit <N>`, `-n <N>`, or the `tail`-style shorthand
135    /// `-<N>` to see only the N newest.
136    #[command(hide = true)]
137    List(ListCliArgs),
138    /// Show daemon state.
139    #[command(hide = true)]
140    Status,
141    /// Stop spawning new agents.
142    #[command(hide = true)]
143    Pause,
144    /// Resume spawning agents.
145    #[command(hide = true)]
146    Resume,
147    /// Stop the daemon.
148    #[command(hide = true)]
149    Stop {
150        /// Cancel active runs instead of refusing to stop.
151        #[arg(long)]
152        force: bool,
153    },
154    /// Cancel a run and preserve its worktree.
155    #[command(hide = true)]
156    Cancel {
157        /// Run alias, ticket reference, or run-id prefix.
158        run: String,
159    },
160    /// Show daemon state, details, or filtered tickets; optionally follow live.
161    #[command(
162        about = "Show daemon state, details, or filtered tickets; optionally follow live",
163        long_about = SHOW_LONG_ABOUT,
164        after_help = SHOW_EXAMPLES
165    )]
166    Show(ShowCliArgs),
167    /// Show output from a run.
168    #[command(
169        long_about = LOGS_LONG_ABOUT,
170        after_help = "See `sloop show --help` for derived state and live activity."
171    )]
172    Logs {
173        /// Run alias, ticket reference, or run-id prefix.
174        run: String,
175        /// Show only output captured by this stage: `<stage>`, or
176        /// `<stage>#<attempt>` for one execution of a re-run stage.
177        #[arg(long, value_name = "STAGE[#ATTEMPT]")]
178        stage: Option<String>,
179        /// Show the last N matching entries instead of the default 64.
180        #[arg(long, value_name = "N")]
181        tail: Option<u32>,
182        /// Stream new output until the run reaches a terminal state.
183        #[arg(long)]
184        follow: bool,
185    },
186    /// Follow ticket and run activity as it happens.
187    ///
188    /// With no reference every event in the repository is streamed. With one,
189    /// only events belonging to that scope are: a ticket covers the ticket and
190    /// all of its runs, a project covers its tickets and their runs, and a run
191    /// covers just that run. Repository-wide events, such as a daemon drain,
192    /// belong to no scope and are streamed only by a bare `sloop watch`. An
193    /// unknown reference fails immediately rather than streaming nothing.
194    #[command(hide = true)]
195    Watch {
196        /// Ticket id or name, run alias or id prefix, or project id to scope to.
197        r#ref: Option<String>,
198        /// Number of recent events to show before following.
199        #[arg(long, default_value_t = 20)]
200        tail: u32,
201    },
202    /// Block until a run reaches a terminal state.
203    #[command(hide = true)]
204    Wait {
205        /// Run alias, ticket reference, or run-id prefix.
206        run: String,
207        /// Give up after this many seconds.
208        #[arg(long, default_value_t = 3600)]
209        timeout: u64,
210    },
211    /// Rebuild local state from committed files and Git.
212    #[command(hide = true)]
213    Reindex,
214    /// Show the current worker's assignment.
215    #[command(hide = true)]
216    Brief,
217    /// Append an advisory note to the current run.
218    #[command(hide = true)]
219    Note {
220        /// The note. It records an observation and moves nothing: no note
221        /// passes a stage, and a stage's verdict comes from its result check.
222        #[arg(required = true, trailing_var_arg = true, value_name = "TEXT")]
223        text: Vec<String>,
224    },
225    /// Report the current stage's verdict.
226    #[command(hide = true, long_about = VERDICT_LONG_ABOUT)]
227    Verdict {
228        /// `pass` or `fail`. The first report is final.
229        verdict: VerdictCliValue,
230        /// Why. Optional on a `reported` stage, required from a panel
231        /// reviewer, and read by whoever opens `sloop show` next.
232        #[arg(long, value_name = "TEXT")]
233        reason: Option<String>,
234        /// How sure you are. Defaults to `medium`; only ever recorded as
235        /// evidence, never weighted into a panel's aggregation.
236        #[arg(long)]
237        confidence: Option<ConfidenceCliValue>,
238    },
239}
240
241/// `verdict` is the only worker verb that moves a run, so its help says who is
242/// allowed to call it before it says how. An agent that reports on a stage
243/// which never asked for a report gets a denial, not a recorded verdict.
244const VERDICT_LONG_ABOUT: &str = "\
245Report the current stage's verdict.
246
247Two callers may use it, each exactly once per stage execution:
248
249  - the worker on a stage whose flow declares `result_check: reported`. It
250    is the only thing that can pass such a stage; one that exits without
251    reporting fails with `no verdict reported`.
252  - a panel reviewer, when the stage's check is `result_check: { panel: ... }`.
253    Its credential names the seat the report lands on, so no argument chooses
254    one, and `--reason` is required.
255
256The first report for an execution is final; a second is refused. A `return_to`
257edge that re-enters the stage starts a fresh execution, which is owed its own
258report.
259
260`--confidence` takes `low`, `medium`, or `high` and defaults to `medium`. It is
261recorded as evidence and shown by `sloop show`, and is never weighted into a
262panel's quorum: a `fail` at low confidence counts exactly as much as one at
263high.";
264
265/// The selector grammar is the whole reason this verb needs long help: a stage
266/// a backward edge re-entered has more than one page of output under one name.
267const LOGS_LONG_ABOUT: &str = "\
268Show output from a run — stdout and stderr, in capture order.
269
270A bare read shows the last 64 entries, the way `tail` does: on a live run that
271is the part worth reading. `--tail N` widens or narrows that window, and
272`--follow` streams the run from its first entry instead. Whenever a window
273hides output, the last line of the page says so — a page that says nothing
274showed everything.
275
276`--stage` narrows to one stage, named exactly as its flow names it. Add
277`#<attempt>` to narrow further to a single execution: `--stage build#2` is the
278second pass a `return_to` edge sent the walk through, and `--stage build` is
279every pass together. The suffix is the same label `sloop show` prints in its
280stage table, so a row read there is a selector that can be pasted back.
281
282A stage the run's flow does not define is an error listing the ones it does,
283rather than an empty page.";
284
285#[derive(Debug, Clone, Copy, ValueEnum)]
286pub enum VerdictCliValue {
287    Pass,
288    Fail,
289}
290
291/// clap renders the variants as the accepted values, so `--confidence 0.8`
292/// fails with the valid list rather than being rounded into one of them.
293#[derive(Debug, Clone, Copy, ValueEnum)]
294pub enum ConfidenceCliValue {
295    Low,
296    Medium,
297    High,
298}
299
300#[derive(Debug, Args)]
301pub struct DaemonCliArgs {
302    #[command(subcommand)]
303    action: Option<DaemonAction>,
304    #[arg(long, hide = true)]
305    foreground: bool,
306}
307
308#[derive(Debug, Subcommand)]
309enum DaemonAction {
310    /// Drain active runs and restart with the binary currently installed.
311    Restart,
312}
313
314#[derive(Debug, Args)]
315#[command(
316    after_help = "Ticket files need `name`, `blocked_by`, and a non-empty body. Run \
317`sloop template ticket` for a commented example of every frontmatter field, or \
318`sloop template flow` for the flow grammar that `--flow` selects.",
319    group(
320        ArgGroup::new("trigger")
321            .args(["auto", "at", "manual", "hold"])
322            .multiple(false)
323    )
324)]
325pub struct PostCliArgs {
326    /// Markdown ticket to register.
327    file: PathBuf,
328    /// Project receiving the ticket; defaults to `default`.
329    #[arg(long, value_name = "PROJECT")]
330    project: Option<String>,
331    /// Flow the ticket binds to; defaults to the repository's default flow.
332    #[arg(long, value_name = "FLOW")]
333    flow: Option<String>,
334    /// Queue one run for the next available opportunity (default).
335    #[arg(long)]
336    auto: bool,
337    /// Queue one run for the next occurrence of a local time.
338    #[arg(long, value_name = "TIME")]
339    at: Option<LocalTime>,
340    /// Register the ticket without creating a run.
341    #[arg(long)]
342    manual: bool,
343    /// Register the ticket as held without creating a run.
344    #[arg(long)]
345    hold: bool,
346}
347
348#[derive(Debug, Args)]
349pub struct ListCliArgs {
350    /// Show only the N newest tickets; `-<N>` is shorthand, as in `tail -10`.
351    #[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
352    limit: Option<u32>,
353}
354
355#[derive(Debug, Default, Args)]
356pub struct ShowCliArgs {
357    /// Exact reference or ticket pattern; exact match wins.
358    #[arg(value_name = "REF_OR_PATTERN")]
359    reference: Option<String>,
360    /// Stream events; ticket and run scopes exit when settled.
361    #[arg(long, short = 'f')]
362    follow: bool,
363    /// With --follow, suppress output and return only the outcome code.
364    #[arg(long, short = 'q', requires = "follow")]
365    quiet: bool,
366    /// At most N rows in list-shaped output; `-<N>` is shorthand.
367    #[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
368    limit: Option<u32>,
369}
370
371#[derive(Debug, Args)]
372#[command(group(
373    ArgGroup::new("trigger")
374        .args(["at", "every", "overnight"])
375        .multiple(false)
376))]
377pub struct RunCliArgs {
378    /// Run a specific ticket instead of selecting ready work.
379    ticket: Option<String>,
380    /// Select ready work from one project.
381    #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
382    project: Option<String>,
383    /// Start at a local time, such as 03:00.
384    #[arg(long, value_name = "TIME")]
385    at: Option<LocalTime>,
386    /// Recur at an interval, such as 30m.
387    #[arg(long, value_name = "DURATION")]
388    every: Option<DurationMs>,
389    /// Run according to the configured overnight window.
390    #[arg(long)]
391    overnight: bool,
392    /// Restrict selection to the comma-separated ticket IDs.
393    #[arg(long, value_delimiter = ',', value_name = "TICKETS")]
394    only: Option<Vec<String>>,
395}
396
397impl Cli {
398    pub fn into_request(self) -> Result<Request, RequestConstructionError> {
399        self.command
400            .ok_or_else(|| {
401                RequestConstructionError(
402                    "bare `sloop` prints help and has no daemon request".into(),
403                )
404            })?
405            .try_into()
406    }
407}
408
409/// Shared by the dispatch path and `into_request` so the two cannot disagree
410/// about which end of the log a bare read anchors to.
411///
412/// A one-shot read tails: it answers what a run is doing now. `--follow` pages
413/// forward from the first entry, so it must stay head-anchored. The default is
414/// the client's; on the socket `tail: null` still means "from the cursor".
415fn logs_args(run: String, stage: Option<String>, tail: Option<u32>, follow: bool) -> LogsArgs {
416    LogsArgs {
417        run,
418        stage,
419        tail: tail.or((!follow).then_some(crate::run_log::PAGE_LIMIT as u32)),
420        after: None,
421    }
422}
423
424impl TryFrom<Command> for Request {
425    type Error = RequestConstructionError;
426
427    fn try_from(command: Command) -> Result<Self, Self::Error> {
428        let empty = EmptyArgs::default;
429        Ok(match command {
430            Command::Init => Self::Init(empty()),
431            // `template` is answered entirely from compiled-in content, so it
432            // has no protocol verb and must never reach the daemon path.
433            Command::Template { .. } => {
434                return Err(RequestConstructionError(
435                    "template is printed locally and has no daemon request".into(),
436                ));
437            }
438            Command::Daemon(args) => match args.action {
439                Some(DaemonAction::Restart) => Self::Restart(empty()),
440                None => Self::Daemon(empty()),
441            },
442            Command::Post(args) => Self::Post(args.try_into()?),
443            Command::Run(args) => Self::Run(args.into()),
444            Command::Retry { ticket } => Self::Retry(TicketReferenceArgs { ticket }),
445            Command::Hold { ticket } => Self::Hold(TicketReferenceArgs { ticket }),
446            Command::Ready { ticket } => Self::Ready(TicketReferenceArgs { ticket }),
447            Command::List(args) => Self::List(ListArgs { limit: args.limit }),
448            Command::Status => Self::Status(empty()),
449            Command::Pause => Self::Pause(empty()),
450            Command::Resume => Self::Resume(empty()),
451            Command::Stop { force } => Self::Stop(StopArgs { force }),
452            Command::Cancel { run } => Self::Cancel(RunReferenceArgs { run }),
453            Command::Logs {
454                run,
455                stage,
456                tail,
457                follow,
458            } => Self::Logs(logs_args(run, stage, tail, follow)),
459            Command::Watch { r#ref, tail } => Self::Events(EventsArgs {
460                after: None,
461                tail: Some(tail),
462                limit: None,
463                scope: r#ref,
464            }),
465            Command::Wait { run, .. } => Self::Wait(RunReferenceArgs { run }),
466            Command::Reindex => Self::Reindex(empty()),
467            Command::Brief => Self::Brief(empty()),
468            Command::Show(args) => Self::Show(ShowArgs {
469                reference: args.reference,
470                limit: args.limit,
471            }),
472            Command::Note { text } => Self::Note(NoteArgs {
473                text: text.join(" "),
474            }),
475            Command::Verdict {
476                verdict,
477                reason,
478                confidence,
479            } => Self::Verdict(VerdictArgs {
480                verdict: match verdict {
481                    VerdictCliValue::Pass => VerdictValue::Pass,
482                    VerdictCliValue::Fail => VerdictValue::Fail,
483                },
484                reason,
485                confidence: confidence.map(|confidence| match confidence {
486                    ConfidenceCliValue::Low => ConfidenceValue::Low,
487                    ConfidenceCliValue::Medium => ConfidenceValue::Medium,
488                    ConfidenceCliValue::High => ConfidenceValue::High,
489                }),
490            }),
491        })
492    }
493}
494
495impl TryFrom<PostCliArgs> for PostArgs {
496    type Error = RequestConstructionError;
497
498    fn try_from(args: PostCliArgs) -> Result<Self, Self::Error> {
499        let file = args
500            .file
501            .into_os_string()
502            .into_string()
503            .map_err(|_| RequestConstructionError("ticket path must be valid UTF-8".into()))?;
504        let trigger = if let Some(time) = args.at {
505            PostTrigger::At { time: time.0 }
506        } else if args.manual {
507            PostTrigger::Manual
508        } else if args.hold {
509            PostTrigger::Hold
510        } else {
511            PostTrigger::Auto
512        };
513
514        Ok(Self {
515            file,
516            project: args.project,
517            flow: args.flow,
518            trigger,
519        })
520    }
521}
522
523impl From<RunCliArgs> for RunArgs {
524    fn from(args: RunCliArgs) -> Self {
525        let trigger = if let Some(time) = args.at {
526            RunTrigger::At { local_time: time.0 }
527        } else if let Some(interval) = args.every {
528            RunTrigger::Every {
529                interval_ms: interval.0,
530            }
531        } else if args.overnight {
532            RunTrigger::Overnight
533        } else {
534            RunTrigger::Now
535        };
536
537        Self {
538            ticket: args.ticket,
539            project: args.project,
540            trigger,
541            only: args.only.unwrap_or_default(),
542        }
543    }
544}
545
546#[derive(Debug, Clone, PartialEq, Eq)]
547pub struct RequestConstructionError(String);
548
549impl fmt::Display for RequestConstructionError {
550    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
551        formatter.write_str(&self.0)
552    }
553}
554
555impl std::error::Error for RequestConstructionError {}
556
557#[derive(Debug, Clone)]
558struct LocalTime(String);
559
560impl FromStr for LocalTime {
561    type Err = String;
562
563    fn from_str(value: &str) -> Result<Self, Self::Err> {
564        let (hour, minute) = value
565            .split_once(':')
566            .ok_or_else(|| "time must use HH:MM".to_owned())?;
567        if hour.len() != 2 || minute.len() != 2 {
568            return Err("time must use HH:MM".into());
569        }
570        let hour: u8 = hour.parse().map_err(|_| "hour must be numeric")?;
571        let minute: u8 = minute.parse().map_err(|_| "minute must be numeric")?;
572        if hour > 23 || minute > 59 {
573            return Err("time must be between 00:00 and 23:59".into());
574        }
575        Ok(Self(value.to_owned()))
576    }
577}
578
579#[derive(Debug, Clone, Copy)]
580struct DurationMs(u64);
581
582impl FromStr for DurationMs {
583    type Err = String;
584
585    fn from_str(value: &str) -> Result<Self, Self::Err> {
586        let digits = value.chars().take_while(char::is_ascii_digit).count();
587        let (amount, unit) = value.split_at(digits);
588        if amount.is_empty() || unit.is_empty() {
589            return Err("duration must include a positive number and unit (ms, s, m, or h)".into());
590        }
591        let amount: u64 = amount
592            .parse()
593            .map_err(|_| "duration amount is too large".to_owned())?;
594        if amount == 0 {
595            return Err("duration must be greater than zero".into());
596        }
597        let multiplier = match unit {
598            "ms" => 1,
599            "s" => 1_000,
600            "m" => 60_000,
601            "h" => 3_600_000,
602            _ => return Err("duration unit must be ms, s, m, or h".into()),
603        };
604        amount
605            .checked_mul(multiplier)
606            .map(Self)
607            .ok_or_else(|| "duration is too large".into())
608    }
609}
610
611pub fn run<I, T, O, E>(args: I, stdout: &mut O, stderr: &mut E) -> ExitCode
612where
613    I: IntoIterator<Item = T>,
614    T: Into<OsString> + Clone,
615    O: Write,
616    E: Write,
617{
618    let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
619    let expanded_help = args.iter().any(|arg| arg == "--all")
620        && args
621            .iter()
622            .any(|arg| arg == "--help" || arg == "-h" || arg == "help");
623    if expanded_help {
624        args.retain(|arg| arg != "--all");
625    }
626    expand_limit_shorthand(&mut args);
627
628    let mut command = Cli::command();
629    if expanded_help {
630        let subcommands: Vec<String> = command
631            .get_subcommands()
632            .map(|subcommand| subcommand.get_name().to_owned())
633            .collect();
634        for subcommand in subcommands {
635            command = command.mut_subcommand(subcommand, |subcommand| subcommand.hide(false));
636        }
637        command = command.after_help(TICKET_STATES_HELP);
638    } else {
639        command = command.after_help(
640            "Read state with `sloop show` and raw output with `sloop logs`.\nRun `sloop show --help` for the complete read model or `sloop --help --all` for every command.",
641        );
642    }
643
644    match command
645        .clone()
646        .try_get_matches_from(&args)
647        .and_then(|matches| Cli::from_arg_matches(&matches))
648    {
649        Ok(cli) => {
650            let mode = if cli.json {
651                OutputMode::Json
652            } else {
653                OutputMode::Human
654            };
655            match cli.command {
656                Some(subcommand) => run_command(subcommand, mode, stdout, stderr),
657                // Bare `sloop` orients rather than acts: print the same help
658                // as `sloop --help` instead of defaulting to a verb.
659                None => {
660                    let help = command.render_help().to_string();
661                    let help = help.trim_end();
662                    write_plain_or(
663                        mode,
664                        stdout,
665                        help,
666                        &ResponseEnvelope::success(None, json!({"kind": "help", "text": help})),
667                    )
668                }
669            }
670        }
671        // Parsing failed, so the flag is read from the raw arguments: an
672        // agent asking for `--json --help` still gets an envelope.
673        Err(error) => {
674            let mode = if args.iter().any(|arg| arg == "--json") {
675                OutputMode::Json
676            } else {
677                OutputMode::Human
678            };
679            match error.kind() {
680                ErrorKind::DisplayHelp => write_plain_or(
681                    mode,
682                    stdout,
683                    error.to_string().trim_end(),
684                    &ResponseEnvelope::success(
685                        None,
686                        json!({"kind": "help", "text": error.to_string().trim_end()}),
687                    ),
688                ),
689                ErrorKind::DisplayVersion => write_plain_or(
690                    mode,
691                    stdout,
692                    concat!("sloop ", env!("CARGO_PKG_VERSION")),
693                    &ResponseEnvelope::success(
694                        None,
695                        json!({"kind": "version", "version": env!("CARGO_PKG_VERSION")}),
696                    ),
697                ),
698                _ => write_cli_error(
699                    mode,
700                    stderr,
701                    augment_unknown_subcommand(&error, error.to_string().trim_end().to_owned()),
702                ),
703            }
704        }
705    }
706}
707
708/// Rewrites `sloop show -10` into `sloop show --limit=10`. clap lexes a bare
709/// `-10` as the short flag `-1`, so the `head`/`tail` shorthand has to be
710/// translated before parsing. Only arguments after a leading `show` or `list`
711/// touched, so no other command can have a negative-looking value rewritten,
712/// and only all-digit runs are: `-abc` and `-n` reach clap untouched and earn
713/// its usage error. `-0` becomes `--limit=0`, which the parser's range rejects.
714fn expand_limit_shorthand(args: &mut [OsString]) {
715    let verb = args
716        .iter()
717        .skip(1)
718        .position(|arg| arg != "--json")
719        .map(|offset| offset + 1);
720    let Some(verb) = verb.filter(|index| args[*index] == "show" || args[*index] == "list") else {
721        return;
722    };
723    for argument in &mut args[verb + 1..] {
724        let Some(digits) = argument.to_str().and_then(|arg| arg.strip_prefix('-')) else {
725            continue;
726        };
727        if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
728            *argument = OsString::from(format!("--limit={digits}"));
729        }
730    }
731}
732
733/// Synonyms an agent is likely to type that clap's edit-distance matcher does
734/// not catch, each mapped to the real verb it should have used. This is the one
735/// place to add an alias; keep it small and keep every entry pointed at a verb
736/// that exists. Suggestions are text only — nothing here executes.
737fn subcommand_synonym(attempted: &str) -> Option<&'static str> {
738    match attempted {
739        "tickets" | "ls" | "queue" => Some("list"),
740        "ps" => Some("status"),
741        "start" => Some("run"),
742        "kill" | "abort" => Some("cancel"),
743        _ => None,
744    }
745}
746
747/// Adds a remedy to clap's "unrecognized subcommand" error. clap already
748/// appends a `tip:` line when the typo is a near-miss of a real verb, and that
749/// text rides through the JSON envelope unchanged, so we leave those alone and
750/// only fill the gap: when similarity matching finds nothing but our synonym
751/// table does, point the caller at the verb they meant.
752fn augment_unknown_subcommand(error: &clap::Error, rendered: String) -> String {
753    if error.kind() != ErrorKind::InvalidSubcommand
754        || error.get(ContextKind::SuggestedSubcommand).is_some()
755    {
756        return rendered;
757    }
758    let Some(attempted) = invalid_subcommand(error) else {
759        return rendered;
760    };
761    let Some(verb) = subcommand_synonym(&attempted) else {
762        return rendered;
763    };
764    let tip = format!(
765        "\n\n  tip: `{attempted}` is not a verb; did you mean `{verb}`? run `sloop {verb}`"
766    );
767    match rendered.find("\n\nUsage:") {
768        Some(index) => {
769            let mut augmented = rendered;
770            augmented.insert_str(index, &tip);
771            augmented
772        }
773        None => rendered + &tip,
774    }
775}
776
777fn invalid_subcommand(error: &clap::Error) -> Option<String> {
778    match error.get(ContextKind::InvalidSubcommand) {
779        Some(ContextValue::String(value)) => Some(value.clone()),
780        _ => None,
781    }
782}
783
784fn run_command(
785    command: Command,
786    mode: OutputMode,
787    stdout: &mut impl Write,
788    stderr: &mut impl Write,
789) -> ExitCode {
790    match command {
791        Command::Init => run_init(mode, stdout, stderr),
792        Command::Template { kind } => run_template(kind, mode, stdout),
793        Command::Daemon(args) if args.foreground && args.action.is_none() => {
794            match crate::daemon::serve_current_repository() {
795                Ok(()) | Err(crate::daemon::DaemonError::AlreadyRunning) => ExitCode::SUCCESS,
796                Err(_) => ExitCode::FAILURE,
797            }
798        }
799        Command::Daemon(args) => {
800            let (request, report_started) = match args.action {
801                Some(DaemonAction::Restart) => (Request::Restart(EmptyArgs::default()), false),
802                None => (Request::Daemon(EmptyArgs::default()), true),
803            };
804            run_daemon_request(request, report_started, mode, stdout, stderr)
805        }
806        Command::Stop { force } => run_stop_request(force, mode, stdout, stderr),
807        Command::Wait { run, timeout } => {
808            if !write_deprecation(stderr, "wait", "show --follow --quiet") {
809                return ExitCode::FAILURE;
810            }
811            run_wait(run, timeout, mode, stdout, stderr)
812        }
813        Command::Watch { r#ref, tail } => {
814            if !write_deprecation(stderr, "watch", "show --follow") {
815                return ExitCode::FAILURE;
816            }
817            run_watch(r#ref, tail, mode, stdout, stderr)
818        }
819        Command::Logs {
820            run,
821            stage,
822            tail,
823            follow,
824        } => run_logs(
825            logs_args(run, stage, tail, follow),
826            follow,
827            mode,
828            stdout,
829            stderr,
830        ),
831        Command::List(args) => {
832            if !write_deprecation(stderr, "list", "show") {
833                return ExitCode::FAILURE;
834            }
835            run_daemon_request(
836                Request::List(ListArgs { limit: args.limit }),
837                false,
838                mode,
839                stdout,
840                stderr,
841            )
842        }
843        Command::Status => {
844            if !write_deprecation(stderr, "status", "show") {
845                return ExitCode::FAILURE;
846            }
847            run_show(ShowCliArgs::default(), mode, stdout, stderr)
848        }
849        Command::Show(args) => run_show(args, mode, stdout, stderr),
850        command @ (Command::Post(_)
851        | Command::Run(_)
852        | Command::Retry { .. }
853        | Command::Hold { .. }
854        | Command::Ready { .. }
855        | Command::Pause
856        | Command::Resume
857        | Command::Cancel { .. }
858        | Command::Reindex) => match Request::try_from(command) {
859            Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
860            Err(error) => write_cli_error(mode, stderr, error.to_string()),
861        },
862        command @ (Command::Brief | Command::Note { .. } | Command::Verdict { .. }) => {
863            match Request::try_from(command) {
864                Ok(request) => run_worker_request(request, mode, stdout, stderr),
865                Err(error) => write_cli_error(mode, stderr, error.to_string()),
866            }
867        }
868    }
869}
870
871/// Writes plain text in human mode or the given envelope in JSON mode; used
872/// for help and version, which have no verb-shaped payload.
873fn write_plain_or(
874    mode: OutputMode,
875    output: &mut impl Write,
876    text: &str,
877    envelope: &ResponseEnvelope,
878) -> ExitCode {
879    match mode {
880        OutputMode::Json => write_response(mode, None, output, envelope, ExitCode::SUCCESS),
881        OutputMode::Human => {
882            if writeln!(output, "{text}").is_err() {
883                return ExitCode::FAILURE;
884            }
885            ExitCode::SUCCESS
886        }
887    }
888}
889
890/// Prints a compiled-in template. Like `stop`, this verb never resurrects a
891/// daemon — unlike `stop`, it never contacts one at all, because the answer
892/// is static content baked into the binary. Plain mode writes the template
893/// verbatim so it can be redirected straight into a file.
894fn run_template(kind: TemplateKind, mode: OutputMode, stdout: &mut impl Write) -> ExitCode {
895    let text = kind.text();
896    match mode {
897        OutputMode::Json => write_response(
898            mode,
899            Some("template"),
900            stdout,
901            &ResponseEnvelope::success(None, json!({"kind": kind.as_str(), "template": text})),
902            ExitCode::SUCCESS,
903        ),
904        OutputMode::Human => {
905            if stdout.write_all(text.as_bytes()).is_err() {
906                return ExitCode::FAILURE;
907            }
908            ExitCode::SUCCESS
909        }
910    }
911}
912
913fn run_init(mode: OutputMode, stdout: &mut impl Write, stderr: &mut impl Write) -> ExitCode {
914    let cwd = match std::env::current_dir() {
915        Ok(cwd) => cwd,
916        Err(error) => {
917            return write_response(
918                mode,
919                Some("init"),
920                stderr,
921                &ResponseEnvelope::failure(
922                    None,
923                    ErrorBody {
924                        code: ErrorCode::Internal,
925                        message: format!("cannot read current directory: {error}"),
926                        details: json!({}),
927                    },
928                ),
929                ExitCode::FAILURE,
930            );
931        }
932    };
933    match crate::init::init(&cwd) {
934        Ok(outcome) => write_response(
935            mode,
936            Some("init"),
937            stdout,
938            &ResponseEnvelope::success(
939                None,
940                json!({
941                    "repository_root": outcome.repository_root.to_string_lossy(),
942                    "created": outcome.created,
943                    "existing": outcome.existing,
944                }),
945            ),
946            ExitCode::SUCCESS,
947        ),
948        Err(error) => {
949            let code = match error {
950                crate::init::InitError::Conflict { .. } => ErrorCode::Conflict,
951                crate::init::InitError::Io { .. } => ErrorCode::Internal,
952            };
953            write_response(
954                mode,
955                Some("init"),
956                stderr,
957                &ResponseEnvelope::failure(
958                    None,
959                    ErrorBody {
960                        code,
961                        message: error.to_string(),
962                        details: json!({}),
963                    },
964                ),
965                ExitCode::FAILURE,
966            )
967        }
968    }
969}
970
971fn write_deprecation(stderr: &mut impl Write, old: &str, new: &str) -> bool {
972    writeln!(
973        stderr,
974        "note: 'sloop {old}' is now 'sloop {new}'; this alias will be removed in a future release"
975    )
976    .is_ok()
977}
978
979fn run_show(
980    args: ShowCliArgs,
981    mode: OutputMode,
982    stdout: &mut impl Write,
983    stderr: &mut impl Write,
984) -> ExitCode {
985    let request_args = ShowArgs {
986        reference: args.reference,
987        limit: args.limit,
988    };
989    let worker_environment =
990        std::env::var_os("SLOOP_SOCKET").is_some() || std::env::var_os("SLOOP_TOKEN").is_some();
991    if worker_environment {
992        if args.follow {
993            return write_cli_error(
994                mode,
995                stderr,
996                "workers cannot follow operator activity; worker `show` only reads its own ticket"
997                    .into(),
998            );
999        }
1000        return run_worker_request(Request::Show(request_args), mode, stdout, stderr);
1001    }
1002    if args.follow {
1003        return follow_show(request_args, 20, None, args.quiet, mode, stdout, stderr);
1004    }
1005    match crate::daemon::request(Request::Show(request_args)) {
1006        Ok(result) if result.response.ok => write_response(
1007            mode,
1008            Some("show"),
1009            stdout,
1010            &result.response,
1011            ExitCode::SUCCESS,
1012        ),
1013        Ok(result) => {
1014            let exit = if result.response.error.as_ref().map(|error| error.code)
1015                == Some(ErrorCode::InvalidArguments)
1016            {
1017                ExitCode::from(2)
1018            } else {
1019                ExitCode::FAILURE
1020            };
1021            write_response(mode, Some("show"), stderr, &result.response, exit)
1022        }
1023        Err(error) => write_response(
1024            mode,
1025            Some("show"),
1026            stderr,
1027            &ResponseEnvelope::failure(None, error.error_body()),
1028            ExitCode::FAILURE,
1029        ),
1030    }
1031}
1032
1033/// Polls the daemon until the run is terminal. The exit code is the outcome
1034/// (`0` only for `merged`), so scripts and CI can gate on a run directly.
1035/// Client-side wall-clock polling; the daemon stays stateless.
1036fn run_wait(
1037    run: String,
1038    timeout_secs: u64,
1039    mode: OutputMode,
1040    stdout: &mut impl Write,
1041    stderr: &mut impl Write,
1042) -> ExitCode {
1043    // Validate with the legacy run resolver so unknown and unrun references
1044    // still fail immediately, then follow the original scope exactly as the
1045    // advertised replacement does.
1046    match crate::daemon::request(Request::Wait(RunReferenceArgs { run: run.clone() })) {
1047        Ok(result) if result.response.ok => {}
1048        Ok(result) => {
1049            return write_response(
1050                mode,
1051                Some("wait"),
1052                stderr,
1053                &result.response,
1054                ExitCode::FAILURE,
1055            );
1056        }
1057        Err(error) => {
1058            return write_response(
1059                mode,
1060                Some("wait"),
1061                stderr,
1062                &ResponseEnvelope::failure(None, error.error_body()),
1063                ExitCode::FAILURE,
1064            );
1065        }
1066    }
1067    follow_show(
1068        ShowArgs {
1069            reference: Some(run),
1070            limit: None,
1071        },
1072        0,
1073        Some(timeout_secs),
1074        true,
1075        mode,
1076        stdout,
1077        stderr,
1078    )
1079}
1080
1081/// Follows the activity feed until interrupted. Same client-side polling
1082/// model as `wait`: each iteration asks the daemon for events past the
1083/// cursor from the previous page, so the daemon stays stateless and any
1084/// other client (a dashboard, a websocket bridge) can stream the same way.
1085/// In `--json` mode each event is written as one NDJSON line.
1086///
1087/// A `scope` reference rides along on every request and the daemon resolves
1088/// and applies it, so the filter stays part of the public protocol instead of
1089/// a CLI-only convenience. An unresolvable reference comes back as a
1090/// `not_found` failure on the very first request, before anything streams.
1091fn run_watch(
1092    scope: Option<String>,
1093    tail: u32,
1094    mode: OutputMode,
1095    stdout: &mut impl Write,
1096    stderr: &mut impl Write,
1097) -> ExitCode {
1098    follow_show(
1099        ShowArgs {
1100            reference: scope,
1101            limit: None,
1102        },
1103        tail,
1104        None,
1105        false,
1106        mode,
1107        stdout,
1108        stderr,
1109    )
1110}
1111
1112fn follow_show(
1113    show: ShowArgs,
1114    tail: u32,
1115    timeout_secs: Option<u64>,
1116    quiet: bool,
1117    mode: OutputMode,
1118    stdout: &mut impl Write,
1119    stderr: &mut impl Write,
1120) -> ExitCode {
1121    let initial = match crate::daemon::request(Request::Show(show.clone())) {
1122        Ok(result) if result.response.ok => result.response.data.unwrap_or_default(),
1123        Ok(result) => {
1124            let exit = if result.response.error.as_ref().map(|error| error.code)
1125                == Some(ErrorCode::InvalidArguments)
1126            {
1127                ExitCode::from(2)
1128            } else {
1129                ExitCode::FAILURE
1130            };
1131            return write_response(mode, Some("show"), stderr, &result.response, exit);
1132        }
1133        Err(error) => {
1134            return write_response(
1135                mode,
1136                Some("show"),
1137                stderr,
1138                &ResponseEnvelope::failure(None, error.error_body()),
1139                ExitCode::FAILURE,
1140            );
1141        }
1142    };
1143    let settling_kind = matches!(initial["kind"].as_str(), Some("ticket" | "run"));
1144    let deadline = timeout_secs
1145        .map(|seconds| std::time::Instant::now() + std::time::Duration::from_secs(seconds));
1146    let mut cursor: Option<i64> = None;
1147    let mut settled_outcome: Option<bool> = None;
1148    loop {
1149        let args = match cursor {
1150            Some(after) => EventsArgs {
1151                after: Some(after),
1152                tail: None,
1153                limit: None,
1154                scope: show.reference.clone(),
1155            },
1156            None => EventsArgs {
1157                after: None,
1158                tail: Some(tail),
1159                limit: None,
1160                scope: show.reference.clone(),
1161            },
1162        };
1163        match crate::daemon::request(Request::Events(args)) {
1164            Ok(result) if result.response.ok => {
1165                let data = result.response.data.unwrap_or_default();
1166                let events = data["events"].as_array().cloned().unwrap_or_default();
1167                for event in &events {
1168                    if quiet {
1169                        continue;
1170                    }
1171                    let written = match mode {
1172                        OutputMode::Json => serde_json::to_writer(&mut *stdout, event)
1173                            .map_err(|_| ())
1174                            .and_then(|()| stdout.write_all(b"\n").map_err(|_| ())),
1175                        OutputMode::Human => {
1176                            writeln!(stdout, "{}", format_event(event)).map_err(|_| ())
1177                        }
1178                    };
1179                    if written.is_err() {
1180                        return ExitCode::FAILURE;
1181                    }
1182                }
1183                if !quiet && stdout.flush().is_err() {
1184                    return ExitCode::FAILURE;
1185                }
1186                let next = data["next_cursor"].as_i64();
1187                let advanced = next.is_some() && next != cursor;
1188                if let Some(next) = next {
1189                    cursor = Some(next);
1190                }
1191                // A cursor short of the newest sequence means more rows are
1192                // already waiting; skip the sleep and drain them. The test is
1193                // on the cursor rather than on this page being non-empty
1194                // because a scoped page can filter out every row it scanned
1195                // and still leave matching rows further along. Requiring the
1196                // cursor to have moved keeps a daemon that returns no cursor
1197                // from spinning.
1198                let caught_up = next == data["latest"].as_i64();
1199                if advanced && !caught_up {
1200                    continue;
1201                }
1202                // Settlement and its final event are committed together, but
1203                // they can land after the events snapshot and before the show
1204                // snapshot below. Once terminal state is observed, perform one
1205                // more event poll before exiting so that event is never lost.
1206                if caught_up && let Some(merged) = settled_outcome {
1207                    return if merged {
1208                        ExitCode::SUCCESS
1209                    } else {
1210                        ExitCode::FAILURE
1211                    };
1212                }
1213                if settling_kind && caught_up {
1214                    match crate::daemon::request(Request::Show(show.clone())) {
1215                        Ok(result) if result.response.ok => {
1216                            let shown = result.response.data.unwrap_or_default();
1217                            let value = &shown["value"];
1218                            let terminal = match shown["kind"].as_str() {
1219                                Some("ticket") => matches!(
1220                                    value["state"].as_str(),
1221                                    Some("merged" | "failed" | "needs_review")
1222                                ),
1223                                Some("run") => value["terminal"] == json!(true),
1224                                _ => false,
1225                            };
1226                            if terminal {
1227                                settled_outcome = Some(value["state"] == "merged");
1228                                continue;
1229                            }
1230                        }
1231                        Ok(result) => {
1232                            return write_response(
1233                                mode,
1234                                Some("show"),
1235                                stderr,
1236                                &result.response,
1237                                ExitCode::FAILURE,
1238                            );
1239                        }
1240                        Err(error) => {
1241                            return write_response(
1242                                mode,
1243                                Some("show"),
1244                                stderr,
1245                                &ResponseEnvelope::failure(None, error.error_body()),
1246                                ExitCode::FAILURE,
1247                            );
1248                        }
1249                    }
1250                }
1251            }
1252            Ok(result) => {
1253                return write_response(
1254                    mode,
1255                    Some("show"),
1256                    stderr,
1257                    &result.response,
1258                    ExitCode::FAILURE,
1259                );
1260            }
1261            Err(error) => {
1262                return write_response(
1263                    mode,
1264                    Some("show"),
1265                    stderr,
1266                    &ResponseEnvelope::failure(None, error.error_body()),
1267                    ExitCode::FAILURE,
1268                );
1269            }
1270        }
1271        if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
1272            let reference = show.reference.as_deref().unwrap_or("dashboard");
1273            return write_cli_error(mode, stderr, format!("timed out following `{reference}`"));
1274        }
1275        std::thread::sleep(std::time::Duration::from_millis(500));
1276    }
1277}
1278
1279/// One page of captured output, or — with `--follow` — every page until the
1280/// run settles. Filtering is the daemon's job; the client only chooses when
1281/// to ask again.
1282fn run_logs(
1283    args: LogsArgs,
1284    follow: bool,
1285    mode: OutputMode,
1286    stdout: &mut impl Write,
1287    stderr: &mut impl Write,
1288) -> ExitCode {
1289    if !follow {
1290        return run_daemon_request(Request::Logs(args), false, mode, stdout, stderr);
1291    }
1292    follow_logs(args, mode, stdout, stderr)
1293}
1294
1295/// Polls the daemon for pages past the cursor of the previous one, exactly
1296/// like `watch` does over the event feed. The daemon keeps no per-follower
1297/// state, so any other client can stream a run the same way.
1298///
1299/// A page is written when it carries entries, and once at the end so an empty
1300/// run still reports itself. Streaming stops only when the run is terminal
1301/// *and* the page reached the end of the log: a settled run can still have
1302/// unread output behind the cursor.
1303fn follow_logs(
1304    mut args: LogsArgs,
1305    mode: OutputMode,
1306    stdout: &mut impl Write,
1307    stderr: &mut impl Write,
1308) -> ExitCode {
1309    let mut written = false;
1310    loop {
1311        match crate::daemon::request(Request::Logs(args.clone())) {
1312            Ok(mut result) => {
1313                if !result.response.ok {
1314                    return write_response(
1315                        mode,
1316                        Some("logs"),
1317                        stderr,
1318                        &result.response,
1319                        ExitCode::FAILURE,
1320                    );
1321                }
1322                // The attempt-disambiguation note describes the run, not the
1323                // page; repeating it on every page would be noise.
1324                if written && let Some(data) = result.response.data.as_mut() {
1325                    data["note"] = serde_json::Value::Null;
1326                }
1327                let data = result.response.data.clone().unwrap_or_default();
1328                let complete = data["complete"] == json!(true);
1329                let settled = complete && data["terminal"] == json!(true);
1330                let entries = data["entries"].as_array().map_or(0, Vec::len);
1331                if entries > 0 || (settled && !written) {
1332                    if write_envelope(mode, Some("logs"), stdout, &result.response).is_err()
1333                        || stdout.flush().is_err()
1334                    {
1335                        return ExitCode::FAILURE;
1336                    }
1337                    written = true;
1338                }
1339                if let Some(next) = data["next_cursor"].as_u64() {
1340                    args.after = Some(next);
1341                }
1342                // `tail` selects a window of what already exists; every later
1343                // page is whatever arrived after it.
1344                args.tail = None;
1345                if settled {
1346                    return ExitCode::SUCCESS;
1347                }
1348                // A truncated page means more output is already on disk;
1349                // drain it before pausing.
1350                if !complete {
1351                    continue;
1352                }
1353            }
1354            Err(error) => {
1355                return write_response(
1356                    mode,
1357                    Some("logs"),
1358                    stderr,
1359                    &ResponseEnvelope::failure(None, error.error_body()),
1360                    ExitCode::FAILURE,
1361                );
1362            }
1363        }
1364        std::thread::sleep(std::time::Duration::from_millis(200));
1365    }
1366}
1367
1368/// Renders one activity event as a human `watch` line.
1369fn format_event(event: &serde_json::Value) -> String {
1370    let time = event["occurred_at_ms"]
1371        .as_i64()
1372        .and_then(crate::clock::format_timestamp)
1373        .unwrap_or_default();
1374    let run = event["run"].as_str().unwrap_or("?");
1375    let ticket = event["ticket"].as_str().unwrap_or("?");
1376    let data = &event["data"];
1377    match event["kind"].as_str().unwrap_or("?") {
1378        "run_claimed" => {
1379            let attempt = data["attempt"].as_i64().unwrap_or(1);
1380            format!("{time}  {ticket} claimed by {run} (attempt {attempt})")
1381        }
1382        "run_started" => format!("{time}  {run} started on {ticket}"),
1383        "run_finished" => {
1384            let outcome = data["outcome"].as_str().unwrap_or("?");
1385            let state = data["ticket_state"].as_str().unwrap_or("?");
1386            format!("{time}  {run} finished: {outcome} ({ticket} -> {state})")
1387        }
1388        "run_aborted" => format!("{time}  {run} aborted before launch ({ticket} back to ready)"),
1389        "run_worktree_cleaned" => format!("{time}  {run} worktree and branch removed"),
1390        "daemon_restart_requested" => {
1391            let active = data["active_runs"].as_u64().unwrap_or(0);
1392            let noun = if active == 1 { "run" } else { "runs" };
1393            format!("{time}  daemon draining for restart ({active} {noun} active)")
1394        }
1395        kind => format!("{time}  {kind} run={run} ticket={ticket}"),
1396    }
1397}
1398
1399/// `stop` is the one operator verb that must never resurrect a daemon: an
1400/// unreachable socket already means the desired state.
1401fn run_stop_request(
1402    force: bool,
1403    mode: OutputMode,
1404    stdout: &mut impl Write,
1405    stderr: &mut impl Write,
1406) -> ExitCode {
1407    match crate::daemon::request_running(Request::Stop(StopArgs { force })) {
1408        Ok(Some(response)) if response.ok => {
1409            write_response(mode, Some("stop"), stdout, &response, ExitCode::SUCCESS)
1410        }
1411        Ok(Some(response)) => {
1412            write_response(mode, Some("stop"), stderr, &response, ExitCode::FAILURE)
1413        }
1414        Ok(None) => write_response(
1415            mode,
1416            Some("stop"),
1417            stdout,
1418            &ResponseEnvelope::success(None, json!({"stopping": false, "running": false})),
1419            ExitCode::SUCCESS,
1420        ),
1421        Err(error) => write_response(
1422            mode,
1423            Some("stop"),
1424            stderr,
1425            &ResponseEnvelope::failure(None, error.error_body()),
1426            ExitCode::FAILURE,
1427        ),
1428    }
1429}
1430
1431fn run_daemon_request(
1432    request: Request,
1433    report_started: bool,
1434    mode: OutputMode,
1435    stdout: &mut impl Write,
1436    stderr: &mut impl Write,
1437) -> ExitCode {
1438    let verb = request.verb();
1439    match crate::daemon::request(request) {
1440        Ok(mut result) => {
1441            if report_started {
1442                let data = result
1443                    .response
1444                    .data
1445                    .as_mut()
1446                    .and_then(serde_json::Value::as_object_mut);
1447                if let Some(data) = data {
1448                    data.insert("started".into(), result.started.into());
1449                }
1450            }
1451            if result.response.ok {
1452                write_response(
1453                    mode,
1454                    Some(verb),
1455                    stdout,
1456                    &result.response,
1457                    ExitCode::SUCCESS,
1458                )
1459            } else {
1460                write_response(
1461                    mode,
1462                    Some(verb),
1463                    stderr,
1464                    &result.response,
1465                    ExitCode::FAILURE,
1466                )
1467            }
1468        }
1469        Err(error) => write_response(
1470            mode,
1471            Some(verb),
1472            stderr,
1473            &ResponseEnvelope::failure(None, error.error_body()),
1474            ExitCode::FAILURE,
1475        ),
1476    }
1477}
1478
1479/// Sends a worker verb over the per-run socket injected by the agent adapter.
1480/// Worker verbs never resurrect a daemon: without a run's `SLOOP_SOCKET` and
1481/// `SLOOP_TOKEN` there is no state worth talking to, so they fail loudly. The
1482/// daemon's reply envelope is written verbatim; agents are the only callers
1483/// and the envelope is the API.
1484fn run_worker_request(
1485    request: Request,
1486    mode: OutputMode,
1487    stdout: &mut impl Write,
1488    stderr: &mut impl Write,
1489) -> ExitCode {
1490    let verb = request.verb();
1491    let socket = std::env::var_os("SLOOP_SOCKET");
1492    let token = std::env::var("SLOOP_TOKEN").ok();
1493    let (Some(socket), Some(token)) = (socket, token) else {
1494        return write_response(
1495            mode,
1496            Some(verb),
1497            stderr,
1498            &ResponseEnvelope::failure(
1499                None,
1500                ErrorBody {
1501                    code: ErrorCode::Unauthorized,
1502                    message: "worker verbs require SLOOP_SOCKET and SLOOP_TOKEN from a run".into(),
1503                    details: json!({}),
1504                },
1505            ),
1506            ExitCode::FAILURE,
1507        );
1508    };
1509
1510    let envelope = RequestEnvelope::new(
1511        RequestId::new(format!("req-{}", std::process::id())),
1512        request,
1513        Some(token),
1514    );
1515    match worker_exchange(&socket, &envelope) {
1516        Ok(reply) => {
1517            let ok = serde_json::from_str::<ResponseEnvelope>(&reply)
1518                .map(|response| response.ok)
1519                .unwrap_or(false);
1520            let written = if ok {
1521                writeln!(stdout, "{}", reply.trim_end())
1522            } else {
1523                writeln!(stderr, "{}", reply.trim_end())
1524            };
1525            if written.is_err() {
1526                return ExitCode::FAILURE;
1527            }
1528            if ok {
1529                ExitCode::SUCCESS
1530            } else {
1531                ExitCode::FAILURE
1532            }
1533        }
1534        Err(message) => write_response(
1535            mode,
1536            Some(verb),
1537            stderr,
1538            &ResponseEnvelope::failure(
1539                None,
1540                ErrorBody {
1541                    code: ErrorCode::DaemonUnavailable,
1542                    message,
1543                    details: json!({}),
1544                },
1545            ),
1546            ExitCode::FAILURE,
1547        ),
1548    }
1549}
1550
1551fn worker_exchange(socket: &std::ffi::OsStr, envelope: &RequestEnvelope) -> Result<String, String> {
1552    let mut stream = UnixStream::connect(socket)
1553        .map_err(|error| format!("cannot connect to worker socket: {error}"))?;
1554    let encoded = envelope
1555        .encode()
1556        .map_err(|error| format!("cannot encode request: {error}"))?;
1557    stream
1558        .write_all(encoded.as_bytes())
1559        .and_then(|()| stream.write_all(b"\n"))
1560        .map_err(|error| format!("cannot send request: {error}"))?;
1561
1562    let mut reply = String::new();
1563    BufReader::new(stream)
1564        .read_line(&mut reply)
1565        .map_err(|error| format!("cannot read response: {error}"))?;
1566    if reply.trim_end().is_empty() {
1567        return Err("the daemon closed the connection without replying".into());
1568    }
1569    Ok(reply)
1570}
1571
1572fn write_cli_error(mode: OutputMode, output: &mut impl Write, message: String) -> ExitCode {
1573    write_response(
1574        mode,
1575        None,
1576        output,
1577        &ResponseEnvelope::failure(
1578            None,
1579            ErrorBody {
1580                code: ErrorCode::InvalidArguments,
1581                message,
1582                details: json!({}),
1583            },
1584        ),
1585        ExitCode::from(2),
1586    )
1587}
1588
1589fn write_response(
1590    mode: OutputMode,
1591    verb: Option<&str>,
1592    output: &mut impl Write,
1593    response: &ResponseEnvelope,
1594    success: ExitCode,
1595) -> ExitCode {
1596    if write_envelope(mode, verb, output, response).is_err() {
1597        return ExitCode::FAILURE;
1598    }
1599    success
1600}
1601
1602/// Writes one envelope in the caller's mode. Split out of `write_response`
1603/// for the streaming verbs, which write many envelopes before choosing an
1604/// exit code.
1605fn write_envelope(
1606    mode: OutputMode,
1607    verb: Option<&str>,
1608    output: &mut impl Write,
1609    response: &ResponseEnvelope,
1610) -> Result<(), ()> {
1611    match mode {
1612        OutputMode::Json => serde_json::to_writer(&mut *output, response)
1613            .map_err(|_| ())
1614            .and_then(|()| output.write_all(b"\n").map_err(|_| ())),
1615        OutputMode::Human => output
1616            .write_all(crate::render::render(verb, response).as_bytes())
1617            .map_err(|_| ()),
1618    }
1619}
1620
1621#[cfg(test)]
1622mod tests {
1623    use clap::{Parser, ValueEnum};
1624    use serde_json::{Value, json};
1625
1626    use super::{Cli, expand_limit_shorthand, subcommand_synonym};
1627    use crate::protocol::{Capability, Request};
1628
1629    /// Drives the full CLI entry point and returns the error envelope written
1630    /// to stderr, exactly as an agent using `--json` would receive it.
1631    fn error_envelope(args: &[&str]) -> Value {
1632        let mut stdout = Vec::new();
1633        let mut stderr = Vec::new();
1634        let mut argv = vec!["sloop", "--json"];
1635        argv.extend_from_slice(args);
1636        super::run(argv, &mut stdout, &mut stderr);
1637        serde_json::from_slice(&stderr).expect("stderr carries a JSON envelope")
1638    }
1639
1640    /// The rewrite is deliberately narrow: only `list`'s own arguments, and
1641    /// only all-digit ones. Everything else has to reach clap untouched, or a
1642    /// negative-looking value elsewhere would silently become a limit.
1643    #[test]
1644    fn the_limit_shorthand_expands_only_for_read_arguments() {
1645        let expanded = |argv: &[&str]| {
1646            let mut args: Vec<std::ffi::OsString> =
1647                argv.iter().map(std::ffi::OsString::from).collect();
1648            expand_limit_shorthand(&mut args);
1649            args.iter()
1650                .map(|arg| arg.to_string_lossy().into_owned())
1651                .collect::<Vec<_>>()
1652        };
1653
1654        assert_eq!(
1655            expanded(&["sloop", "list", "-2"]),
1656            ["sloop", "list", "--limit=2"]
1657        );
1658        assert_eq!(
1659            expanded(&["sloop", "--json", "list", "-2"]),
1660            ["sloop", "--json", "list", "--limit=2"]
1661        );
1662        // `-0` is expanded so clap's range, not this function, refuses it.
1663        assert_eq!(expanded(&["sloop", "list", "-0"])[2], "--limit=0");
1664        assert_eq!(
1665            expanded(&["sloop", "show", "log", "-2"]),
1666            ["sloop", "show", "log", "--limit=2"]
1667        );
1668        for untouched in [
1669            ["sloop", "list", "-abc"].as_slice(),
1670            ["sloop", "list", "-n"].as_slice(),
1671            // Another verb's arguments, and the verb token itself, are safe.
1672            ["sloop", "post", "list", "-2"].as_slice(),
1673            ["sloop", "watch", "-2"].as_slice(),
1674        ] {
1675            assert_eq!(expanded(untouched), untouched, "{untouched:?}");
1676        }
1677    }
1678
1679    #[test]
1680    fn unknown_subcommand_suggests_the_tickets_synonym() {
1681        let envelope = error_envelope(&["tickets"]);
1682
1683        assert_eq!(envelope["error"]["code"], "invalid_arguments");
1684        let message = envelope["error"]["message"]
1685            .as_str()
1686            .expect("error message");
1687        assert!(
1688            message.contains("did you mean `list`") && message.contains("sloop list"),
1689            "synonym remedy missing from: {message}"
1690        );
1691    }
1692
1693    #[test]
1694    fn unknown_subcommand_suggests_a_near_miss_spelling() {
1695        // clap's own similarity matcher supplies this tip; the envelope must
1696        // carry it through unchanged.
1697        let envelope = error_envelope(&["statuss"]);
1698
1699        let message = envelope["error"]["message"]
1700            .as_str()
1701            .expect("error message");
1702        assert!(
1703            message.contains("status"),
1704            "near-miss suggestion missing from: {message}"
1705        );
1706    }
1707
1708    #[test]
1709    fn synonym_table_only_points_at_real_verbs() {
1710        use clap::CommandFactory;
1711
1712        let verbs: Vec<String> = Cli::command()
1713            .get_subcommands()
1714            .map(|subcommand| subcommand.get_name().to_owned())
1715            .collect();
1716        for attempted in ["tickets", "ls", "queue", "ps", "start", "kill", "abort"] {
1717            let verb = subcommand_synonym(attempted).expect("synonym maps to a verb");
1718            assert!(
1719                verbs.iter().any(|known| known == verb),
1720                "synonym `{attempted}` points at unknown verb `{verb}`"
1721            );
1722        }
1723        assert!(subcommand_synonym("definitely-not-a-verb").is_none());
1724    }
1725
1726    #[test]
1727    fn parses_every_documented_verb() {
1728        let commands: &[&[&str]] = &[
1729            &["sloop", "init"],
1730            &["sloop", "template", "ticket"],
1731            &["sloop", "template", "flow"],
1732            &["sloop", "template", "project"],
1733            &["sloop", "template", "config"],
1734            &["sloop", "daemon"],
1735            &["sloop", "post", "ticket.md", "--auto"],
1736            &["sloop", "post", "ticket.md", "--at", "03:00"],
1737            &["sloop", "post", "ticket.md", "--manual"],
1738            &["sloop", "post", "ticket.md", "--hold"],
1739            &["sloop", "run"],
1740            &["sloop", "run", "T1", "--at", "03:00"],
1741            &["sloop", "run", "--every", "30m", "--only", "T1,T7"],
1742            &["sloop", "run", "--overnight"],
1743            &["sloop", "retry", "T1"],
1744            &["sloop", "hold", "T1"],
1745            &["sloop", "ready", "T1"],
1746            &["sloop", "list"],
1747            &["sloop", "status"],
1748            &["sloop", "pause"],
1749            &["sloop", "resume"],
1750            &["sloop", "cancel", "R1"],
1751            &["sloop", "logs", "R1"],
1752            &["sloop", "logs", "R1", "--stage", "test"],
1753            &["sloop", "logs", "R1", "--tail", "50"],
1754            &[
1755                "sloop", "logs", "R1", "--stage", "test", "--tail", "5", "--follow",
1756            ],
1757            &["sloop", "watch"],
1758            &["sloop", "watch", "--tail", "50"],
1759            &["sloop", "watch", "T1"],
1760            &["sloop", "watch", "T1-r2", "--tail", "50"],
1761            &["sloop", "reindex"],
1762            &["sloop", "brief"],
1763            &["sloop", "show", "T1"],
1764            &["sloop", "note", "work", "in", "progress"],
1765            &["sloop", "verdict", "fail", "--reason", "changes requested"],
1766        ];
1767
1768        for command in commands {
1769            Cli::try_parse_from(*command).unwrap_or_else(|error| {
1770                panic!("failed to parse {command:?}: {error}");
1771            });
1772        }
1773    }
1774
1775    #[test]
1776    fn every_documented_verb_constructs_a_typed_request() {
1777        let commands: &[&[&str]] = &[
1778            &["sloop", "init"],
1779            &["sloop", "daemon"],
1780            &["sloop", "post", "ticket.md", "--auto"],
1781            &["sloop", "run"],
1782            &["sloop", "retry", "T1"],
1783            &["sloop", "hold", "T1"],
1784            &["sloop", "ready", "T1"],
1785            &["sloop", "list"],
1786            &["sloop", "status"],
1787            &["sloop", "pause"],
1788            &["sloop", "resume"],
1789            &["sloop", "cancel", "R1"],
1790            &["sloop", "logs", "R1"],
1791            &["sloop", "logs", "R1", "--stage", "test", "--tail", "5"],
1792            &["sloop", "watch"],
1793            &["sloop", "watch", "T1"],
1794            &["sloop", "reindex"],
1795            &["sloop", "brief"],
1796            &["sloop", "show", "T1"],
1797            &["sloop", "note", "working"],
1798            &["sloop", "verdict", "pass"],
1799        ];
1800
1801        for command in commands {
1802            Cli::try_parse_from(*command)
1803                .unwrap()
1804                .into_request()
1805                .unwrap_or_else(|error| panic!("failed to construct {command:?}: {error}"));
1806        }
1807    }
1808
1809    #[test]
1810    fn run_options_become_protocol_arguments() {
1811        let request = Cli::try_parse_from(["sloop", "run", "--every", "30m", "--only", "T1,T7"])
1812            .unwrap()
1813            .into_request()
1814            .unwrap();
1815
1816        assert_eq!(
1817            serde_json::to_value(request).unwrap(),
1818            json!({
1819                "verb": "run",
1820                "args": {
1821                    "trigger": {"kind": "every", "interval_ms": 1_800_000},
1822                    "only": ["T1", "T7"]
1823                }
1824            })
1825        );
1826    }
1827
1828    #[test]
1829    fn hold_becomes_a_distinct_post_trigger() {
1830        let request = Cli::try_parse_from(["sloop", "post", "ticket.md", "--hold"])
1831            .unwrap()
1832            .into_request()
1833            .unwrap();
1834
1835        assert_eq!(
1836            serde_json::to_value(request).unwrap(),
1837            json!({
1838                "verb": "post",
1839                "args": {
1840                    "file": "ticket.md",
1841                    "trigger": {"kind": "hold"}
1842                }
1843            })
1844        );
1845    }
1846
1847    #[test]
1848    fn worker_request_text_and_capability_are_preserved() {
1849        let request = Cli::try_parse_from(["sloop", "note", "work", "in", "progress"])
1850            .unwrap()
1851            .into_request()
1852            .unwrap();
1853
1854        assert_eq!(request.capability(), Capability::Worker);
1855        assert_eq!(
1856            serde_json::to_value(request).unwrap(),
1857            json!({"verb": "note", "args": {"text": "work in progress"}})
1858        );
1859    }
1860
1861    #[test]
1862    fn verdict_becomes_a_worker_request() {
1863        let request =
1864            Cli::try_parse_from(["sloop", "verdict", "fail", "--reason", "changes requested"])
1865                .unwrap()
1866                .into_request()
1867                .unwrap();
1868
1869        assert_eq!(request.capability(), Capability::Worker);
1870        assert_eq!(
1871            serde_json::to_value(request).unwrap(),
1872            json!({
1873                "verb": "verdict",
1874                "args": {"verdict": "fail", "reason": "changes requested"}
1875            })
1876        );
1877    }
1878
1879    #[test]
1880    fn operator_requests_are_classified_separately() {
1881        let request = Cli::try_parse_from(["sloop", "status"])
1882            .unwrap()
1883            .into_request()
1884            .unwrap();
1885
1886        assert_eq!(request.capability(), Capability::Operator);
1887        assert!(matches!(request, Request::Status(_)));
1888    }
1889
1890    /// Drives the full entry point and returns stdout, so these tests prove
1891    /// the verb answers without a daemon: the test process has no repository,
1892    /// no socket, and no `SLOOP_TOKEN`, and any daemon path would fail.
1893    fn stdout_of(args: &[&str]) -> String {
1894        let mut stdout = Vec::new();
1895        let mut stderr = Vec::new();
1896        let mut argv = vec!["sloop"];
1897        argv.extend_from_slice(args);
1898        let code = super::run(argv, &mut stdout, &mut stderr);
1899
1900        assert_eq!(
1901            format!("{code:?}"),
1902            format!("{:?}", std::process::ExitCode::SUCCESS),
1903            "stderr: {}",
1904            String::from_utf8_lossy(&stderr)
1905        );
1906        String::from_utf8(stdout).expect("stdout is UTF-8")
1907    }
1908
1909    #[test]
1910    fn every_template_kind_prints_verbatim_without_a_daemon() {
1911        for kind in ["ticket", "flow", "project", "config"] {
1912            let printed = stdout_of(&["template", kind]);
1913            let expected = super::TemplateKind::from_str(kind, false)
1914                .expect("kind is accepted")
1915                .text();
1916            assert_eq!(printed, expected, "`sloop template {kind}` was rewritten");
1917        }
1918    }
1919
1920    #[test]
1921    fn template_json_mode_wraps_the_text_in_an_envelope() {
1922        let mut stdout = Vec::new();
1923        let mut stderr = Vec::new();
1924        super::run(
1925            ["sloop", "--json", "template", "flow"],
1926            &mut stdout,
1927            &mut stderr,
1928        );
1929
1930        let envelope: Value = serde_json::from_slice(&stdout).expect("stdout carries an envelope");
1931        assert_eq!(envelope["ok"], true);
1932        assert_eq!(envelope["data"]["kind"], "flow");
1933        assert_eq!(
1934            envelope["data"]["template"].as_str(),
1935            Some(super::TemplateKind::Flow.text())
1936        );
1937    }
1938
1939    #[test]
1940    fn an_unknown_template_kind_lists_the_valid_kinds() {
1941        let envelope = error_envelope(&["template", "readme"]);
1942
1943        assert_eq!(envelope["error"]["code"], "invalid_arguments");
1944        let message = envelope["error"]["message"]
1945            .as_str()
1946            .expect("error message");
1947        for kind in ["ticket", "flow", "project", "config"] {
1948            assert!(message.contains(kind), "`{kind}` missing from: {message}");
1949        }
1950    }
1951
1952    /// `template` answers from compiled-in content, so it deliberately has no
1953    /// protocol verb. Constructing a request must fail rather than silently
1954    /// routing it at the daemon.
1955    #[test]
1956    fn template_never_becomes_a_daemon_request() {
1957        let error = Cli::try_parse_from(["sloop", "template", "ticket"])
1958            .unwrap()
1959            .into_request()
1960            .expect_err("template has no daemon request");
1961
1962        assert!(error.to_string().contains("printed locally"), "{error}");
1963    }
1964
1965    #[test]
1966    fn post_help_points_at_the_ticket_template() {
1967        let help = stdout_of(&["post", "--help"]);
1968
1969        assert!(help.contains("sloop template ticket"), "{help}");
1970        assert!(help.contains("sloop template flow"), "{help}");
1971    }
1972
1973    #[test]
1974    fn invalid_time_and_duration_fail_during_cli_parsing() {
1975        assert!(Cli::try_parse_from(["sloop", "run", "--at", "25:00"]).is_err());
1976        assert!(Cli::try_parse_from(["sloop", "run", "--every", "later"]).is_err());
1977        assert!(Cli::try_parse_from(["sloop", "run", "--every", "0m"]).is_err());
1978    }
1979
1980    #[test]
1981    fn run_accepts_only_one_trigger_mode() {
1982        let result = Cli::try_parse_from(["sloop", "run", "--at", "03:00", "--every", "30m"]);
1983        assert!(result.is_err());
1984    }
1985
1986    #[test]
1987    fn post_defaults_to_auto_and_accepts_only_one_explicit_trigger_mode() {
1988        let request = Cli::try_parse_from(["sloop", "post", "ticket.md"])
1989            .unwrap()
1990            .into_request()
1991            .unwrap();
1992        assert_eq!(
1993            serde_json::to_value(request).unwrap(),
1994            json!({
1995                "verb": "post",
1996                "args": {
1997                    "file": "ticket.md",
1998                    "trigger": {"kind": "auto"}
1999                }
2000            })
2001        );
2002        assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--auto", "--manual"]).is_err());
2003        assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--manual", "--hold"]).is_err());
2004    }
2005}