Skip to main content

speccoder_core/
step.rs

1//! How a fix line or a next step is taken from an interface: speccoder
2//! performs it, opens a view, runs the person's own command, or it belongs
3//! in the person's shell. One parser ([`step_of`]) for every line speccoder
4//! prints, so no interface copies a command speccoder can take itself.
5
6use serde::{Deserialize, Serialize};
7
8use crate::actions::{PlanFrom, Runnable, Through};
9
10/// A read-only place an interface opens instead of running a command.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
12#[serde(rename_all = "snake_case")]
13pub enum View {
14    /// `doctor`: every check of the setup.
15    Doctor,
16    /// `doctor --bug-report`: the setup as a bug report, to copy.
17    BugReport,
18    /// `report`, `status`: every task and its evidence.
19    Report,
20    /// `report --live`, `tui`: the run as it goes.
21    Live,
22    /// `report --approvals`, `task approve|reject`: the calls waiting,
23    /// shown in full before a decision.
24    Approvals,
25    /// `explain CODE`: what a code means and how to resolve it.
26    Explain,
27    /// `review`: the spec's review.
28    Review,
29    /// `config`: the settings.
30    Settings,
31    /// `init`: the setup assistant.
32    Setup,
33    /// `bug`: the bug sheet.
34    Bug,
35    /// `do`: the quick-change sheet.
36    QuickChange,
37    /// `feature similar`: the new-feature sheet.
38    NewFeature,
39    /// `new`: the new-project sheet.
40    NewProject,
41    /// `auth`: the GitHub sign-in.
42    #[serde(rename = "github")]
43    GitHub,
44    /// A task's drawer (`subject`: its id).
45    Task,
46    /// `ask`: the Ask panel.
47    Ask,
48    /// `spec explore`: the Explore sheet (`subject`: a session, if one).
49    Explore,
50}
51
52/// How an interface takes a line.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
54#[serde(tag = "how", rename_all = "snake_case")]
55pub enum How {
56    /// speccoder performs it (`actions::perform`).
57    Perform {
58        /// What runs.
59        run: Box<Runnable>,
60        /// What to ask for first (`Why it is skipped`); `None`: nothing.
61        input: Option<String>,
62        /// The words the line gives already (a `--reason "…"` filled in).
63        text: Option<String>,
64        /// What to say in a confirmation first (it leaves the machine or
65        /// cannot be undone).
66        confirm: Option<String>,
67        /// The feature the line names (`--feature F`); `None`: the current.
68        feature: Option<String>,
69    },
70    /// A view to open.
71    View {
72        /// Which.
73        view: View,
74        /// What it is about: a code, a task.
75        subject: Option<String>,
76    },
77    /// A command speccoder printed as a fix and can run for the person, in
78    /// the project folder and the OS sandbox (see `outside`) — never one
79    /// composed elsewhere. Several argvs run in order, each only after the last
80    /// one succeeded (`a && b`).
81    Command {
82        /// The argvs, exactly.
83        argvs: Vec<Vec<String>>,
84        /// It reads the keyboard (a login): a terminal of the person's
85        /// own, not a panel.
86        interactive: bool,
87        /// What it changes outside the project, for a command that runs
88        /// outside the sandbox by its nature (`changes your global git
89        /// config`); `None`: it runs in the sandbox, writes confined to
90        /// the project and the package caches.
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        outside: Option<String>,
93        /// The host's proof that it issued exactly these argvs; only a
94        /// host that runs commands sets it.
95        #[serde(default, skip_serializing_if = "Option::is_none")]
96        token: Option<String>,
97    },
98    /// It must run in the shell speccoder starts from (`export NAME=…`):
99    /// nothing run elsewhere reaches that shell.
100    Shell {
101        /// Why it cannot run here.
102        why: String,
103        /// The setting that stands for it (`model`), if there is one.
104        setting: Option<String>,
105    },
106    /// Words for a person (`→ …`), or a command only they can complete.
107    Words {
108        /// Why it is not taken here; `None` for words.
109        why: Option<String>,
110    },
111}
112
113/// A line with how it is taken.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
115pub struct Step {
116    /// The line, as printed.
117    pub command: String,
118    /// How it is taken.
119    #[serde(flatten)]
120    pub how: How,
121}
122
123/// What a line is about when it does not say: the result's feature and task.
124#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
125pub struct Context<'a> {
126    /// The feature.
127    pub feature: Option<&'a str>,
128    /// The task.
129    pub task: Option<&'a str>,
130}
131
132/// The person's own programs a fix may name: anything else is words to
133/// copy (a line of prose is never run by accident).
134const 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
179/// The commands of speccoder no interface takes: developer tools.
180const DEVELOPER: &[&str] = &["eval", "replay", "bench", "completion", "acp", "workflow"];
181
182/// speccoder's subcommands: a line that starts `speccoder` and goes on
183/// with anything else is words (`speccoder did not create …`).
184pub 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    "extensions",
201    "feature",
202    "fix",
203    "help",
204    "hooks",
205    "inbox",
206    "init",
207    "inspect",
208    "issue",
209    "new",
210    "pr",
211    "preset",
212    "propose",
213    "replay",
214    "report",
215    "review",
216    "run",
217    "sample",
218    "schema",
219    "spec",
220    "status",
221    "story",
222    "suggest",
223    "task",
224    "tui",
225    "ui",
226    "verify",
227    "workflow",
228    "worktree",
229];
230
231/// The marker of text a person writes.
232const BLANK: &str = "…";
233
234/// Argvs run one after the other (`a && b`).
235pub type Argvs = Vec<Vec<String>>;
236
237/// How `line` is taken, with `ctx` filling what it leaves open.
238///
239/// A line is text, and text can carry values spliced into it (a file's
240/// name, a feature's): so a line becomes the person's own command
241/// ([`How::Command`]) only when it is one of speccoder's constant lines —
242/// a catalog fix or a builder's line, its `{task}`/`{feature}` slots
243/// filled with plain identifiers. A source that wants a command with
244/// other values gives its argvs ([`person`], [`steps_with`]). A speccoder
245/// command that speccoder performs or opens is read from any line: what it
246/// becomes is a typed runnable, never an argv.
247#[must_use]
248pub fn step_of(line: &str, ctx: Context<'_>) -> Step {
249    let filled = fill(line, ctx);
250    Step {
251        command: line.to_owned(),
252        how: how(&filled),
253    }
254}
255
256/// The person's own command a source built as argvs: shown as the line a
257/// shell would read ([`render`]), run exactly as given.
258#[must_use]
259pub fn person(argvs: Argvs) -> Step {
260    Step {
261        command: render(&argvs),
262        how: command(argvs),
263    }
264}
265
266/// Every line of `fix`, as steps; a line a source gave as argvs
267/// (`typed`, rendered to that line) is those argvs.
268#[must_use]
269pub fn steps_with(fix: &[String], typed: &[Argvs], ctx: Context<'_>) -> Vec<Step> {
270    fix.iter()
271        .map(|f| match typed.iter().find(|t| render(t) == *f) {
272            Some(t) => person(t.clone()),
273            None => step_of(f, ctx),
274        })
275        .collect()
276}
277
278/// Argvs as a shell reads them: a word with anything but plain characters
279/// single-quoted; `&&` between them.
280#[must_use]
281pub fn render(argvs: &[Vec<String>]) -> String {
282    let word = |w: &String| {
283        let plain = !w.is_empty()
284            && w.chars().all(|c| {
285                c.is_ascii_alphanumeric()
286                    || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | ',' | '@' | '+')
287            });
288        if plain {
289            w.clone()
290        } else {
291            format!("'{}'", w.replace('\'', "'\\''"))
292        }
293    };
294    argvs
295        .iter()
296        .map(|a| a.iter().map(word).collect::<Vec<_>>().join(" "))
297        .collect::<Vec<_>>()
298        .join(" && ")
299}
300
301fn fill(line: &str, ctx: Context<'_>) -> String {
302    let mut s = line.to_owned();
303    if let Some(t) = ctx.task {
304        s = s.replace("{task}", t).replace("<task>", t);
305    }
306    if let Some(f) = ctx.feature {
307        s = s.replace("{feature}", f).replace("<feature>", f);
308    }
309    s
310}
311
312fn words(why: Option<&str>) -> How {
313    How::Words {
314        why: why.map(str::to_owned),
315    }
316}
317
318/// Why a line with values of the project's is not run from here.
319const NAMES_VALUES: &str = "it names files or values of this project: run it in your shell";
320
321/// A trailing "(why)" is said, not run.
322fn said(line: &str) -> &str {
323    let line = line.trim();
324    match line.rfind(" (") {
325        Some(at) if line.ends_with(')') => line[..at].trim_end(),
326        _ => line,
327    }
328}
329
330fn how(line: &str) -> How {
331    let line = said(line);
332    if line.starts_with('→') || line.is_empty() {
333        return words(None);
334    }
335    if let Some(rest) = line.strip_prefix("export ") {
336        let name = rest.split('=').next().unwrap_or_default().trim();
337        let model = name.ends_with("_API_KEY") || name.starts_with("AWS_");
338        return How::Shell {
339            why: format!(
340                "`{name}` must be set where speccoder starts: a command run from here ends with its own shell, and the variable with it"
341            ),
342            setting: model.then(|| "model".to_owned()),
343        };
344    }
345    let Some(chain) = argvs(line) else {
346        return words(Some("a shell line: run it in your shell"));
347    };
348    let Some(first) = chain.first() else {
349        return words(None);
350    };
351    let constant = constant(&chain);
352    if first.first().map(String::as_str) == Some("speccoder") {
353        let known = first
354            .get(1)
355            .is_some_and(|w| SUBCOMMANDS.contains(&w.as_str()));
356        return if known && chain.len() == 1 {
357            speccoder(first, constant)
358        } else {
359            words(None)
360        };
361    }
362    if chain
363        .iter()
364        .any(|a| a.first().map(String::as_str) == Some("sudo"))
365    {
366        return words(Some("it asks for your password: run it in your shell"));
367    }
368    if chain.iter().any(|a| {
369        a.first()
370            .is_none_or(|p| p == "speccoder" || !PROGRAMS.contains(&p.as_str()))
371    }) {
372        return words(None);
373    }
374    if chain.iter().flatten().any(|w| has_blank(w)) {
375        return words(Some("it has a part only you can fill in"));
376    }
377    if constant {
378        command(chain)
379    } else {
380        words(Some(NAMES_VALUES))
381    }
382}
383
384/// Lines builders print that are no catalog fix: constant, or with
385/// `{task}`/`{feature}` slots.
386const BUILT: &[&str] = &[
387    "speccoder run --in-place",
388    "speccoder run --in-place --return",
389    "speccoder run --ignore-checklists",
390    "speccoder run --allow-failing-baseline",
391    "speccoder run --parallel 2",
392    "speccoder run --task {task} --fresh-acceptance",
393    "speccoder attest verify",
394    "speccoder attest verify --feature {feature} --json",
395    "speccoder feature --clear",
396    "speccoder explain --list",
397    "speccoder fix --dry-run",
398    "speccoder inspect",
399    "speccoder worktree",
400    "speccoder verify",
401    "speccoder schema",
402    "speccoder sample",
403    "speccoder spec archive",
404    "speccoder spec archive --yes",
405    "git stash -u",
406    "git status",
407    "speccoder spec archive {change} --yes",
408    "speccoder config commit",
409    "speccoder help",
410    "speccoder help advanced",
411    "speccoder sample --fresh --yes",
412    "speccoder spec",
413    "ssh-keygen -t ed25519",
414    "git config --global gpg.format ssh",
415];
416
417/// The constant lines, parsed: the catalog's fixes and [`BUILT`].
418fn templates() -> &'static [Argvs] {
419    static PARSED: std::sync::OnceLock<Vec<Argvs>> = std::sync::OnceLock::new();
420    PARSED.get_or_init(|| {
421        crate::codes::CATALOG
422            .iter()
423            .flat_map(|e| e.fix.iter())
424            // A line about another command (`speccoder {command} --help`)
425            // is said, never signed: its slot names any command.
426            .filter(|l| !l.contains("{command}"))
427            .chain(BUILT.iter())
428            .filter_map(|l| argvs(said(l)))
429            .collect()
430    })
431}
432
433/// A value a slot may take: a plain identifier (`001-x`, `T004`), never a
434/// flag, never more than one word.
435fn plain(v: &str) -> bool {
436    v.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
437        && v.chars()
438            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
439}
440
441/// `w` is the template word `t` with its slots filled by plain values.
442fn fills(t: &str, w: &str) -> bool {
443    let Some(open) = t.find('{') else {
444        return t == w;
445    };
446    let Some(close) = t[open..].find('}').map(|c| open + c) else {
447        return t == w;
448    };
449    let (head, rest) = (&t[..open], &t[close + 1..]);
450    let Some(w) = w.strip_prefix(head) else {
451        return false;
452    };
453    // The slot takes the shortest plain run after which the rest fits.
454    (1..=w.len())
455        .filter(|n| w.is_char_boundary(*n))
456        .any(|n| plain(&w[..n]) && fills(rest, &w[n..]))
457}
458
459/// `chain` is one of speccoder's constant lines (with `--feature F`
460/// appended, as next steps are).
461fn constant(chain: &[Vec<String>]) -> bool {
462    let matches = |t: &[String], a: &[String]| {
463        let a = match a {
464            [rest @ .., f, v]
465                if f == "--feature" && plain(v) && !t.iter().any(|w| w == "--feature") =>
466            {
467                rest
468            }
469            a => a,
470        };
471        t.len() == a.len() && t.iter().zip(a).all(|(t, w)| fills(t, w))
472    };
473    templates()
474        .iter()
475        .any(|t| t.len() == chain.len() && t.iter().zip(chain).all(|(t, a)| matches(t, a)))
476}
477
478fn has_blank(w: &str) -> bool {
479    w.contains(BLANK) || (w.contains('<') && w.contains('>')) || w.contains('{')
480}
481
482type Signer = std::rc::Rc<dyn Fn(&[Vec<String>], bool) -> String>;
483
484thread_local! {
485    static SIGNER: std::cell::RefCell<Option<Signer>> = const { std::cell::RefCell::new(None) };
486}
487
488/// Runs `f` with `signer` signing every command step built meanwhile on
489/// this thread (its `token`): a host that runs commands signs only the
490/// steps speccoder itself builds, never JSON from elsewhere.
491pub fn signing<T>(signer: Signer, f: impl FnOnce() -> T) -> T {
492    struct Reset(Option<Signer>);
493    impl Drop for Reset {
494        fn drop(&mut self) {
495            let before = self.0.take();
496            SIGNER.with(|s| *s.borrow_mut() = before);
497        }
498    }
499    let before = SIGNER.with(|s| s.borrow_mut().replace(signer));
500    let _reset = Reset(before);
501    f()
502}
503
504fn command(argvs: Argvs) -> How {
505    let interactive = argvs.iter().any(|a| interactive(a));
506    let token = SIGNER.with(|s| s.borrow().as_ref().map(|f| f(&argvs, interactive)));
507    How::Command {
508        interactive,
509        outside: argvs.iter().find_map(|a| outside(a)),
510        argvs,
511        token,
512    }
513}
514
515/// What `argv` changes outside the project when it is one of the few
516/// fixes that, by their nature, cannot run confined to it: those run
517/// outside the sandbox, and say so. Every other runs in it.
518#[must_use]
519pub fn outside(argv: &[String]) -> Option<String> {
520    match argv
521        .iter()
522        .map(String::as_str)
523        .collect::<Vec<_>>()
524        .as_slice()
525    {
526        ["git", "config", "--global", ..] => Some("changes your global git config".to_owned()),
527        ["git", "worktree", "remove", path] => Some(format!(
528            "removes the worktree at {path}, outside the project"
529        )),
530        ["brew", "install", .., package] => {
531            Some(format!("installs {package} on this machine with Homebrew"))
532        }
533        ["npm", "install", "-g" | "--global", package] => {
534            Some(format!("installs {package} on this machine with npm"))
535        }
536        _ => None,
537    }
538}
539
540/// `argv` reads the keyboard — a sign-in's prompt or device flow, an
541/// editor, a question — so it needs a terminal of the person's own.
542#[must_use]
543pub fn interactive(argv: &[String]) -> bool {
544    let w: Vec<&str> = argv.iter().map(String::as_str).collect();
545    let has = |f: &[&str]| {
546        w.iter()
547            .any(|x| f.contains(x) || f.iter().any(|p| p.ends_with('=') && x.starts_with(p)))
548    };
549    match w.as_slice() {
550        ["copilot", "login", ..]
551        | ["opencode", "auth", "login", ..]
552        | ["gh", "auth", "login" | "refresh", ..]
553        | ["aws", "login" | "configure", ..]
554        | ["aws", "sso", "login", ..]
555        | ["claude", ..]
556        | ["codex", "login", ..]
557        | ["codex"]
558        | ["gemini", ..]
559        | ["npm", "login" | "adduser", ..]
560        | ["specify", "init", ..]
561        | ["openspec", "init", ..]
562        | [
563            "gpg",
564            "--full-generate-key" | "--gen-key" | "--edit-key",
565            ..,
566        ]
567        | ["speccoder", "tui" | "ui", ..]
568        | ["speccoder", "auth", "login", ..] => true,
569        ["speccoder", "spec", "archive", ..] => !has(&["--yes"]),
570        ["ssh-keygen", ..] => !(has(&["-N"]) && has(&["-f"])),
571        ["git", "commit", ..] => !has(&["-m", "-F", "--file", "--no-edit", "-C", "--message="]),
572        ["git", "merge", ..] => !has(&["--no-edit", "--ff-only", "--abort"]),
573        ["git", "rebase", ..] => has(&["-i", "--interactive"]),
574        ["git", "add", ..] => has(&["-p", "-i", "--patch", "--interactive"]),
575        _ => false,
576    }
577}
578
579/// The words of a line, split as a shell would for the plain cases;
580/// `None` for anything a shell would expand or redirect. `a && b` is two.
581fn argvs(line: &str) -> Option<Vec<Vec<String>>> {
582    let mut out = vec![Vec::new()];
583    let mut word = String::new();
584    let mut in_word = false;
585    let mut quote: Option<char> = None;
586    let mut chars = line.chars().peekable();
587    while let Some(c) = chars.next() {
588        match (quote, c) {
589            (Some(q), c) if c == q => quote = None,
590            (Some('"'), '\\') => word.push(chars.next()?),
591            (Some('"'), '$' | '`') => return None,
592            (Some(_), c) => word.push(c),
593            (None, '"' | '\'') => {
594                quote = Some(c);
595                in_word = true;
596            }
597            // `&&` separates only as a word of its own (`a && b`).
598            (None, '&') if !in_word && chars.peek() == Some(&'&') => {
599                chars.next();
600                if !chars.peek().is_none_or(|c| c.is_whitespace()) {
601                    return None;
602                }
603                out.push(Vec::new());
604            }
605            // A comment ends the line.
606            (None, '#') if !in_word => break,
607            // `<name>` is a blank to fill in, never a redirection.
608            (None, '<') => {
609                word.push('<');
610                loop {
611                    let c = chars.next()?;
612                    word.push(c);
613                    if c == '>' {
614                        break;
615                    }
616                }
617                in_word = true;
618            }
619            (None, '|' | ';' | '>' | '$' | '`' | '&' | '(' | ')' | '*' | '~') => return None,
620            (None, c) if c.is_whitespace() => {
621                if in_word {
622                    out.last_mut()?.push(std::mem::take(&mut word));
623                    in_word = false;
624                }
625            }
626            (None, '\\') => return None,
627            (None, c) => {
628                word.push(c);
629                in_word = true;
630            }
631        }
632    }
633    if quote.is_some() {
634        return None;
635    }
636    if in_word {
637        out.last_mut()?.push(word);
638    }
639    (!out.iter().any(Vec::is_empty)).then_some(out)
640}
641
642/// The value after `flag` (`--feature F` → `F`).
643fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
644    args.iter()
645        .position(|a| a == name)
646        .and_then(|i| args.get(i + 1))
647        .map(String::as_str)
648}
649
650fn has(args: &[String], name: &str) -> bool {
651    args.iter().any(|a| a == name)
652}
653
654/// The first word after the subcommand's words that is no flag.
655fn positional(args: &[String], skip: usize) -> Option<&str> {
656    let mut i = skip;
657    while let Some(a) = args.get(i) {
658        if a.starts_with('-') {
659            // A flag with a value: the flags speccoder's fixes use.
660            let valued = matches!(
661                a.as_str(),
662                "--feature"
663                    | "--reason"
664                    | "--note"
665                    | "--story"
666                    | "--task"
667                    | "--remote"
668                    | "--through"
669            );
670            i += if valued { 2 } else { 1 };
671            continue;
672        }
673        return Some(a);
674    }
675    None
676}
677
678/// The words given for an input, or the prompt to ask with when the line
679/// leaves them blank (`"…"`).
680fn input(given: Option<&str>, prompt: &str) -> (Option<String>, Option<String>) {
681    match given {
682        Some(t) if !t.contains(BLANK) && !t.trim().is_empty() => (None, Some(t.to_owned())),
683        _ => (Some(prompt.to_owned()), None),
684    }
685}
686
687fn view(view: View, subject: Option<&str>) -> How {
688    How::View {
689        view,
690        subject: subject.map(str::to_owned),
691    }
692}
693
694/// What a line about a feature names, when it names one fully.
695fn named_feature(args: &[String]) -> Option<String> {
696    flag(args, "--feature")
697        .filter(|f| !has_blank(f))
698        .map(str::to_owned)
699}
700
701fn perform(run: Runnable, args: &[String]) -> How {
702    How::Perform {
703        run: Box::new(run),
704        input: None,
705        text: None,
706        confirm: None,
707        feature: named_feature(args),
708    }
709}
710
711/// `speccoder story accept US2-2 --reason R`: the reason is asked for.
712fn accept_untestable(scenario: String, args: &[String]) -> How {
713    asking(
714        Runnable::AcceptUntestable { scenario },
715        args,
716        flag(args, "--reason"),
717        "Why it cannot be tested",
718    )
719}
720
721fn asking(run: Runnable, args: &[String], given: Option<&str>, prompt: &str) -> How {
722    let (input, text) = input(given, prompt);
723    How::Perform {
724        run: Box::new(run),
725        input,
726        text,
727        confirm: None,
728        feature: named_feature(args),
729    }
730}
731
732fn confirmed(run: Runnable, args: &[String], confirm: &str) -> How {
733    How::Perform {
734        run: Box::new(run),
735        input: None,
736        text: None,
737        confirm: Some(confirm.to_owned()),
738        feature: named_feature(args),
739    }
740}
741
742/// The confirmation a pull request asks for (it leaves the machine).
743pub 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).";
744
745/// A speccoder command line, as an interface takes it.
746#[allow(clippy::too_many_lines)] // one table, read top to bottom
747fn speccoder(argv: &[String], constant: bool) -> How {
748    // A feature not named yet is the one the interface has selected.
749    let mut args: Vec<String> = argv.get(1..).unwrap_or_default().to_vec();
750    if let Some(i) = args.iter().position(|a| a == "--feature")
751        && args.get(i + 1).is_some_and(|f| has_blank(f))
752    {
753        args.drain(i..=i + 1);
754    }
755    let a = args.as_slice();
756    let word = |i: usize| a.get(i).map(String::as_str);
757    let task = |i: usize| {
758        positional(a, i)
759            .filter(|t| !has_blank(t))
760            .map(str::to_owned)
761    };
762    // As written: only one of speccoder's constant lines.
763    let run_cmd = || {
764        if a.iter().any(|w| has_blank(w)) {
765            words(Some("it has a part only you can fill in"))
766        } else if constant {
767            command(vec![argv.to_vec()])
768        } else {
769            words(Some(NAMES_VALUES))
770        }
771    };
772    match (word(0), word(1)) {
773        (Some("doctor"), _) if has(a, "--bug-report") => view(View::BugReport, None),
774        (Some("doctor"), _) => view(View::Doctor, None),
775        (Some("status"), _) => view(View::Report, None),
776        (Some("report"), _) if has(a, "--approvals") => view(View::Approvals, None),
777        (Some("report"), _) if has(a, "--live") => view(View::Live, None),
778        (Some("report"), _) => view(View::Report, None),
779        (Some("tui"), _) => view(View::Live, None),
780        (Some("ui" | "inbox" | "approvals"), _) if !has(a, "-C") => view(View::Approvals, None),
781        (Some("explain"), Some(code)) if !code.starts_with('-') => view(View::Explain, Some(code)),
782        (Some("run"), _) if has(a, "--converge") && !a.iter().any(|w| w == "--task") => {
783            perform(Runnable::Converge, a)
784        }
785        (Some("converge"), _) => perform(Runnable::Converge, a),
786        (Some("story"), Some("check")) => match task(2) {
787            Some(story) => perform(Runnable::CheckStory { story }, a),
788            None => run_cmd(),
789        },
790        (Some("story"), Some("accept")) => match task(2) {
791            Some(scenario) => accept_untestable(scenario, a),
792            None => run_cmd(),
793        },
794        (Some("run"), _) => {
795            // Only what a run from an interface does: one task or the
796            // feature, nothing else changed. Other flags run as written.
797            let mut rest = a.iter().skip(1);
798            let mut plain = true;
799            while let Some(w) = rest.next() {
800                match w.as_str() {
801                    "--task" | "--feature" | "--agent" | "--cap" => plain &= rest.next().is_some(),
802                    "-v" => {}
803                    _ => plain = false,
804                }
805            }
806            match flag(a, "--task") {
807                _ if !plain => run_cmd(),
808                Some(t) if has_blank(t) => view(View::Report, None),
809                Some(t) => perform(
810                    Runnable::Run {
811                        task: Some(t.to_owned()),
812                        agent: flag(a, "--agent").map(str::to_owned),
813                        cap: flag(a, "--cap").map(str::to_owned),
814                    },
815                    a,
816                ),
817                None => perform(
818                    Runnable::Run {
819                        task: None,
820                        agent: flag(a, "--agent").map(str::to_owned),
821                        cap: flag(a, "--cap").map(str::to_owned),
822                    },
823                    a,
824                ),
825            }
826        }
827        (Some("task"), Some("approve" | "reject")) => view(View::Approvals, None),
828        (Some("task"), Some(verb)) => {
829            let Some(t) = task(2) else {
830                return view(View::Report, None);
831            };
832            match verb {
833                "accept" => asking(
834                    Runnable::Accept { task: t },
835                    a,
836                    flag(a, "--reason"),
837                    "Why it is done",
838                ),
839                "skip" => asking(
840                    Runnable::Skip { task: t },
841                    a,
842                    flag(a, "--reason"),
843                    "Why it is skipped",
844                ),
845                "nudge" => asking(
846                    Runnable::Nudge { task: t },
847                    a,
848                    flag(a, "--note"),
849                    "The note",
850                ),
851                "hint" => asking(Runnable::Hint { task: t }, a, positional(a, 3), "The hint"),
852                "rule" if a.iter().any(|w| w == "--tests-wrong") => asking(
853                    Runnable::RuleScenario {
854                        task: t,
855                        scenario: flag(a, "--scenario").map(str::to_owned),
856                    },
857                    a,
858                    flag(a, "--reason"),
859                    "Why the tests are wrong",
860                ),
861                "rule" => asking(
862                    Runnable::Rule { task: t },
863                    a,
864                    flag(a, "--reason"),
865                    "Why the tests are right",
866                ),
867                "done" => perform(Runnable::Done { task: t }, a),
868                "restore" => perform(Runnable::KeepSetAside { task: t }, a),
869                "drop" => confirmed(
870                    Runnable::DropSetAside { task: t.clone() },
871                    a,
872                    &format!(
873                        "Discard the changes set aside before {t}? They are deleted, not kept anywhere."
874                    ),
875                ),
876                _ => view(View::Task, Some(&t)),
877            }
878        }
879        (Some("review"), Some("approve")) if a.len() == 2 => perform(
880            Runnable::Review {
881                ids: Vec::new(),
882                verdict: "approve".to_owned(),
883            },
884            a,
885        ),
886        (Some("review"), Some("answer")) => match task(2) {
887            Some(q) => asking(
888                Runnable::Review {
889                    ids: vec![q],
890                    verdict: "answer".to_owned(),
891                },
892                a,
893                positional(a, 3),
894                "Your answer",
895            ),
896            None => view(View::Review, None),
897        },
898        (Some("review"), _) => view(View::Review, None),
899        // Another project's: run there.
900        (Some(_), _) if has(a, "-C") => words(Some("it is another project's: run it there")),
901        (Some("spec"), Some("archive")) => run_cmd(),
902        // The offer lives in Needs You; declining it is one action.
903        // A session is chosen from, resumed and started in the sheet.
904        (Some("spec"), Some("explore")) => view(
905            View::Explore,
906            match positional(a, 2) {
907                Some("choose") => positional(a, 4),
908                Some("resume" | "start") => positional(a, 3),
909                _ => None,
910            }
911            .filter(|s| s.starts_with("ex-")),
912        ),
913        (Some("spec"), Some("promote")) => match positional(a, 2) {
914            Some(task) if has(a, "--decline") => perform(
915                Runnable::DeclinePromotion {
916                    task: task.to_owned(),
917                },
918                a,
919            ),
920            _ => view(View::Approvals, None),
921        },
922        (Some("spec"), Some(name)) if !has_blank(name) && !name.starts_with('-') => {
923            let given = positional(a, 2);
924            if let Some(through) = flag(a, "--through").and_then(Through::parse) {
925                // Words to plan from: the new-feature sheet asks for them.
926                return match given {
927                    Some(_) => view(View::NewFeature, None),
928                    None => perform(
929                        Runnable::PlanThrough {
930                            from: PlanFrom::Continue,
931                            through,
932                        },
933                        a,
934                    ),
935                };
936            }
937            if a.iter()
938                .skip(2)
939                .any(|w| w.starts_with('-') && w != "--feature")
940            {
941                // A flag only the command takes.
942                return run_cmd();
943            }
944            let skill = |arguments: &str| Runnable::Skill {
945                name: name.to_owned(),
946                arguments: arguments.to_owned(),
947            };
948            match given {
949                None => perform(skill(""), a),
950                Some(w) if has_blank(w) => asking(skill(""), a, None, "Describe it"),
951                Some(w) => perform(skill(w), a),
952            }
953        }
954        (Some("bug"), Some("keep")) => match task(2) {
955            Some(t) => asking(
956                Runnable::BugKeep { task: t },
957                a,
958                flag(a, "--reason"),
959                "Why it is kept",
960            ),
961            None => view(View::Bug, None),
962        },
963        (Some("bug"), Some("run")) => match flag(a, "--task").filter(|t| !has_blank(t)) {
964            Some(t) => perform(
965                Runnable::BugRun {
966                    slug: None,
967                    task: Some(t.to_owned()),
968                },
969                a,
970            ),
971            None => view(View::Bug, None),
972        },
973        (Some("bug"), _) => view(View::Bug, None),
974        (Some("do"), _) => view(View::QuickChange, None),
975        (Some("ask"), _) => view(View::Ask, None),
976        (Some("pr"), _) if flag(a, "--remote").is_some() => {
977            words(Some("name the remote it pushes to"))
978        }
979        (Some("pr"), _) => confirmed(Runnable::PullRequest, a, PR_CONFIRM),
980        (Some("fix"), _) if a.len() == 1 => perform(Runnable::FixSetup, a),
981        (Some("preset"), w) if w != Some("add") => words(None),
982        (Some("preset"), _) => confirmed(
983            Runnable::PresetAdd { update: false },
984            a,
985            "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.",
986        ),
987        (Some("feature"), Some("similar")) => view(View::NewFeature, None),
988        (Some("feature"), None) => asking(Runnable::Feature, a, None, "The feature"),
989        (Some("feature"), Some(name)) if !name.starts_with('-') => asking(
990            Runnable::Feature,
991            a,
992            Some(name).filter(|n| !has_blank(n)),
993            "The feature",
994        ),
995        (Some("init"), _) => view(View::Setup, None),
996        // A hook is tested in place; trusted in its sheet, after it is seen.
997        (Some("hooks"), Some("test")) => match a.get(2).filter(|n| !n.starts_with('-')) {
998            Some(name) => perform(Runnable::HookTest { name: name.clone() }, a),
999            None => view(View::Settings, Some("hooks")),
1000        },
1001        (Some("hooks"), _) => view(View::Settings, Some("hooks")),
1002        // An extension is trusted in its sheet, after it is seen.
1003        (Some("extensions"), _) => view(View::Settings, Some("extensions")),
1004        (Some("config"), None | Some("get" | "unset")) => view(View::Settings, None),
1005        (Some("config"), Some("set")) if a.iter().any(|w| has_blank(w)) => {
1006            view(View::Settings, None)
1007        }
1008        (Some("new"), _) => view(View::NewProject, None),
1009        (Some("auth"), _) => view(View::GitHub, None),
1010        (Some(dev), _) if DEVELOPER.contains(&dev) => {
1011            words(Some("a developer tool: run it in your shell"))
1012        }
1013        _ => run_cmd(),
1014    }
1015}
1016
1017impl How {
1018    /// It is a copy and nothing else: words, or a shell line.
1019    #[must_use]
1020    pub const fn copy_only(&self) -> bool {
1021        matches!(self, Self::Words { .. } | Self::Shell { .. })
1022    }
1023}
1024
1025/// Every line of `fix`, as steps.
1026#[must_use]
1027pub fn steps(fix: &[String], ctx: Context<'_>) -> Vec<Step> {
1028    steps_with(fix, &[], ctx)
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    fn h(line: &str) -> How {
1036        step_of(
1037            line,
1038            Context {
1039                feature: Some("001-x"),
1040                task: Some("T001"),
1041            },
1042        )
1043        .how
1044    }
1045
1046    fn run_of(line: &str) -> (Runnable, Option<String>, Option<String>) {
1047        match h(line) {
1048            How::Perform {
1049                run, input, text, ..
1050            } => (*run, input, text),
1051            other => panic!("{line}: {other:?}"),
1052        }
1053    }
1054
1055    fn view_of(line: &str) -> (View, Option<String>) {
1056        match h(line) {
1057            How::View { view, subject } => (view, subject),
1058            other => panic!("{line}: {other:?}"),
1059        }
1060    }
1061
1062    fn argvs_of(line: &str) -> (Vec<Vec<String>>, bool) {
1063        match h(line) {
1064            How::Command {
1065                argvs, interactive, ..
1066            } => (argvs, interactive),
1067            other => panic!("{line}: {other:?}"),
1068        }
1069    }
1070
1071    fn s(v: &[&str]) -> Vec<String> {
1072        v.iter().map(|w| (*w).to_owned()).collect()
1073    }
1074
1075    #[test]
1076    fn read_only_commands_open_their_view() {
1077        assert_eq!(view_of("speccoder doctor").0, View::Doctor);
1078        assert_eq!(
1079            view_of("speccoder doctor --feature {feature}").0,
1080            View::Doctor
1081        );
1082        assert_eq!(view_of("speccoder doctor --bug-report").0, View::BugReport);
1083        assert_eq!(view_of("speccoder report").0, View::Report);
1084        assert_eq!(view_of("speccoder status").0, View::Report);
1085        assert_eq!(view_of("speccoder report --approvals").0, View::Approvals);
1086        assert_eq!(view_of("speccoder report --live").0, View::Live);
1087        assert_eq!(view_of("speccoder report --feature <name>").0, View::Report);
1088        assert_eq!(
1089            view_of("speccoder explain NEEDS-APPROVAL"),
1090            (View::Explain, Some("NEEDS-APPROVAL".to_owned()))
1091        );
1092        assert_eq!(view_of("speccoder task approve {task}").0, View::Approvals);
1093        assert_eq!(view_of("speccoder init --force").0, View::Setup);
1094        assert_eq!(view_of("speccoder config").0, View::Settings);
1095        assert_eq!(
1096            view_of("speccoder config set checks.test.argv -- <your test command>").0,
1097            View::Settings
1098        );
1099        assert_eq!(view_of("speccoder do \"…\"").0, View::QuickChange);
1100        assert_eq!(
1101            view_of("speccoder bug \"<what happens>\" --expected \"<what should>\"").0,
1102            View::Bug
1103        );
1104        assert_eq!(
1105            view_of("speccoder review tests <REQ> --write").0,
1106            View::Review
1107        );
1108        assert_eq!(
1109            view_of("speccoder task acceptance-at {task} --story <US>"),
1110            (View::Task, Some("T001".to_owned()))
1111        );
1112    }
1113
1114    #[test]
1115    fn speccoder_commands_with_a_runnable_perform() {
1116        let t = || "T001".to_owned();
1117        assert_eq!(
1118            run_of("speccoder run").0,
1119            Runnable::Run {
1120                task: None,
1121                agent: None,
1122                cap: None
1123            }
1124        );
1125        assert_eq!(
1126            run_of("speccoder run --task T004 --agent opencode").0,
1127            Runnable::Run {
1128                task: Some("T004".to_owned()),
1129                agent: Some("opencode".to_owned()),
1130                cap: None
1131            }
1132        );
1133        assert_eq!(
1134            run_of("speccoder run --task {task}").0,
1135            Runnable::Run {
1136                task: Some(t()),
1137                agent: None,
1138                cap: None
1139            }
1140        );
1141        assert_eq!(
1142            run_of("speccoder run --feature {feature} -v").0,
1143            Runnable::Run {
1144                task: None,
1145                agent: None,
1146                cap: None
1147            }
1148        );
1149        assert_eq!(
1150            run_of("speccoder run --cap 20k --feature {feature}").0,
1151            Runnable::Run {
1152                task: None,
1153                agent: None,
1154                cap: Some("20k".to_owned())
1155            }
1156        );
1157        assert_eq!(run_of("speccoder run --converge").0, Runnable::Converge);
1158        assert_eq!(
1159            run_of("speccoder converge --feature {feature}").0,
1160            Runnable::Converge
1161        );
1162        assert_eq!(
1163            run_of("speccoder task skip {task} --reason \"…\""),
1164            (
1165                Runnable::Skip { task: t() },
1166                Some("Why it is skipped".to_owned()),
1167                None
1168            )
1169        );
1170        assert_eq!(
1171            run_of("speccoder task hint {task} \"…\"").1.as_deref(),
1172            Some("The hint")
1173        );
1174        assert_eq!(
1175            run_of("speccoder task done {task}").0,
1176            Runnable::Done { task: t() }
1177        );
1178        assert_eq!(
1179            run_of("speccoder task rule {task} --tests-right --reason \"…\"").0,
1180            Runnable::Rule { task: t() }
1181        );
1182        assert_eq!(
1183            run_of("speccoder bug keep {feature}/{task} --reason \"kept as a regression test\""),
1184            (
1185                Runnable::BugKeep {
1186                    task: "001-x/T001".to_owned()
1187                },
1188                None,
1189                Some("kept as a regression test".to_owned())
1190            )
1191        );
1192        assert_eq!(
1193            run_of("speccoder spec tasks"),
1194            (
1195                Runnable::Skill {
1196                    name: "tasks".to_owned(),
1197                    arguments: String::new()
1198                },
1199                None,
1200                None
1201            )
1202        );
1203        assert_eq!(
1204            run_of("speccoder spec plan --through tasks").0,
1205            Runnable::PlanThrough {
1206                from: PlanFrom::Continue,
1207                through: Through::Tasks
1208            }
1209        );
1210        assert_eq!(
1211            run_of("speccoder review answer Q1 \"…\"").1.as_deref(),
1212            Some("Your answer")
1213        );
1214        assert_eq!(run_of("speccoder fix").0, Runnable::FixSetup);
1215        assert!(matches!(
1216            h("speccoder pr --yes"),
1217            How::Perform { run, confirm: Some(_), .. } if *run == Runnable::PullRequest
1218        ));
1219        assert!(matches!(
1220            h("speccoder task drop {task}"),
1221            How::Perform { run, confirm: Some(_), .. }
1222                if matches!(*run, Runnable::DropSetAside { .. })
1223        ));
1224        assert_eq!(
1225            run_of("speccoder feature <name>"),
1226            (Runnable::Feature, Some("The feature".to_owned()), None)
1227        );
1228        // The feature a line names travels with it.
1229        assert!(matches!(
1230            h("speccoder run --feature 002-y"),
1231            How::Perform { feature: Some(f), .. } if f == "002-y"
1232        ));
1233    }
1234
1235    #[test]
1236    fn what_has_no_runnable_runs_as_written_when_speccoder_wrote_it() {
1237        assert_eq!(
1238            argvs_of("speccoder run --ignore-checklists").0,
1239            vec![s(&["speccoder", "run", "--ignore-checklists"])]
1240        );
1241        assert_eq!(
1242            argvs_of("speccoder attest verify --feature {feature} --json").0,
1243            vec![s(&[
1244                "speccoder",
1245                "attest",
1246                "verify",
1247                "--feature",
1248                "001-x",
1249                "--json"
1250            ])]
1251        );
1252        assert_eq!(
1253            argvs_of("speccoder spec archive --yes").0,
1254            vec![s(&["speccoder", "spec", "archive", "--yes"])]
1255        );
1256    }
1257
1258    #[test]
1259    fn the_persons_own_commands_are_exact_argvs() {
1260        // A constant line of speccoder's: run as it reads.
1261        assert_eq!(
1262            argvs_of("git add speccoder.toml && git commit -m \"speccoder: config\"").0,
1263            vec![
1264                s(&["git", "add", "speccoder.toml"]),
1265                s(&["git", "commit", "-m", "speccoder: config"])
1266            ]
1267        );
1268        assert_eq!(argvs_of("git stash -u").0, vec![s(&["git", "stash", "-u"])]);
1269        // Built as argvs by its source: run as given, shown as a shell reads it.
1270        let built = person(vec![s(&["git", "add", "--", "my file.toml", "it's"])]);
1271        assert_eq!(built.command, "git add -- 'my file.toml' 'it'\\''s'");
1272        assert!(matches!(&built.how, How::Command { argvs, .. } if argvs[0][3] == "my file.toml"));
1273        let typed = vec![vec![s(&["npm", "install"])]];
1274        let steps = steps_with(
1275            &["npm install".to_owned(), "npm ci".to_owned()],
1276            &typed,
1277            Context::default(),
1278        );
1279        assert!(matches!(&steps[0].how, How::Command { argvs, .. } if argvs == &typed[0]));
1280        // A line nobody built as argvs is never run from its text.
1281        assert!(steps[1].how.copy_only(), "{:?}", steps[1]);
1282    }
1283
1284    #[test]
1285    fn spliced_values_never_become_a_command() {
1286        // A file named `.x&&make`, spliced into a line, and a comment.
1287        for line in [
1288            "git add .x&&make && git commit -m \"Track project config\"",
1289            "git add .x && make && git commit -m init",
1290            "git switch B && git merge b   # resolve the conflicts",
1291            "git worktree remove /tmp/wt --force",
1292            "npm install",
1293            "speccoder run --feature 001-a --yes",
1294            "speccoder attest verify --feature 001-a --yes",
1295        ] {
1296            let how = h(line);
1297            assert!(!matches!(&how, How::Command { .. }), "{line}: {how:?}");
1298        }
1299        // `&&` separates only as a word; a word with it is no line at all.
1300        assert_eq!(argvs("a .x&&make"), None);
1301        assert_eq!(
1302            argvs("git merge b   # resolve"),
1303            Some(vec![s(&["git", "merge", "b"])])
1304        );
1305        // A slot takes a plain identifier, never a flag or two words.
1306        assert!(matches!(
1307            h("speccoder run --task T004 --fresh-acceptance"),
1308            How::Command { .. }
1309        ));
1310        assert!(!matches!(
1311            h("speccoder run --task --yes --fresh-acceptance"),
1312            How::Command { .. }
1313        ));
1314        assert!(matches!(
1315            h("speccoder run --ignore-checklists --feature 001-x"),
1316            How::Command { .. }
1317        ));
1318    }
1319
1320    #[test]
1321    fn free_slots_and_lines_about_other_commands_are_never_signed() {
1322        // `speccoder {command} --help` names any command: said, not run.
1323        for line in [
1324            "speccoder run --help",
1325            "speccoder run --json",
1326            "speccoder doctor --events",
1327        ] {
1328            assert!(!matches!(h(line), How::Command { .. }), "{line}");
1329        }
1330        // A key is the settings' to show, never a free slot.
1331        assert_eq!(
1332            view_of("speccoder config get model.region").0,
1333            View::Settings
1334        );
1335        assert_eq!(
1336            view_of("speccoder config unset model.base_url").0,
1337            View::Settings
1338        );
1339    }
1340
1341    #[test]
1342    fn what_writes_outside_the_project_says_so() {
1343        let global = person(vec![s(&["git", "config", "--global", "gpg.format", "ssh"])]);
1344        assert!(
1345            matches!(global.how, How::Command { outside: Some(o), .. } if o == "changes your global git config")
1346        );
1347        let local = person(vec![s(&["npm", "install"])]);
1348        assert!(matches!(local.how, How::Command { outside: None, .. }));
1349    }
1350
1351    #[test]
1352    fn what_reads_the_keyboard_is_known() {
1353        for argv in [
1354            &["copilot", "login"][..],
1355            &["opencode", "auth", "login"],
1356            &["gh", "auth", "login"],
1357            &["aws", "sso", "login", "--profile", "dev"],
1358            &["ssh-keygen", "-t", "ed25519"],
1359            &["git", "commit"],
1360            &["git", "merge", "b"],
1361            &["specify", "init", "--here"],
1362            &["speccoder", "spec", "archive"],
1363        ] {
1364            assert!(interactive(&s(argv)), "{argv:?}");
1365        }
1366        for argv in [
1367            &["brew", "install", "gh"][..],
1368            &["git", "commit", "-m", "x"],
1369            &["ssh-keygen", "-t", "ed25519", "-N", "", "-f", "k"],
1370            &["speccoder", "spec", "archive", "--yes"],
1371        ] {
1372            assert!(!interactive(&s(argv)), "{argv:?}");
1373        }
1374    }
1375
1376    #[test]
1377    fn only_a_signer_on_this_thread_signs_and_only_what_is_built() {
1378        let unsigned = person(vec![s(&["npm", "install"])]);
1379        assert!(matches!(unsigned.how, How::Command { token: None, .. }));
1380        let signed = signing(
1381            std::rc::Rc::new(|a: &[Vec<String>], i: bool| format!("{}:{i}", a.len())),
1382            || person(vec![s(&["npm", "install"])]),
1383        );
1384        assert!(matches!(signed.how, How::Command { token: Some(t), .. } if t == "1:false"));
1385        // Reset once done.
1386        assert!(matches!(
1387            person(vec![s(&["x"])]).how,
1388            How::Command { token: None, .. }
1389        ));
1390    }
1391
1392    #[test]
1393    fn what_cannot_run_here_says_why() {
1394        assert!(matches!(
1395            h("export ANTHROPIC_API_KEY=…"),
1396            How::Shell { setting: Some(s), .. } if s == "model"
1397        ));
1398        assert!(matches!(
1399            h("export SPECCODER_STATE_DIR=\"$PWD/.speccoder-state\""),
1400            How::Shell { setting: None, .. }
1401        ));
1402        for line in [
1403            "→ re-run with -v and report the output",
1404            "set [sandbox] mode = \"off\"",
1405            "curl https://example.com | sh",
1406            "sudo apt-get install bubblewrap",
1407            "git remote add origin <url>",
1408            "git diff -- <unit>/review.json",
1409            "echo $HOME",
1410            "speccoder eval --keep <dir>",
1411            "speccoder pr --remote <name>",
1412        ] {
1413            assert!(h(line).copy_only(), "{line}: {:?}", h(line));
1414        }
1415    }
1416}