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