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