1use serde::{Deserialize, Serialize};
7
8use crate::actions::{PlanFrom, Runnable, Through};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum View {
14 Doctor,
16 BugReport,
18 Report,
20 Live,
22 Approvals,
25 Explain,
27 Review,
29 Settings,
31 Setup,
33 Bug,
35 QuickChange,
37 NewFeature,
39 NewProject,
41 #[serde(rename = "github")]
43 GitHub,
44 Task,
46 Ask,
48 Explore,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "how", rename_all = "snake_case")]
55pub enum How {
56 Perform {
58 run: Box<Runnable>,
60 input: Option<String>,
62 text: Option<String>,
64 confirm: Option<String>,
67 feature: Option<String>,
69 },
70 View {
72 view: View,
74 subject: Option<String>,
76 },
77 Command {
82 argvs: Vec<Vec<String>>,
84 interactive: bool,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
92 outside: Option<String>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 token: Option<String>,
97 },
98 Shell {
101 why: String,
103 setting: Option<String>,
105 },
106 Words {
108 why: Option<String>,
110 },
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
115pub struct Step {
116 pub command: String,
118 #[serde(flatten)]
120 pub how: How,
121}
122
123#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub struct Context<'a> {
126 pub feature: Option<&'a str>,
128 pub task: Option<&'a str>,
130}
131
132const PROGRAMS: &[&str] = &[
135 "aws",
136 "brew",
137 "bun",
138 "bundle",
139 "cargo",
140 "claude",
141 "codex",
142 "composer",
143 "copilot",
144 "corepack",
145 "deno",
146 "dotnet",
147 "gem",
148 "gemini",
149 "gh",
150 "git",
151 "go",
152 "gpg",
153 "gradle",
154 "just",
155 "make",
156 "mise",
157 "mkdir",
158 "mvn",
159 "npm",
160 "npx",
161 "opencode",
162 "openspec",
163 "pip",
164 "pip3",
165 "pipx",
166 "pnpm",
167 "poetry",
168 "python",
169 "python3",
170 "rustup",
171 "specify",
172 "ssh-keygen",
173 "uv",
174 "uvx",
175 "xcode-select",
176 "yarn",
177];
178
179const DEVELOPER: &[&str] = &["eval", "replay", "bench", "completion", "acp", "workflow"];
181
182pub const SUBCOMMANDS: &[&str] = &[
185 "acp",
186 "actions",
187 "approvals",
188 "ask",
189 "attest",
190 "auth",
191 "bench",
192 "bug",
193 "completion",
194 "config",
195 "converge",
196 "do",
197 "doctor",
198 "eval",
199 "explain",
200 "feature",
201 "fix",
202 "help",
203 "inbox",
204 "init",
205 "inspect",
206 "issue",
207 "new",
208 "pr",
209 "preset",
210 "propose",
211 "replay",
212 "report",
213 "review",
214 "run",
215 "sample",
216 "schema",
217 "spec",
218 "status",
219 "suggest",
220 "task",
221 "tui",
222 "ui",
223 "verify",
224 "workflow",
225 "worktree",
226];
227
228const BLANK: &str = "…";
230
231pub type Argvs = Vec<Vec<String>>;
233
234#[must_use]
245pub fn step_of(line: &str, ctx: Context<'_>) -> Step {
246 let filled = fill(line, ctx);
247 Step {
248 command: line.to_owned(),
249 how: how(&filled),
250 }
251}
252
253#[must_use]
256pub fn person(argvs: Argvs) -> Step {
257 Step {
258 command: render(&argvs),
259 how: command(argvs),
260 }
261}
262
263#[must_use]
266pub fn steps_with(fix: &[String], typed: &[Argvs], ctx: Context<'_>) -> Vec<Step> {
267 fix.iter()
268 .map(|f| match typed.iter().find(|t| render(t) == *f) {
269 Some(t) => person(t.clone()),
270 None => step_of(f, ctx),
271 })
272 .collect()
273}
274
275#[must_use]
278pub fn render(argvs: &[Vec<String>]) -> String {
279 let word = |w: &String| {
280 let plain = !w.is_empty()
281 && w.chars().all(|c| {
282 c.is_ascii_alphanumeric()
283 || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | ',' | '@' | '+')
284 });
285 if plain {
286 w.clone()
287 } else {
288 format!("'{}'", w.replace('\'', "'\\''"))
289 }
290 };
291 argvs
292 .iter()
293 .map(|a| a.iter().map(word).collect::<Vec<_>>().join(" "))
294 .collect::<Vec<_>>()
295 .join(" && ")
296}
297
298fn fill(line: &str, ctx: Context<'_>) -> String {
299 let mut s = line.to_owned();
300 if let Some(t) = ctx.task {
301 s = s.replace("{task}", t).replace("<task>", t);
302 }
303 if let Some(f) = ctx.feature {
304 s = s.replace("{feature}", f).replace("<feature>", f);
305 }
306 s
307}
308
309fn words(why: Option<&str>) -> How {
310 How::Words {
311 why: why.map(str::to_owned),
312 }
313}
314
315const NAMES_VALUES: &str = "it names files or values of this project: run it in your shell";
317
318fn said(line: &str) -> &str {
320 let line = line.trim();
321 match line.rfind(" (") {
322 Some(at) if line.ends_with(')') => line[..at].trim_end(),
323 _ => line,
324 }
325}
326
327fn how(line: &str) -> How {
328 let line = said(line);
329 if line.starts_with('→') || line.is_empty() {
330 return words(None);
331 }
332 if let Some(rest) = line.strip_prefix("export ") {
333 let name = rest.split('=').next().unwrap_or_default().trim();
334 let model = name.ends_with("_API_KEY") || name.starts_with("AWS_");
335 return How::Shell {
336 why: format!(
337 "`{name}` must be set where speccoder starts: a command run from here ends with its own shell, and the variable with it"
338 ),
339 setting: model.then(|| "model".to_owned()),
340 };
341 }
342 let Some(chain) = argvs(line) else {
343 return words(Some("a shell line: run it in your shell"));
344 };
345 let Some(first) = chain.first() else {
346 return words(None);
347 };
348 let constant = constant(&chain);
349 if first.first().map(String::as_str) == Some("speccoder") {
350 let known = first
351 .get(1)
352 .is_some_and(|w| SUBCOMMANDS.contains(&w.as_str()));
353 return if known && chain.len() == 1 {
354 speccoder(first, constant)
355 } else {
356 words(None)
357 };
358 }
359 if chain
360 .iter()
361 .any(|a| a.first().map(String::as_str) == Some("sudo"))
362 {
363 return words(Some("it asks for your password: run it in your shell"));
364 }
365 if chain.iter().any(|a| {
366 a.first()
367 .is_none_or(|p| p == "speccoder" || !PROGRAMS.contains(&p.as_str()))
368 }) {
369 return words(None);
370 }
371 if chain.iter().flatten().any(|w| has_blank(w)) {
372 return words(Some("it has a part only you can fill in"));
373 }
374 if constant {
375 command(chain)
376 } else {
377 words(Some(NAMES_VALUES))
378 }
379}
380
381const BUILT: &[&str] = &[
384 "speccoder run --in-place",
385 "speccoder run --in-place --return",
386 "speccoder run --ignore-checklists",
387 "speccoder run --allow-failing-baseline",
388 "speccoder run --parallel 2",
389 "speccoder run --task {task} --fresh-acceptance",
390 "speccoder attest verify",
391 "speccoder attest verify --feature {feature} --json",
392 "speccoder feature --clear",
393 "speccoder explain --list",
394 "speccoder fix --dry-run",
395 "speccoder inspect",
396 "speccoder worktree",
397 "speccoder verify",
398 "speccoder schema",
399 "speccoder sample",
400 "speccoder spec archive",
401 "speccoder spec archive --yes",
402 "git stash -u",
403 "git status",
404 "speccoder spec archive {change} --yes",
405 "speccoder config commit",
406 "speccoder help",
407 "speccoder help advanced",
408 "speccoder sample --fresh --yes",
409 "speccoder spec",
410 "ssh-keygen -t ed25519",
411 "git config --global gpg.format ssh",
412];
413
414fn templates() -> &'static [Argvs] {
416 static PARSED: std::sync::OnceLock<Vec<Argvs>> = std::sync::OnceLock::new();
417 PARSED.get_or_init(|| {
418 crate::codes::CATALOG
419 .iter()
420 .flat_map(|e| e.fix.iter())
421 .filter(|l| !l.contains("{command}"))
424 .chain(BUILT.iter())
425 .filter_map(|l| argvs(said(l)))
426 .collect()
427 })
428}
429
430fn plain(v: &str) -> bool {
433 v.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
434 && v.chars()
435 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
436}
437
438fn fills(t: &str, w: &str) -> bool {
440 let Some(open) = t.find('{') else {
441 return t == w;
442 };
443 let Some(close) = t[open..].find('}').map(|c| open + c) else {
444 return t == w;
445 };
446 let (head, rest) = (&t[..open], &t[close + 1..]);
447 let Some(w) = w.strip_prefix(head) else {
448 return false;
449 };
450 (1..=w.len())
452 .filter(|n| w.is_char_boundary(*n))
453 .any(|n| plain(&w[..n]) && fills(rest, &w[n..]))
454}
455
456fn constant(chain: &[Vec<String>]) -> bool {
459 let matches = |t: &[String], a: &[String]| {
460 let a = match a {
461 [rest @ .., f, v]
462 if f == "--feature" && plain(v) && !t.iter().any(|w| w == "--feature") =>
463 {
464 rest
465 }
466 a => a,
467 };
468 t.len() == a.len() && t.iter().zip(a).all(|(t, w)| fills(t, w))
469 };
470 templates()
471 .iter()
472 .any(|t| t.len() == chain.len() && t.iter().zip(chain).all(|(t, a)| matches(t, a)))
473}
474
475fn has_blank(w: &str) -> bool {
476 w.contains(BLANK) || (w.contains('<') && w.contains('>')) || w.contains('{')
477}
478
479type Signer = std::rc::Rc<dyn Fn(&[Vec<String>], bool) -> String>;
480
481thread_local! {
482 static SIGNER: std::cell::RefCell<Option<Signer>> = const { std::cell::RefCell::new(None) };
483}
484
485pub fn signing<T>(signer: Signer, f: impl FnOnce() -> T) -> T {
489 struct Reset(Option<Signer>);
490 impl Drop for Reset {
491 fn drop(&mut self) {
492 let before = self.0.take();
493 SIGNER.with(|s| *s.borrow_mut() = before);
494 }
495 }
496 let before = SIGNER.with(|s| s.borrow_mut().replace(signer));
497 let _reset = Reset(before);
498 f()
499}
500
501fn command(argvs: Argvs) -> How {
502 let interactive = argvs.iter().any(|a| interactive(a));
503 let token = SIGNER.with(|s| s.borrow().as_ref().map(|f| f(&argvs, interactive)));
504 How::Command {
505 interactive,
506 outside: argvs.iter().find_map(|a| outside(a)),
507 argvs,
508 token,
509 }
510}
511
512#[must_use]
516pub fn outside(argv: &[String]) -> Option<String> {
517 match argv
518 .iter()
519 .map(String::as_str)
520 .collect::<Vec<_>>()
521 .as_slice()
522 {
523 ["git", "config", "--global", ..] => Some("changes your global git config".to_owned()),
524 ["git", "worktree", "remove", path] => Some(format!(
525 "removes the worktree at {path}, outside the project"
526 )),
527 ["brew", "install", .., package] => {
528 Some(format!("installs {package} on this machine with Homebrew"))
529 }
530 ["npm", "install", "-g" | "--global", package] => {
531 Some(format!("installs {package} on this machine with npm"))
532 }
533 _ => None,
534 }
535}
536
537#[must_use]
540pub fn interactive(argv: &[String]) -> bool {
541 let w: Vec<&str> = argv.iter().map(String::as_str).collect();
542 let has = |f: &[&str]| {
543 w.iter()
544 .any(|x| f.contains(x) || f.iter().any(|p| p.ends_with('=') && x.starts_with(p)))
545 };
546 match w.as_slice() {
547 ["copilot", "login", ..]
548 | ["opencode", "auth", "login", ..]
549 | ["gh", "auth", "login" | "refresh", ..]
550 | ["aws", "login" | "configure", ..]
551 | ["aws", "sso", "login", ..]
552 | ["claude", ..]
553 | ["codex", "login", ..]
554 | ["codex"]
555 | ["gemini", ..]
556 | ["npm", "login" | "adduser", ..]
557 | ["specify", "init", ..]
558 | ["openspec", "init", ..]
559 | [
560 "gpg",
561 "--full-generate-key" | "--gen-key" | "--edit-key",
562 ..,
563 ]
564 | ["speccoder", "tui" | "ui", ..]
565 | ["speccoder", "auth", "login", ..] => true,
566 ["speccoder", "spec", "archive", ..] => !has(&["--yes"]),
567 ["ssh-keygen", ..] => !(has(&["-N"]) && has(&["-f"])),
568 ["git", "commit", ..] => !has(&["-m", "-F", "--file", "--no-edit", "-C", "--message="]),
569 ["git", "merge", ..] => !has(&["--no-edit", "--ff-only", "--abort"]),
570 ["git", "rebase", ..] => has(&["-i", "--interactive"]),
571 ["git", "add", ..] => has(&["-p", "-i", "--patch", "--interactive"]),
572 _ => false,
573 }
574}
575
576fn argvs(line: &str) -> Option<Vec<Vec<String>>> {
579 let mut out = vec![Vec::new()];
580 let mut word = String::new();
581 let mut in_word = false;
582 let mut quote: Option<char> = None;
583 let mut chars = line.chars().peekable();
584 while let Some(c) = chars.next() {
585 match (quote, c) {
586 (Some(q), c) if c == q => quote = None,
587 (Some('"'), '\\') => word.push(chars.next()?),
588 (Some('"'), '$' | '`') => return None,
589 (Some(_), c) => word.push(c),
590 (None, '"' | '\'') => {
591 quote = Some(c);
592 in_word = true;
593 }
594 (None, '&') if !in_word && chars.peek() == Some(&'&') => {
596 chars.next();
597 if !chars.peek().is_none_or(|c| c.is_whitespace()) {
598 return None;
599 }
600 out.push(Vec::new());
601 }
602 (None, '#') if !in_word => break,
604 (None, '<') => {
606 word.push('<');
607 loop {
608 let c = chars.next()?;
609 word.push(c);
610 if c == '>' {
611 break;
612 }
613 }
614 in_word = true;
615 }
616 (None, '|' | ';' | '>' | '$' | '`' | '&' | '(' | ')' | '*' | '~') => return None,
617 (None, c) if c.is_whitespace() => {
618 if in_word {
619 out.last_mut()?.push(std::mem::take(&mut word));
620 in_word = false;
621 }
622 }
623 (None, '\\') => return None,
624 (None, c) => {
625 word.push(c);
626 in_word = true;
627 }
628 }
629 }
630 if quote.is_some() {
631 return None;
632 }
633 if in_word {
634 out.last_mut()?.push(word);
635 }
636 (!out.iter().any(Vec::is_empty)).then_some(out)
637}
638
639fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
641 args.iter()
642 .position(|a| a == name)
643 .and_then(|i| args.get(i + 1))
644 .map(String::as_str)
645}
646
647fn has(args: &[String], name: &str) -> bool {
648 args.iter().any(|a| a == name)
649}
650
651fn positional(args: &[String], skip: usize) -> Option<&str> {
653 let mut i = skip;
654 while let Some(a) = args.get(i) {
655 if a.starts_with('-') {
656 let valued = matches!(
658 a.as_str(),
659 "--feature"
660 | "--reason"
661 | "--note"
662 | "--story"
663 | "--task"
664 | "--remote"
665 | "--through"
666 );
667 i += if valued { 2 } else { 1 };
668 continue;
669 }
670 return Some(a);
671 }
672 None
673}
674
675fn input(given: Option<&str>, prompt: &str) -> (Option<String>, Option<String>) {
678 match given {
679 Some(t) if !t.contains(BLANK) && !t.trim().is_empty() => (None, Some(t.to_owned())),
680 _ => (Some(prompt.to_owned()), None),
681 }
682}
683
684fn view(view: View, subject: Option<&str>) -> How {
685 How::View {
686 view,
687 subject: subject.map(str::to_owned),
688 }
689}
690
691fn named_feature(args: &[String]) -> Option<String> {
693 flag(args, "--feature")
694 .filter(|f| !has_blank(f))
695 .map(str::to_owned)
696}
697
698fn perform(run: Runnable, args: &[String]) -> How {
699 How::Perform {
700 run: Box::new(run),
701 input: None,
702 text: None,
703 confirm: None,
704 feature: named_feature(args),
705 }
706}
707
708fn asking(run: Runnable, args: &[String], given: Option<&str>, prompt: &str) -> How {
709 let (input, text) = input(given, prompt);
710 How::Perform {
711 run: Box::new(run),
712 input,
713 text,
714 confirm: None,
715 feature: named_feature(args),
716 }
717}
718
719fn confirmed(run: Runnable, args: &[String], confirm: &str) -> How {
720 How::Perform {
721 run: Box::new(run),
722 input: None,
723 text: None,
724 confirm: Some(confirm.to_owned()),
725 feature: named_feature(args),
726 }
727}
728
729pub const PR_CONFIRM: &str = "Push the feature's speccoder branch and its attestation notes to `origin`, and open (or update) its draft pull request on GitHub, as you (your token).";
731
732#[allow(clippy::too_many_lines)] fn speccoder(argv: &[String], constant: bool) -> How {
735 let mut args: Vec<String> = argv.get(1..).unwrap_or_default().to_vec();
737 if let Some(i) = args.iter().position(|a| a == "--feature")
738 && args.get(i + 1).is_some_and(|f| has_blank(f))
739 {
740 args.drain(i..=i + 1);
741 }
742 let a = args.as_slice();
743 let word = |i: usize| a.get(i).map(String::as_str);
744 let task = |i: usize| {
745 positional(a, i)
746 .filter(|t| !has_blank(t))
747 .map(str::to_owned)
748 };
749 let run_cmd = || {
751 if a.iter().any(|w| has_blank(w)) {
752 words(Some("it has a part only you can fill in"))
753 } else if constant {
754 command(vec![argv.to_vec()])
755 } else {
756 words(Some(NAMES_VALUES))
757 }
758 };
759 match (word(0), word(1)) {
760 (Some("doctor"), _) if has(a, "--bug-report") => view(View::BugReport, None),
761 (Some("doctor"), _) => view(View::Doctor, None),
762 (Some("status"), _) => view(View::Report, None),
763 (Some("report"), _) if has(a, "--approvals") => view(View::Approvals, None),
764 (Some("report"), _) if has(a, "--live") => view(View::Live, None),
765 (Some("report"), _) => view(View::Report, None),
766 (Some("tui"), _) => view(View::Live, None),
767 (Some("ui" | "inbox" | "approvals"), _) if !has(a, "-C") => view(View::Approvals, None),
768 (Some("explain"), Some(code)) if !code.starts_with('-') => view(View::Explain, Some(code)),
769 (Some("run"), _) if has(a, "--converge") && !a.iter().any(|w| w == "--task") => {
770 perform(Runnable::Converge, a)
771 }
772 (Some("converge"), _) => perform(Runnable::Converge, a),
773 (Some("run"), _) => {
774 let mut rest = a.iter().skip(1);
777 let mut plain = true;
778 while let Some(w) = rest.next() {
779 match w.as_str() {
780 "--task" | "--feature" | "--agent" => plain &= rest.next().is_some(),
781 "-v" => {}
782 _ => plain = false,
783 }
784 }
785 match flag(a, "--task") {
786 _ if !plain => run_cmd(),
787 Some(t) if has_blank(t) => view(View::Report, None),
788 Some(t) => perform(
789 Runnable::Run {
790 task: Some(t.to_owned()),
791 agent: flag(a, "--agent").map(str::to_owned),
792 },
793 a,
794 ),
795 None => perform(
796 Runnable::Run {
797 task: None,
798 agent: flag(a, "--agent").map(str::to_owned),
799 },
800 a,
801 ),
802 }
803 }
804 (Some("task"), Some("approve" | "reject")) => view(View::Approvals, None),
805 (Some("task"), Some(verb)) => {
806 let Some(t) = task(2) else {
807 return view(View::Report, None);
808 };
809 match verb {
810 "accept" => asking(
811 Runnable::Accept { task: t },
812 a,
813 flag(a, "--reason"),
814 "Why it is done",
815 ),
816 "skip" => asking(
817 Runnable::Skip { task: t },
818 a,
819 flag(a, "--reason"),
820 "Why it is skipped",
821 ),
822 "nudge" => asking(
823 Runnable::Nudge { task: t },
824 a,
825 flag(a, "--note"),
826 "The note",
827 ),
828 "hint" => asking(Runnable::Hint { task: t }, a, positional(a, 3), "The hint"),
829 "rule" => asking(
830 Runnable::Rule { task: t },
831 a,
832 flag(a, "--reason"),
833 "Why the tests are right",
834 ),
835 "done" => perform(Runnable::Done { task: t }, a),
836 "defer" => perform(
837 Runnable::DeferAcceptance {
838 task: t,
839 revoke: a.iter().any(|w| w == "--revoke"),
840 },
841 a,
842 ),
843 "restore" => perform(Runnable::KeepSetAside { task: t }, a),
844 "drop" => confirmed(
845 Runnable::DropSetAside { task: t.clone() },
846 a,
847 &format!(
848 "Discard the changes set aside before {t}? They are deleted, not kept anywhere."
849 ),
850 ),
851 _ => view(View::Task, Some(&t)),
852 }
853 }
854 (Some("review"), Some("approve")) if a.len() == 2 => perform(
855 Runnable::Review {
856 ids: Vec::new(),
857 verdict: "approve".to_owned(),
858 },
859 a,
860 ),
861 (Some("review"), Some("answer")) => match task(2) {
862 Some(q) => asking(
863 Runnable::Review {
864 ids: vec![q],
865 verdict: "answer".to_owned(),
866 },
867 a,
868 positional(a, 3),
869 "Your answer",
870 ),
871 None => view(View::Review, None),
872 },
873 (Some("review"), _) => view(View::Review, None),
874 (Some(_), _) if has(a, "-C") => words(Some("it is another project's: run it there")),
876 (Some("spec"), Some("archive")) => run_cmd(),
877 (Some("spec"), Some("explore")) => view(
880 View::Explore,
881 match positional(a, 2) {
882 Some("choose") => positional(a, 4),
883 Some("resume" | "start") => positional(a, 3),
884 _ => None,
885 }
886 .filter(|s| s.starts_with("ex-")),
887 ),
888 (Some("spec"), Some("promote")) => match positional(a, 2) {
889 Some(task) if has(a, "--decline") => perform(
890 Runnable::DeclinePromotion {
891 task: task.to_owned(),
892 },
893 a,
894 ),
895 _ => view(View::Approvals, None),
896 },
897 (Some("spec"), Some(name)) if !has_blank(name) && !name.starts_with('-') => {
898 let given = positional(a, 2);
899 if let Some(through) = flag(a, "--through").and_then(Through::parse) {
900 return match given {
902 Some(_) => view(View::NewFeature, None),
903 None => perform(
904 Runnable::PlanThrough {
905 from: PlanFrom::Continue,
906 through,
907 },
908 a,
909 ),
910 };
911 }
912 if a.iter()
913 .skip(2)
914 .any(|w| w.starts_with('-') && w != "--feature")
915 {
916 return run_cmd();
918 }
919 let skill = |arguments: &str| Runnable::Skill {
920 name: name.to_owned(),
921 arguments: arguments.to_owned(),
922 };
923 match given {
924 None => perform(skill(""), a),
925 Some(w) if has_blank(w) => asking(skill(""), a, None, "Describe it"),
926 Some(w) => perform(skill(w), a),
927 }
928 }
929 (Some("bug"), Some("keep")) => match task(2) {
930 Some(t) => asking(
931 Runnable::BugKeep { task: t },
932 a,
933 flag(a, "--reason"),
934 "Why it is kept",
935 ),
936 None => view(View::Bug, None),
937 },
938 (Some("bug"), Some("run")) => match flag(a, "--task").filter(|t| !has_blank(t)) {
939 Some(t) => perform(
940 Runnable::BugRun {
941 slug: None,
942 task: Some(t.to_owned()),
943 },
944 a,
945 ),
946 None => view(View::Bug, None),
947 },
948 (Some("bug"), _) => view(View::Bug, None),
949 (Some("do"), _) => view(View::QuickChange, None),
950 (Some("ask"), _) => view(View::Ask, None),
951 (Some("pr"), _) if flag(a, "--remote").is_some() => {
952 words(Some("name the remote it pushes to"))
953 }
954 (Some("pr"), _) => confirmed(Runnable::PullRequest, a, PR_CONFIRM),
955 (Some("fix"), _) if a.len() == 1 => perform(Runnable::FixSetup, a),
956 (Some("preset"), w) if w != Some("add") => words(None),
957 (Some("preset"), _) => confirmed(
958 Runnable::PresetAdd { update: false },
959 a,
960 "Install the speccoder preset with the spec tool's own preset command? It writes the spec tool's preset directory and registry; no spec file changes.",
961 ),
962 (Some("feature"), Some("similar")) => view(View::NewFeature, None),
963 (Some("feature"), None) => asking(Runnable::Feature, a, None, "The feature"),
964 (Some("feature"), Some(name)) if !name.starts_with('-') => asking(
965 Runnable::Feature,
966 a,
967 Some(name).filter(|n| !has_blank(n)),
968 "The feature",
969 ),
970 (Some("init"), _) => view(View::Setup, None),
971 (Some("config"), None | Some("get" | "unset")) => view(View::Settings, None),
972 (Some("config"), Some("set")) if a.iter().any(|w| has_blank(w)) => {
973 view(View::Settings, None)
974 }
975 (Some("new"), _) => view(View::NewProject, None),
976 (Some("auth"), _) => view(View::GitHub, None),
977 (Some(dev), _) if DEVELOPER.contains(&dev) => {
978 words(Some("a developer tool: run it in your shell"))
979 }
980 _ => run_cmd(),
981 }
982}
983
984impl How {
985 #[must_use]
987 pub const fn copy_only(&self) -> bool {
988 matches!(self, Self::Words { .. } | Self::Shell { .. })
989 }
990}
991
992#[must_use]
994pub fn steps(fix: &[String], ctx: Context<'_>) -> Vec<Step> {
995 steps_with(fix, &[], ctx)
996}
997
998#[cfg(test)]
999mod tests {
1000 use super::*;
1001
1002 fn h(line: &str) -> How {
1003 step_of(
1004 line,
1005 Context {
1006 feature: Some("001-x"),
1007 task: Some("T001"),
1008 },
1009 )
1010 .how
1011 }
1012
1013 fn run_of(line: &str) -> (Runnable, Option<String>, Option<String>) {
1014 match h(line) {
1015 How::Perform {
1016 run, input, text, ..
1017 } => (*run, input, text),
1018 other => panic!("{line}: {other:?}"),
1019 }
1020 }
1021
1022 fn view_of(line: &str) -> (View, Option<String>) {
1023 match h(line) {
1024 How::View { view, subject } => (view, subject),
1025 other => panic!("{line}: {other:?}"),
1026 }
1027 }
1028
1029 fn argvs_of(line: &str) -> (Vec<Vec<String>>, bool) {
1030 match h(line) {
1031 How::Command {
1032 argvs, interactive, ..
1033 } => (argvs, interactive),
1034 other => panic!("{line}: {other:?}"),
1035 }
1036 }
1037
1038 fn s(v: &[&str]) -> Vec<String> {
1039 v.iter().map(|w| (*w).to_owned()).collect()
1040 }
1041
1042 #[test]
1043 fn read_only_commands_open_their_view() {
1044 assert_eq!(view_of("speccoder doctor").0, View::Doctor);
1045 assert_eq!(
1046 view_of("speccoder doctor --feature {feature}").0,
1047 View::Doctor
1048 );
1049 assert_eq!(view_of("speccoder doctor --bug-report").0, View::BugReport);
1050 assert_eq!(view_of("speccoder report").0, View::Report);
1051 assert_eq!(view_of("speccoder status").0, View::Report);
1052 assert_eq!(view_of("speccoder report --approvals").0, View::Approvals);
1053 assert_eq!(view_of("speccoder report --live").0, View::Live);
1054 assert_eq!(view_of("speccoder report --feature <name>").0, View::Report);
1055 assert_eq!(
1056 view_of("speccoder explain NEEDS-APPROVAL"),
1057 (View::Explain, Some("NEEDS-APPROVAL".to_owned()))
1058 );
1059 assert_eq!(view_of("speccoder task approve {task}").0, View::Approvals);
1060 assert_eq!(view_of("speccoder init --force").0, View::Setup);
1061 assert_eq!(view_of("speccoder config").0, View::Settings);
1062 assert_eq!(
1063 view_of("speccoder config set checks.test.argv -- <your test command>").0,
1064 View::Settings
1065 );
1066 assert_eq!(view_of("speccoder do \"…\"").0, View::QuickChange);
1067 assert_eq!(
1068 view_of("speccoder bug \"<what happens>\" --expected \"<what should>\"").0,
1069 View::Bug
1070 );
1071 assert_eq!(
1072 view_of("speccoder review tests <REQ> --write").0,
1073 View::Review
1074 );
1075 assert_eq!(
1076 view_of("speccoder task acceptance-at {task} --story <US>"),
1077 (View::Task, Some("T001".to_owned()))
1078 );
1079 }
1080
1081 #[test]
1082 fn speccoder_commands_with_a_runnable_perform() {
1083 let t = || "T001".to_owned();
1084 assert_eq!(
1085 run_of("speccoder run").0,
1086 Runnable::Run {
1087 task: None,
1088 agent: None
1089 }
1090 );
1091 assert_eq!(
1092 run_of("speccoder run --task T004 --agent opencode").0,
1093 Runnable::Run {
1094 task: Some("T004".to_owned()),
1095 agent: Some("opencode".to_owned())
1096 }
1097 );
1098 assert_eq!(
1099 run_of("speccoder run --task {task}").0,
1100 Runnable::Run {
1101 task: Some(t()),
1102 agent: None
1103 }
1104 );
1105 assert_eq!(
1106 run_of("speccoder run --feature {feature} -v").0,
1107 Runnable::Run {
1108 task: None,
1109 agent: None
1110 }
1111 );
1112 assert_eq!(run_of("speccoder run --converge").0, Runnable::Converge);
1113 assert_eq!(
1114 run_of("speccoder converge --feature {feature}").0,
1115 Runnable::Converge
1116 );
1117 assert_eq!(
1118 run_of("speccoder task skip {task} --reason \"…\""),
1119 (
1120 Runnable::Skip { task: t() },
1121 Some("Why it is skipped".to_owned()),
1122 None
1123 )
1124 );
1125 assert_eq!(
1126 run_of("speccoder task hint {task} \"…\"").1.as_deref(),
1127 Some("The hint")
1128 );
1129 assert_eq!(
1130 run_of("speccoder task done {task}").0,
1131 Runnable::Done { task: t() }
1132 );
1133 assert_eq!(
1134 run_of("speccoder task rule {task} --tests-right --reason \"…\"").0,
1135 Runnable::Rule { task: t() }
1136 );
1137 assert_eq!(
1138 run_of("speccoder bug keep {feature}/{task} --reason \"kept as a regression test\""),
1139 (
1140 Runnable::BugKeep {
1141 task: "001-x/T001".to_owned()
1142 },
1143 None,
1144 Some("kept as a regression test".to_owned())
1145 )
1146 );
1147 assert_eq!(
1148 run_of("speccoder spec tasks"),
1149 (
1150 Runnable::Skill {
1151 name: "tasks".to_owned(),
1152 arguments: String::new()
1153 },
1154 None,
1155 None
1156 )
1157 );
1158 assert_eq!(
1159 run_of("speccoder spec plan --through tasks").0,
1160 Runnable::PlanThrough {
1161 from: PlanFrom::Continue,
1162 through: Through::Tasks
1163 }
1164 );
1165 assert_eq!(
1166 run_of("speccoder review answer Q1 \"…\"").1.as_deref(),
1167 Some("Your answer")
1168 );
1169 assert_eq!(run_of("speccoder fix").0, Runnable::FixSetup);
1170 assert!(matches!(
1171 h("speccoder pr --yes"),
1172 How::Perform { run, confirm: Some(_), .. } if *run == Runnable::PullRequest
1173 ));
1174 assert!(matches!(
1175 h("speccoder task drop {task}"),
1176 How::Perform { run, confirm: Some(_), .. }
1177 if matches!(*run, Runnable::DropSetAside { .. })
1178 ));
1179 assert_eq!(
1180 run_of("speccoder feature <name>"),
1181 (Runnable::Feature, Some("The feature".to_owned()), None)
1182 );
1183 assert!(matches!(
1185 h("speccoder run --feature 002-y"),
1186 How::Perform { feature: Some(f), .. } if f == "002-y"
1187 ));
1188 }
1189
1190 #[test]
1191 fn what_has_no_runnable_runs_as_written_when_speccoder_wrote_it() {
1192 assert_eq!(
1193 argvs_of("speccoder run --ignore-checklists").0,
1194 vec![s(&["speccoder", "run", "--ignore-checklists"])]
1195 );
1196 assert_eq!(
1197 argvs_of("speccoder attest verify --feature {feature} --json").0,
1198 vec![s(&[
1199 "speccoder",
1200 "attest",
1201 "verify",
1202 "--feature",
1203 "001-x",
1204 "--json"
1205 ])]
1206 );
1207 assert_eq!(
1208 argvs_of("speccoder spec archive --yes").0,
1209 vec![s(&["speccoder", "spec", "archive", "--yes"])]
1210 );
1211 }
1212
1213 #[test]
1214 fn the_persons_own_commands_are_exact_argvs() {
1215 assert_eq!(
1217 argvs_of("git add speccoder.toml && git commit -m \"speccoder: config\"").0,
1218 vec![
1219 s(&["git", "add", "speccoder.toml"]),
1220 s(&["git", "commit", "-m", "speccoder: config"])
1221 ]
1222 );
1223 assert_eq!(argvs_of("git stash -u").0, vec![s(&["git", "stash", "-u"])]);
1224 let built = person(vec![s(&["git", "add", "--", "my file.toml", "it's"])]);
1226 assert_eq!(built.command, "git add -- 'my file.toml' 'it'\\''s'");
1227 assert!(matches!(&built.how, How::Command { argvs, .. } if argvs[0][3] == "my file.toml"));
1228 let typed = vec![vec![s(&["npm", "install"])]];
1229 let steps = steps_with(
1230 &["npm install".to_owned(), "npm ci".to_owned()],
1231 &typed,
1232 Context::default(),
1233 );
1234 assert!(matches!(&steps[0].how, How::Command { argvs, .. } if argvs == &typed[0]));
1235 assert!(steps[1].how.copy_only(), "{:?}", steps[1]);
1237 }
1238
1239 #[test]
1240 fn spliced_values_never_become_a_command() {
1241 for line in [
1243 "git add .x&&make && git commit -m \"Track project config\"",
1244 "git add .x && make && git commit -m init",
1245 "git switch B && git merge b # resolve the conflicts",
1246 "git worktree remove /tmp/wt --force",
1247 "npm install",
1248 "speccoder run --feature 001-a --yes",
1249 "speccoder attest verify --feature 001-a --yes",
1250 ] {
1251 let how = h(line);
1252 assert!(!matches!(&how, How::Command { .. }), "{line}: {how:?}");
1253 }
1254 assert_eq!(argvs("a .x&&make"), None);
1256 assert_eq!(
1257 argvs("git merge b # resolve"),
1258 Some(vec![s(&["git", "merge", "b"])])
1259 );
1260 assert!(matches!(
1262 h("speccoder run --task T004 --fresh-acceptance"),
1263 How::Command { .. }
1264 ));
1265 assert!(!matches!(
1266 h("speccoder run --task --yes --fresh-acceptance"),
1267 How::Command { .. }
1268 ));
1269 assert!(matches!(
1270 h("speccoder run --ignore-checklists --feature 001-x"),
1271 How::Command { .. }
1272 ));
1273 }
1274
1275 #[test]
1276 fn free_slots_and_lines_about_other_commands_are_never_signed() {
1277 for line in [
1279 "speccoder run --help",
1280 "speccoder run --json",
1281 "speccoder doctor --events",
1282 ] {
1283 assert!(!matches!(h(line), How::Command { .. }), "{line}");
1284 }
1285 assert_eq!(
1287 view_of("speccoder config get model.region").0,
1288 View::Settings
1289 );
1290 assert_eq!(
1291 view_of("speccoder config unset model.base_url").0,
1292 View::Settings
1293 );
1294 }
1295
1296 #[test]
1297 fn what_writes_outside_the_project_says_so() {
1298 let global = person(vec![s(&["git", "config", "--global", "gpg.format", "ssh"])]);
1299 assert!(
1300 matches!(global.how, How::Command { outside: Some(o), .. } if o == "changes your global git config")
1301 );
1302 let local = person(vec![s(&["npm", "install"])]);
1303 assert!(matches!(local.how, How::Command { outside: None, .. }));
1304 }
1305
1306 #[test]
1307 fn what_reads_the_keyboard_is_known() {
1308 for argv in [
1309 &["copilot", "login"][..],
1310 &["opencode", "auth", "login"],
1311 &["gh", "auth", "login"],
1312 &["aws", "sso", "login", "--profile", "dev"],
1313 &["ssh-keygen", "-t", "ed25519"],
1314 &["git", "commit"],
1315 &["git", "merge", "b"],
1316 &["specify", "init", "--here"],
1317 &["speccoder", "spec", "archive"],
1318 ] {
1319 assert!(interactive(&s(argv)), "{argv:?}");
1320 }
1321 for argv in [
1322 &["brew", "install", "gh"][..],
1323 &["git", "commit", "-m", "x"],
1324 &["ssh-keygen", "-t", "ed25519", "-N", "", "-f", "k"],
1325 &["speccoder", "spec", "archive", "--yes"],
1326 ] {
1327 assert!(!interactive(&s(argv)), "{argv:?}");
1328 }
1329 }
1330
1331 #[test]
1332 fn only_a_signer_on_this_thread_signs_and_only_what_is_built() {
1333 let unsigned = person(vec![s(&["npm", "install"])]);
1334 assert!(matches!(unsigned.how, How::Command { token: None, .. }));
1335 let signed = signing(
1336 std::rc::Rc::new(|a: &[Vec<String>], i: bool| format!("{}:{i}", a.len())),
1337 || person(vec![s(&["npm", "install"])]),
1338 );
1339 assert!(matches!(signed.how, How::Command { token: Some(t), .. } if t == "1:false"));
1340 assert!(matches!(
1342 person(vec![s(&["x"])]).how,
1343 How::Command { token: None, .. }
1344 ));
1345 }
1346
1347 #[test]
1348 fn what_cannot_run_here_says_why() {
1349 assert!(matches!(
1350 h("export ANTHROPIC_API_KEY=…"),
1351 How::Shell { setting: Some(s), .. } if s == "model"
1352 ));
1353 assert!(matches!(
1354 h("export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""),
1355 How::Shell { setting: None, .. }
1356 ));
1357 for line in [
1358 "→ re-run with -v and report the output",
1359 "set [sandbox] mode = \"off\"",
1360 "curl https://example.com | sh",
1361 "sudo apt-get install bubblewrap",
1362 "git remote add origin <url>",
1363 "git diff -- <unit>/review.json",
1364 "echo $HOME",
1365 "speccoder eval --keep <dir>",
1366 "speccoder pr --remote <name>",
1367 ] {
1368 assert!(h(line).copy_only(), "{line}: {:?}", h(line));
1369 }
1370 }
1371}