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