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 #[arg(long, global = true)]
79 pub json: bool,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum OutputMode {
86 Json,
87 Human,
88}
89
90#[derive(Debug, Subcommand)]
91pub enum Command {
92 Init,
94 Template {
100 #[arg(value_name = "KIND")]
102 kind: TemplateKind,
103 },
104 Daemon(DaemonCliArgs),
106 Post(PostCliArgs),
108 #[command(hide = true)]
110 Run(RunCliArgs),
111 #[command(hide = true)]
113 Retry { ticket: String },
114 #[command(hide = true)]
116 Hold { ticket: String },
117 #[command(hide = true)]
119 Ready { ticket: String },
120 #[command(hide = true)]
126 List(ListCliArgs),
127 #[command(hide = true)]
129 Status,
130 #[command(hide = true)]
132 Pause,
133 #[command(hide = true)]
135 Resume,
136 #[command(hide = true)]
138 Stop {
139 #[arg(long)]
141 force: bool,
142 },
143 #[command(hide = true)]
145 Cancel {
146 run: String,
148 },
149 #[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 #[command(after_help = "See `sloop show --help` for derived state and live activity.")]
158 Logs {
159 run: String,
161 #[arg(long, value_name = "NAME")]
163 stage: Option<String>,
164 #[arg(long, value_name = "N")]
166 tail: Option<u32>,
167 #[arg(long)]
169 follow: bool,
170 },
171 #[command(hide = true)]
180 Watch {
181 r#ref: Option<String>,
183 #[arg(long, default_value_t = 20)]
185 tail: u32,
186 },
187 #[command(hide = true)]
189 Wait {
190 run: String,
192 #[arg(long, default_value_t = 3600)]
194 timeout: u64,
195 },
196 #[command(hide = true)]
198 Reindex,
199 #[command(hide = true)]
201 Brief,
202 #[command(hide = true)]
204 Note {
205 #[arg(required = true, trailing_var_arg = true)]
206 text: Vec<String>,
207 },
208 #[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 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 file: PathBuf,
251 #[arg(long, value_name = "PROJECT")]
253 project: Option<String>,
254 #[arg(long, value_name = "FLOW")]
256 flow: Option<String>,
257 #[arg(long)]
259 auto: bool,
260 #[arg(long, value_name = "TIME")]
262 at: Option<LocalTime>,
263 #[arg(long)]
265 manual: bool,
266 #[arg(long)]
268 hold: bool,
269}
270
271#[derive(Debug, Args)]
272pub struct ListCliArgs {
273 #[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 #[arg(value_name = "REF_OR_PATTERN")]
282 reference: Option<String>,
283 #[arg(long, short = 'f')]
285 follow: bool,
286 #[arg(long, short = 'q', requires = "follow")]
288 quiet: bool,
289 #[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 ticket: Option<String>,
303 #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
305 project: Option<String>,
306 #[arg(long, value_name = "TIME")]
308 at: Option<LocalTime>,
309 #[arg(long, value_name = "DURATION")]
311 every: Option<DurationMs>,
312 #[arg(long)]
314 overnight: bool,
315 #[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 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 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 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
598fn 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
623fn 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
637fn 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
766fn 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
785fn 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
928fn 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 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
976fn 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 let caught_up = next == data["latest"].as_i64();
1094 if advanced && !caught_up {
1095 continue;
1096 }
1097 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
1174fn 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
1190fn 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 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 args.tail = None;
1240 if settled {
1241 return ExitCode::SUCCESS;
1242 }
1243 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
1263fn 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
1294fn 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
1374fn 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
1497fn 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 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 #[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 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 ["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 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 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 #[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}