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