Skip to main content

sloop/
cli.rs

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