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