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