Skip to content

v1.15.1 ·

The thirteenth prompt cannot forget

The problem

tty-prompt-guard made the interactivity check available everywhere (core/tty.ts exports isInteractive() and NO_TTY_REASON) and tiered all 16 @clack/prompts call sites. It did not make the check unskippable. Seven modules import @clack/prompts directly today:

ModulePrompt calls
src/menu/prompt.tsselect, text, intro, outro (the existing seam)
src/commands/init.tsselect ×3, multiselect ×1, confirm ×1, intro, note
src/commands/add-action.tsselect ×1, confirm ×2
src/commands/start.tsselect ×2
src/commands/add-skill.tsconfirm ×1
src/commands/knowledge.tsconfirm ×1
src/doctor/runner.tsconfirm ×1

(src/security/confirmation.ts matches a grep for @clack/prompts but only in a comment — it imports nothing and is not in scope.)

The 13th prompt can still forget the guard. The only thing preventing it is tty-prompt-guard/AC-17, a behavioural test that spawns every registered command under a non-TTY stdin — one subprocess per command, in the slowest and most timing-sensitive part of the package (see subprocess-test-flake) — and one that proves the guard held this time rather than that it cannot be skipped.

Every call site also re-implements the same clack.isCancel(x) translation, and each hand-writes its own non-interactive message.

How it could be solved

There were four ways to stop the seventeenth prompt from freezing the tool, and they differ in what they ask of the person who writes it.

The first was to keep reminding. The check already existed and was already applied everywhere; all that was missing was for the next person to remember to use it. This project has an unusually good record of what that is worth. A release record once named an exact fix in plain sentences, and three days later the same fix was rediscovered from nothing and re-scoped as new work. A note asking for a manifest entry was never re-read, and two releases published no post at all while every automated check reported success. The conclusion is already written down twice: an instruction recorded as prose is not a mechanism, and being correct has no bearing on whether it runs.

The second was the one already in place, and it is worth being precise about why it was not enough. A test started every command in the tool with no terminal attached and checked that none of them froze. That is a real test of real behaviour and it caught the original defect. But it can only ever speak about the commands that existed on the day it ran. A new prompt added to a command it already covers passes it for free, because the command exits for some earlier reason and never reaches the new question. It also cost eighteen process launches to say what it said, in the part of the suite that has historically been the slowest and the most sensitive to what else the machine is doing. It was, in the end, a test of the past.

The third was to move the check into the tool's own type system — make it impossible to hold an unanswered question without first holding proof that a terminal exists. That is the strongest version, and it was rejected on cost rather than merit. It means threading a witness value through five commands and their private helpers, and it changes every signature along the way in service of a rule that has exactly one enforcement point. The strength would be real and so would the noise.

The fourth is what shipped: put all the questions behind one door, and make the door's own construction impossible to get wrong. Every question checks the terminal first and reaches for the prompt library second. Since the library is now imported in exactly one file, asking a question any other way means importing it somewhere else, and a single test reads every source file and refuses that, naming the offender. The rule became a property of where code lives rather than of what somebody remembered. That is why this trades one gate for another rather than adding one: the enumeration test is deleted, not supplemented.

The hardest decision inside that choice was what the door should decide. The obvious move is to have it handle the no-terminal case itself — print something sensible, exit, done. That would have been wrong, and the reason is that the five commands disagree with each other on purpose. Setup treats a piped run as agreement to enable usage tracking, because its existing "ask me nothing" flag already does and the two must not give different answers. Skill creation exits with a failure code, because a script has to be able to tell "you declined" from "I could not ask you". The merge-driver installer prints its whole plan and then refuses, because nothing should authorise a write to your git configuration on the grounds that nobody was watching. The action creator declines separately rather than borrowing the "ask me nothing" path, because that path saves the file. Four commands, four correct answers. A single policy would have silently overwritten three of them — which is the standing lesson about auditing a whole class before fixing one member of it, and then checking whether the uniform fix is wrong for some of them.

So the door reports and does not rule. It says which of three things happened: here is your answer, you cancelled, or nobody was there. Three rather than two is a small change with a long reach. The shape it replaced returned either a string or a private cancellation marker, and that could not express the third case without inventing a second marker to sit beside the first. Worse, it made the dangerous reading cheap: the cancellation marker is a truthy value, so a check written the natural way treats "they gave up" as "they agreed". The new shape cannot be misread that way, and the three places that write to disk the instant they see a yes are exactly where that protection is worth having.

The last question was whether to trust the refactor or verify it. Sixteen no-terminal runs across eight commands and both kinds of empty input were recorded from the built tool before a single line was edited, recorded again when it was finished, and compared character by character — including which stream each message was written to. All sixteen match. That was worth more than any amount of reading the diff, and it was cheap, because the recording was written before the work rather than after it went wrong.

How AIDLC solves it

single-clack-prompt-seam

Every question the CLI asks now comes from one module, and that module checks whether anyone is there to answer before it opens a prompt.

