Skip to main content

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