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" if a.iter().any(|w| w == "--tests-wrong") => asking(
830 Runnable::RuleScenario {
831 task: t,
832 scenario: flag(a, "--scenario").map(str::to_owned),
833 },
834 a,
835 flag(a, "--reason"),
836 "Why the tests are wrong",
837 ),
838 "rule" => asking(
839 Runnable::Rule { task: t },
840 a,
841 flag(a, "--reason"),
842 "Why the tests are right",
843 ),
844 "done" => perform(Runnable::Done { task: t }, a),
845 "restore" => perform(Runnable::KeepSetAside { task: t }, a),
846 "drop" => confirmed(
847 Runnable::DropSetAside { task: t.clone() },
848 a,
849 &format!(
850 "Discard the changes set aside before {t}? They are deleted, not kept anywhere."
851 ),
852 ),
853 _ => view(View::Task, Some(&t)),
854 }
855 }
856 (Some("review"), Some("approve")) if a.len() == 2 => perform(
857 Runnable::Review {
858 ids: Vec::new(),
859 verdict: "approve".to_owned(),
860 },
861 a,
862 ),
863 (Some("review"), Some("answer")) => match task(2) {
864 Some(q) => asking(
865 Runnable::Review {
866 ids: vec![q],
867 verdict: "answer".to_owned(),
868 },
869 a,
870 positional(a, 3),
871 "Your answer",
872 ),
873 None => view(View::Review, None),
874 },
875 (Some("review"), _) => view(View::Review, None),
876 (Some(_), _) if has(a, "-C") => words(Some("it is another project's: run it there")),
878 (Some("spec"), Some("archive")) => run_cmd(),
879 (Some("spec"), Some("explore")) => view(
882 View::Explore,
883 match positional(a, 2) {
884 Some("choose") => positional(a, 4),
885 Some("resume" | "start") => positional(a, 3),
886 _ => None,
887 }
888 .filter(|s| s.starts_with("ex-")),
889 ),
890 (Some("spec"), Some("promote")) => match positional(a, 2) {
891 Some(task) if has(a, "--decline") => perform(
892 Runnable::DeclinePromotion {
893 task: task.to_owned(),
894 },
895 a,
896 ),
897 _ => view(View::Approvals, None),
898 },
899 (Some("spec"), Some(name)) if !has_blank(name) && !name.starts_with('-') => {
900 let given = positional(a, 2);
901 if let Some(through) = flag(a, "--through").and_then(Through::parse) {
902 return match given {
904 Some(_) => view(View::NewFeature, None),
905 None => perform(
906 Runnable::PlanThrough {
907 from: PlanFrom::Continue,
908 through,
909 },
910 a,
911 ),
912 };
913 }
914 if a.iter()
915 .skip(2)
916 .any(|w| w.starts_with('-') && w != "--feature")
917 {
918 return run_cmd();
920 }
921 let skill = |arguments: &str| Runnable::Skill {
922 name: name.to_owned(),
923 arguments: arguments.to_owned(),
924 };
925 match given {
926 None => perform(skill(""), a),
927 Some(w) if has_blank(w) => asking(skill(""), a, None, "Describe it"),
928 Some(w) => perform(skill(w), a),
929 }
930 }
931 (Some("bug"), Some("keep")) => match task(2) {
932 Some(t) => asking(
933 Runnable::BugKeep { task: t },
934 a,
935 flag(a, "--reason"),
936 "Why it is kept",
937 ),
938 None => view(View::Bug, None),
939 },
940 (Some("bug"), Some("run")) => match flag(a, "--task").filter(|t| !has_blank(t)) {
941 Some(t) => perform(
942 Runnable::BugRun {
943 slug: None,
944 task: Some(t.to_owned()),
945 },
946 a,
947 ),
948 None => view(View::Bug, None),
949 },
950 (Some("bug"), _) => view(View::Bug, None),
951 (Some("do"), _) => view(View::QuickChange, None),
952 (Some("ask"), _) => view(View::Ask, None),
953 (Some("pr"), _) if flag(a, "--remote").is_some() => {
954 words(Some("name the remote it pushes to"))
955 }
956 (Some("pr"), _) => confirmed(Runnable::PullRequest, a, PR_CONFIRM),
957 (Some("fix"), _) if a.len() == 1 => perform(Runnable::FixSetup, a),
958 (Some("preset"), w) if w != Some("add") => words(None),
959 (Some("preset"), _) => confirmed(
960 Runnable::PresetAdd { update: false },
961 a,
962 "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.",
963 ),
964 (Some("feature"), Some("similar")) => view(View::NewFeature, None),
965 (Some("feature"), None) => asking(Runnable::Feature, a, None, "The feature"),
966 (Some("feature"), Some(name)) if !name.starts_with('-') => asking(
967 Runnable::Feature,
968 a,
969 Some(name).filter(|n| !has_blank(n)),
970 "The feature",
971 ),
972 (Some("init"), _) => view(View::Setup, None),
973 (Some("config"), None | Some("get" | "unset")) => view(View::Settings, None),
974 (Some("config"), Some("set")) if a.iter().any(|w| has_blank(w)) => {
975 view(View::Settings, None)
976 }
977 (Some("new"), _) => view(View::NewProject, None),
978 (Some("auth"), _) => view(View::GitHub, None),
979 (Some(dev), _) if DEVELOPER.contains(&dev) => {
980 words(Some("a developer tool: run it in your shell"))
981 }
982 _ => run_cmd(),
983 }
984}
985
986impl How {
987 #[must_use]
989 pub const fn copy_only(&self) -> bool {
990 matches!(self, Self::Words { .. } | Self::Shell { .. })
991 }
992}
993
994#[must_use]
996pub fn steps(fix: &[String], ctx: Context<'_>) -> Vec<Step> {
997 steps_with(fix, &[], ctx)
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 fn h(line: &str) -> How {
1005 step_of(
1006 line,
1007 Context {
1008 feature: Some("001-x"),
1009 task: Some("T001"),
1010 },
1011 )
1012 .how
1013 }
1014
1015 fn run_of(line: &str) -> (Runnable, Option<String>, Option<String>) {
1016 match h(line) {
1017 How::Perform {
1018 run, input, text, ..
1019 } => (*run, input, text),
1020 other => panic!("{line}: {other:?}"),
1021 }
1022 }
1023
1024 fn view_of(line: &str) -> (View, Option<String>) {
1025 match h(line) {
1026 How::View { view, subject } => (view, subject),
1027 other => panic!("{line}: {other:?}"),
1028 }
1029 }
1030
1031 fn argvs_of(line: &str) -> (Vec<Vec<String>>, bool) {
1032 match h(line) {
1033 How::Command {
1034 argvs, interactive, ..
1035 } => (argvs, interactive),
1036 other => panic!("{line}: {other:?}"),
1037 }
1038 }
1039
1040 fn s(v: &[&str]) -> Vec<String> {
1041 v.iter().map(|w| (*w).to_owned()).collect()
1042 }
1043
1044 #[test]
1045 fn read_only_commands_open_their_view() {
1046 assert_eq!(view_of("speccoder doctor").0, View::Doctor);
1047 assert_eq!(
1048 view_of("speccoder doctor --feature {feature}").0,
1049 View::Doctor
1050 );
1051 assert_eq!(view_of("speccoder doctor --bug-report").0, View::BugReport);
1052 assert_eq!(view_of("speccoder report").0, View::Report);
1053 assert_eq!(view_of("speccoder status").0, View::Report);
1054 assert_eq!(view_of("speccoder report --approvals").0, View::Approvals);
1055 assert_eq!(view_of("speccoder report --live").0, View::Live);
1056 assert_eq!(view_of("speccoder report --feature <name>").0, View::Report);
1057 assert_eq!(
1058 view_of("speccoder explain NEEDS-APPROVAL"),
1059 (View::Explain, Some("NEEDS-APPROVAL".to_owned()))
1060 );
1061 assert_eq!(view_of("speccoder task approve {task}").0, View::Approvals);
1062 assert_eq!(view_of("speccoder init --force").0, View::Setup);
1063 assert_eq!(view_of("speccoder config").0, View::Settings);
1064 assert_eq!(
1065 view_of("speccoder config set checks.test.argv -- <your test command>").0,
1066 View::Settings
1067 );
1068 assert_eq!(view_of("speccoder do \"…\"").0, View::QuickChange);
1069 assert_eq!(
1070 view_of("speccoder bug \"<what happens>\" --expected \"<what should>\"").0,
1071 View::Bug
1072 );
1073 assert_eq!(
1074 view_of("speccoder review tests <REQ> --write").0,
1075 View::Review
1076 );
1077 assert_eq!(
1078 view_of("speccoder task acceptance-at {task} --story <US>"),
1079 (View::Task, Some("T001".to_owned()))
1080 );
1081 }
1082
1083 #[test]
1084 fn speccoder_commands_with_a_runnable_perform() {
1085 let t = || "T001".to_owned();
1086 assert_eq!(
1087 run_of("speccoder run").0,
1088 Runnable::Run {
1089 task: None,
1090 agent: None
1091 }
1092 );
1093 assert_eq!(
1094 run_of("speccoder run --task T004 --agent opencode").0,
1095 Runnable::Run {
1096 task: Some("T004".to_owned()),
1097 agent: Some("opencode".to_owned())
1098 }
1099 );
1100 assert_eq!(
1101 run_of("speccoder run --task {task}").0,
1102 Runnable::Run {
1103 task: Some(t()),
1104 agent: None
1105 }
1106 );
1107 assert_eq!(
1108 run_of("speccoder run --feature {feature} -v").0,
1109 Runnable::Run {
1110 task: None,
1111 agent: None
1112 }
1113 );
1114 assert_eq!(run_of("speccoder run --converge").0, Runnable::Converge);
1115 assert_eq!(
1116 run_of("speccoder converge --feature {feature}").0,
1117 Runnable::Converge
1118 );
1119 assert_eq!(
1120 run_of("speccoder task skip {task} --reason \"…\""),
1121 (
1122 Runnable::Skip { task: t() },
1123 Some("Why it is skipped".to_owned()),
1124 None
1125 )
1126 );
1127 assert_eq!(
1128 run_of("speccoder task hint {task} \"…\"").1.as_deref(),
1129 Some("The hint")
1130 );
1131 assert_eq!(
1132 run_of("speccoder task done {task}").0,
1133 Runnable::Done { task: t() }
1134 );
1135 assert_eq!(
1136 run_of("speccoder task rule {task} --tests-right --reason \"…\"").0,
1137 Runnable::Rule { task: t() }
1138 );
1139 assert_eq!(
1140 run_of("speccoder bug keep {feature}/{task} --reason \"kept as a regression test\""),
1141 (
1142 Runnable::BugKeep {
1143 task: "001-x/T001".to_owned()
1144 },
1145 None,
1146 Some("kept as a regression test".to_owned())
1147 )
1148 );
1149 assert_eq!(
1150 run_of("speccoder spec tasks"),
1151 (
1152 Runnable::Skill {
1153 name: "tasks".to_owned(),
1154 arguments: String::new()
1155 },
1156 None,
1157 None
1158 )
1159 );
1160 assert_eq!(
1161 run_of("speccoder spec plan --through tasks").0,
1162 Runnable::PlanThrough {
1163 from: PlanFrom::Continue,
1164 through: Through::Tasks
1165 }
1166 );
1167 assert_eq!(
1168 run_of("speccoder review answer Q1 \"…\"").1.as_deref(),
1169 Some("Your answer")
1170 );
1171 assert_eq!(run_of("speccoder fix").0, Runnable::FixSetup);
1172 assert!(matches!(
1173 h("speccoder pr --yes"),
1174 How::Perform { run, confirm: Some(_), .. } if *run == Runnable::PullRequest
1175 ));
1176 assert!(matches!(
1177 h("speccoder task drop {task}"),
1178 How::Perform { run, confirm: Some(_), .. }
1179 if matches!(*run, Runnable::DropSetAside { .. })
1180 ));
1181 assert_eq!(
1182 run_of("speccoder feature <name>"),
1183 (Runnable::Feature, Some("The feature".to_owned()), None)
1184 );
1185 assert!(matches!(
1187 h("speccoder run --feature 002-y"),
1188 How::Perform { feature: Some(f), .. } if f == "002-y"
1189 ));
1190 }
1191
1192 #[test]
1193 fn what_has_no_runnable_runs_as_written_when_speccoder_wrote_it() {
1194 assert_eq!(
1195 argvs_of("speccoder run --ignore-checklists").0,
1196 vec![s(&["speccoder", "run", "--ignore-checklists"])]
1197 );
1198 assert_eq!(
1199 argvs_of("speccoder attest verify --feature {feature} --json").0,
1200 vec![s(&[
1201 "speccoder",
1202 "attest",
1203 "verify",
1204 "--feature",
1205 "001-x",
1206 "--json"
1207 ])]
1208 );
1209 assert_eq!(
1210 argvs_of("speccoder spec archive --yes").0,
1211 vec![s(&["speccoder", "spec", "archive", "--yes"])]
1212 );
1213 }
1214
1215 #[test]
1216 fn the_persons_own_commands_are_exact_argvs() {
1217 assert_eq!(
1219 argvs_of("git add speccoder.toml && git commit -m \"speccoder: config\"").0,
1220 vec![
1221 s(&["git", "add", "speccoder.toml"]),
1222 s(&["git", "commit", "-m", "speccoder: config"])
1223 ]
1224 );
1225 assert_eq!(argvs_of("git stash -u").0, vec![s(&["git", "stash", "-u"])]);
1226 let built = person(vec![s(&["git", "add", "--", "my file.toml", "it's"])]);
1228 assert_eq!(built.command, "git add -- 'my file.toml' 'it'\\''s'");
1229 assert!(matches!(&built.how, How::Command { argvs, .. } if argvs[0][3] == "my file.toml"));
1230 let typed = vec![vec![s(&["npm", "install"])]];
1231 let steps = steps_with(
1232 &["npm install".to_owned(), "npm ci".to_owned()],
1233 &typed,
1234 Context::default(),
1235 );
1236 assert!(matches!(&steps[0].how, How::Command { argvs, .. } if argvs == &typed[0]));
1237 assert!(steps[1].how.copy_only(), "{:?}", steps[1]);
1239 }
1240
1241 #[test]
1242 fn spliced_values_never_become_a_command() {
1243 for line in [
1245 "git add .x&&make && git commit -m \"Track project config\"",
1246 "git add .x && make && git commit -m init",
1247 "git switch B && git merge b # resolve the conflicts",
1248 "git worktree remove /tmp/wt --force",
1249 "npm install",
1250 "speccoder run --feature 001-a --yes",
1251 "speccoder attest verify --feature 001-a --yes",
1252 ] {
1253 let how = h(line);
1254 assert!(!matches!(&how, How::Command { .. }), "{line}: {how:?}");
1255 }
1256 assert_eq!(argvs("a .x&&make"), None);
1258 assert_eq!(
1259 argvs("git merge b # resolve"),
1260 Some(vec![s(&["git", "merge", "b"])])
1261 );
1262 assert!(matches!(
1264 h("speccoder run --task T004 --fresh-acceptance"),
1265 How::Command { .. }
1266 ));
1267 assert!(!matches!(
1268 h("speccoder run --task --yes --fresh-acceptance"),
1269 How::Command { .. }
1270 ));
1271 assert!(matches!(
1272 h("speccoder run --ignore-checklists --feature 001-x"),
1273 How::Command { .. }
1274 ));
1275 }
1276
1277 #[test]
1278 fn free_slots_and_lines_about_other_commands_are_never_signed() {
1279 for line in [
1281 "speccoder run --help",
1282 "speccoder run --json",
1283 "speccoder doctor --events",
1284 ] {
1285 assert!(!matches!(h(line), How::Command { .. }), "{line}");
1286 }
1287 assert_eq!(
1289 view_of("speccoder config get model.region").0,
1290 View::Settings
1291 );
1292 assert_eq!(
1293 view_of("speccoder config unset model.base_url").0,
1294 View::Settings
1295 );
1296 }
1297
1298 #[test]
1299 fn what_writes_outside_the_project_says_so() {
1300 let global = person(vec![s(&["git", "config", "--global", "gpg.format", "ssh"])]);
1301 assert!(
1302 matches!(global.how, How::Command { outside: Some(o), .. } if o == "changes your global git config")
1303 );
1304 let local = person(vec![s(&["npm", "install"])]);
1305 assert!(matches!(local.how, How::Command { outside: None, .. }));
1306 }
1307
1308 #[test]
1309 fn what_reads_the_keyboard_is_known() {
1310 for argv in [
1311 &["copilot", "login"][..],
1312 &["opencode", "auth", "login"],
1313 &["gh", "auth", "login"],
1314 &["aws", "sso", "login", "--profile", "dev"],
1315 &["ssh-keygen", "-t", "ed25519"],
1316 &["git", "commit"],
1317 &["git", "merge", "b"],
1318 &["specify", "init", "--here"],
1319 &["speccoder", "spec", "archive"],
1320 ] {
1321 assert!(interactive(&s(argv)), "{argv:?}");
1322 }
1323 for argv in [
1324 &["brew", "install", "gh"][..],
1325 &["git", "commit", "-m", "x"],
1326 &["ssh-keygen", "-t", "ed25519", "-N", "", "-f", "k"],
1327 &["speccoder", "spec", "archive", "--yes"],
1328 ] {
1329 assert!(!interactive(&s(argv)), "{argv:?}");
1330 }
1331 }
1332
1333 #[test]
1334 fn only_a_signer_on_this_thread_signs_and_only_what_is_built() {
1335 let unsigned = person(vec![s(&["npm", "install"])]);
1336 assert!(matches!(unsigned.how, How::Command { token: None, .. }));
1337 let signed = signing(
1338 std::rc::Rc::new(|a: &[Vec<String>], i: bool| format!("{}:{i}", a.len())),
1339 || person(vec![s(&["npm", "install"])]),
1340 );
1341 assert!(matches!(signed.how, How::Command { token: Some(t), .. } if t == "1:false"));
1342 assert!(matches!(
1344 person(vec![s(&["x"])]).how,
1345 How::Command { token: None, .. }
1346 ));
1347 }
1348
1349 #[test]
1350 fn what_cannot_run_here_says_why() {
1351 assert!(matches!(
1352 h("export ANTHROPIC_API_KEY=…"),
1353 How::Shell { setting: Some(s), .. } if s == "model"
1354 ));
1355 assert!(matches!(
1356 h("export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""),
1357 How::Shell { setting: None, .. }
1358 ));
1359 for line in [
1360 "→ re-run with -v and report the output",
1361 "set [sandbox] mode = \"off\"",
1362 "curl https://example.com | sh",
1363 "sudo apt-get install bubblewrap",
1364 "git remote add origin <url>",
1365 "git diff -- <unit>/review.json",
1366 "echo $HOME",
1367 "speccoder eval --keep <dir>",
1368 "speccoder pr --remote <name>",
1369 ] {
1370 assert!(h(line).copy_only(), "{line}: {:?}", h(line));
1371 }
1372 }
1373}