The previous instance, tty-prompt-guard, fixed twelve unguarded prompts by giving the package a shared "is this a terminal?" test and applying it at each of the sixteen places that ask something. That made the check available everywhere. It did not make it unavoidable. The next prompt anybody wrote could still skip it, and the only thing standing in the way was a test that started every command in the CLI with no terminal attached and checked that none of them froze. That test proved the guard held on the commands that existed the day it was written. It could not prove the seventeenth prompt would consult the guard at all, and it spent eighteen process launches to say so.

The new module is where the check now lives, and it is the only file in the CLI allowed to talk to the prompt library. Every question it exposes looks at the terminal first and the library second, in that order. Opening a prompt nobody can answer therefore requires importing the library somewhere else — and a test now reads every source file in the package and fails, naming the file, if any of them does. It recognises all four ways of writing an import, so the rule cannot be sidestepped by changing syntax rather than behaviour.

What the module deliberately does not do is decide what a command should do when there is no terminal. Those answers differ, and they differ correctly. Setup treats a piped run as a yes for its usage-tracking consent, because that is what its existing "no questions" flag already does and the two must not disagree. Skill creation exits with a failure code so that "you said no" stays distinguishable from "I could not ask you" — a script needs to tell those apart. The merge-driver installer prints exactly what it would change and then refuses, because a missing terminal must never authorise a write to your git configuration. The action creator gets its own refusal rather than borrowing the "no questions" path, because that path saves the file. One uniform policy would have quietly changed three of those. So the module reports which of the three things happened — a real answer, you cancelled, or nobody was there — and each command keeps its own judgement.

Three outcomes rather than two is the change with the longest reach. The old shape returned either a string or a private cancellation marker, which could not express "nobody was there" without inventing a second marker. It also made the dangerous mistake cheap to write, because a cancellation marker is a truthy value and reads as agreement to anyone skimming. The new shape cannot be misread that way, which matters most at the three places that write to disk immediately after a yes.

Nothing about using the CLI changes. Every command, flag, exit code, and message is the same. That is not an assertion: sixteen no-terminal runs across eight commands and both kinds of empty input were recorded from the built tool before a single line was edited, recorded again afterwards, and compared. All sixteen match exactly, including which stream each message went to.

The old enumeration test is gone, and the eighteen process launches with it. The targeted checks for the commands that actually own a prompt are kept, because a test that reads source code can never observe an exit code. The suite is not measurably faster — removing those launches bought roughly what the thirty-six new checks spent, and the variation between runs is wider than the difference. The launch count is what mattered, and a new test makes sure it cannot quietly grow back.

cost-perf-test-measures-load

Every question the CLI asks now comes from one module, and that module checks whether anyone is there to answer before it opens a prompt.

The previous instance, tty-prompt-guard, fixed twelve unguarded prompts by giving the package a shared "is this a terminal?" test and applying it at each of the sixteen places that ask something. That made the check available everywhere. It did not make it unavoidable. The next prompt anybody wrote could still skip it, and the only thing standing in the way was a test that started every command in the CLI with no terminal attached and checked that none of them froze. That test proved the guard held on the commands that existed the day it was written. It could not prove the seventeenth prompt would consult the guard at all, and it spent eighteen process launches to say so.

The new module is where the check now lives, and it is the only file in the CLI allowed to talk to the prompt library. Every question it exposes looks at the terminal first and the library second, in that order. Opening a prompt nobody can answer therefore requires importing the library somewhere else — and a test now reads every source file in the package and fails, naming the file, if any of them does. It recognises all four ways of writing an import, so the rule cannot be sidestepped by changing syntax rather than behaviour.

What the module deliberately does not do is decide what a command should do when there is no terminal. Those answers differ, and they differ correctly. Setup treats a piped run as a yes for its usage-tracking consent, because that is what its existing "no questions" flag already does and the two must not disagree. Skill creation exits with a failure code so that "you said no" stays distinguishable from "I could not ask you" — a script needs to tell those apart. The merge-driver installer prints exactly what it would change and then refuses, because a missing terminal must never authorise a write to your git configuration. The action creator gets its own refusal rather than borrowing the "no questions" path, because that path saves the file. One uniform policy would have quietly changed three of those. So the module reports which of the three things happened — a real answer, you cancelled, or nobody was there — and each command keeps its own judgement.

Three outcomes rather than two is the change with the longest reach. The old shape returned either a string or a private cancellation marker, which could not express "nobody was there" without inventing a second marker. It also made the dangerous mistake cheap to write, because a cancellation marker is a truthy value and reads as agreement to anyone skimming. The new shape cannot be misread that way, which matters most at the three places that write to disk immediately after a yes.

Nothing about using the CLI changes. Every command, flag, exit code, and message is the same. That is not an assertion: sixteen no-terminal runs across eight commands and both kinds of empty input were recorded from the built tool before a single line was edited, recorded again afterwards, and compared. All sixteen match exactly, including which stream each message went to.

The old enumeration test is gone, and the eighteen process launches with it. The targeted checks for the commands that actually own a prompt are kept, because a test that reads source code can never observe an exit code. The suite is not measurably faster — removing those launches bought roughly what the thirty-six new checks spent, and the variation between runs is wider than the difference. The launch count is what mattered, and a new test makes sure it cannot quietly grow back.