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