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