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