fix: answer install prompts without a terminal, and say what --yes leaves (iss-167, iss-166) - #226
Merged
Merged
Conversation
…aves `ahoy install` attached its prompter to stdin only when stdin was a terminal, so `printf 'y\n' | abcd ahoy install` — the first thing a host agent reaches for — arrived as a decline on every question. The interactive path could not be driven at all: the agent reported failure and handed the step back to the human, the exact seam the install-experience plan exists to smooth (iss-167). The prompts now read stdin whether or not it is a terminal. It is the same prompter and the same questions; only the reader differs. Off a terminal each answer is echoed to stderr (sanitised — the bytes come from the caller), so a piped run leaves a transcript of what was asked and what it was answered rather than a column of questions with no visible reply. The safe default survives intact: answers that run out read as EOF, and EOF declines every confirm and takes the default for every prompt, so an unattended run still adopts nothing it was not told to adopt. A run at a terminal behaves exactly as before. Folded in, the iss-166 ruling: `--yes` does NOT cover the optional git-identity pin. The alternative — letting it — is the wrong half of the fork, because the pin records whatever git identity happens to be configured, so a blanket approval would canonicalise a sandbox or agent identity, the very value the identity gate exists to reject. So the exclusion stands and stops being silent: it is stated in the flag's own help, carried in the install envelope as `optional_skipped`, and printed in the completion output with the way to apply it — which the piped answer above makes available to a non-interactive caller for the first time. A `--yes` run that says "already up to date" while leaving the pin unadopted now says so. Tests watched fail before the change: a piped `y` read as a decline at the adoption gate, a second `--yes` run naming no exclusion, and `--yes` help stating none. The empty-stdin test passed before and after — it guards the default the new read must not cost. Resolves iss-167 and iss-166 in the ledger. Assisted-by: Claude:claude-opus-5[1m]
The approval walk iterates a map, so which category is asked first is not fixed from run to run. The piped-pin test fed a single `y`, which landed on whichever question came up first: it passed locally and failed on both CI platforms, where the answer went to the dependency category and the identity pin was left to read EOF and decline. Feed one `y` per remaining category. Test-only; no behaviour change. Assisted-by: Claude:claude-opus-5[1m]
…that answers all of them Review found the piped-answer path resting on an order that does not exist. resolveApproval ranged over the presence map, so which category was asked first was a fresh permutation on every run. Answers are POSITIONAL — a human reads down the list, a caller pipes a stream — so "answer y to the first question" approved a different category each time. On the exact scenario the iss-166 notice prints, that is a silent nondeterministic failure: the run exits 0 with status "clean" and the pin is simply not written, on some runs and not others. The earlier test fix (feeding eight answers) papered over the product defect rather than removing it. The questions now come in a fixed order, categoryPromptOrder: dependency, safe-autocreate, config-change, user-state, plugin-owned — the order the apply pass acts in, so what the user is asked follows what happens to their repo. A category the list does not name is appended sorted, so the contract cannot be broken by omission, only made less meaningful, which a coverage test catches. Second: the remedy this change prints has to answer every question. One line answers one question, and more than one category is open after a --yes run, so `printf 'y\n' |` answered the first and let the rest read EOF and decline — under the fixed order, deterministically the wrong one. Every place that printed or documented it now says `yes | abcd ahoy install`: the completion notice, the --yes help, the regenerated CLI reference, the plugin surface, the brief and the changelog. Third: the plugin surface states the cost of reading a non-terminal stdin. A stdin held open and silent makes a prompt WAIT rather than decline, which no committed document said. commands/ahoy.md names it with both escapes — close stdin, or pre-answer with --yes --refuse-adopt < /dev/null. Tests: the order test fails on the map range (run 0 asked config-change, plugin-owned, dependency, safe-autocreate, user-state against the canonical order) and passes after, over 64 repetitions because a single run of a randomised order passes by luck too often to mean anything. The unknown- category test pins the sorted tail. At the CLI the transcript itself is asserted to arrive in order, and the pin test now asserts the config-change question was the one answered y, rather than assuming some question was. Assisted-by: Claude:claude-opus-5[1m]
…pipefail exit
Two polish findings from the second review pass.
The order helper fatalled when a transcript held fewer than two category
approvals, and how many a repo has is not the test's to decide: the hermetic
repo redirects HOME, the plugin root and the bin target, but not PATH, so the
dependency approval exists only while the opt-in scanners are absent. A
maintainer who follows abcd's own `brew install gitleaks` hint would have got a
red preflight from a product that behaved perfectly — the transcript in that
state reads `Apply config-change changes? [y/N] y`, exactly right.
Too few questions is now a no-op in the helper: it checks the order of what it
was given and claims nothing about how much that was. The count assertion moves
to the one test that owns its repo state — a fresh unmanaged repo, which has
gaps in at least three categories whatever the host has installed, so an order
claim there is never vacuous.
Watched fail and pass under the reviewer's condition: with stub gitleaks and
trufflehog on PATH the committed test fails ("transcript asked 1 category
approvals") and the fixed one passes.
Second, the plugin surface names the pipefail exit: `yes | abcd ahoy install`
under `set -o pipefail` reports 141, because `yes` takes SIGPIPE when abcd stops
reading. A wrapper reading the pipeline's status would call a successful install
a failure, so the doc says to judge the run by abcd's own output and exit.
Assisted-by: Claude:claude-opus-5[1m]
…rompts Assisted-by: Claude:claude-fable-5
REPPL
enabled auto-merge
August 12, 2026 14:12
The fenced non-interactive form predates the command-ladder detector that
main now carries, so the union of the two green branches was red: a fenced
bare-path invocation in commands/ahoy.md. The fenced line now invokes
"${CLAUDE_PLUGIN_ROOT}/abcd" like every other fenced command, per the
ladder iss-205 established.
Assisted-by: Claude:claude-fable-5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Item A5 of the install-experience run (
.abcd/development/plans/2026-08-11-install-experience.md), per interview decision 6: "Prompts read stdin when stdin is not a TTY." Fixes iss-167, carries iss-166.Why
ahoy installattached its prompter to stdin only when stdin was a character device. Every other stdin — a pipe, a file, a pseudo-TTY attempt — got the refusing prompter, soprintf 'y\n' | abcd ahoy install, the first thing a host agent reaches for, arrived as a decline on every question. The interactive path could not be driven at all: the agent reported failure and handed the step back to the human, which is the exact seam this plan exists to smooth.What
iss-167 — prompts read a non-TTY stdin. The same prompter, the same questions; only the reader differs.
newPrompterbinds tocmd.InOrStdin()whether or not it is a terminal, and the safe default survives intact: answers that run out read as EOF, and EOF declines every confirm and takes the default for every prompt — exactly what the refusing prompter did — so an unattended run with nothing on stdin still adopts nothing it was not told to adopt. A run at a terminal is untouched. Off a terminal each answer is echoed to stderr (throughtermsafe.Sanitize, since the bytes come from the caller), so a piped run leaves a transcript of what was asked and answered rather than a column of questions with no visible reply. Smallest surface: no new flag, no--prompt-answersfile, no pseudo-TTY.The order contract that makes piped answers mean anything. Answers are positional, and the approval walk ranged over a map, so which category was asked first was a fresh permutation on every run: "answer y to the first question" approved a different category each time — a wrong answer that exits 0 and reads as a clean install. The questions now come in a fixed order,
categoryPromptOrder—dependency,safe-autocreate,config-change,user-state,plugin-owned, the order the apply pass acts in, so what the user is asked follows what happens to their repo. A category the list does not name is appended sorted, so the contract cannot be broken by omission, only made less meaningful — which a coverage test catches.One line answers one question, and more than one category is open after a
--yesrun, so the documented remedy isyes | abcd ahoy installeverywhere it is printed or written: the completion notice, the--yeshelp, the regenerated CLI reference,commands/ahoy.md, the brief and the changelog. A singleprintf 'y\n'answers the first question and lets the rest read EOF and decline.iss-166 — the ruling, stated. The fork was: either
--yescovers optional categories too, or its help and completion output say they are excluded and how to apply them.Letting
--yespin is the wrong half of the fork. The pin records whatever git identity happens to be configured, so a blanket non-interactive approval would canonicalise a sandbox or agent identity as this repo's canonical author — precisely the value the identity gate exists to reject, and precisely what the attribution gate refuses (#222). The exclusion is a safety property, not an oversight; what was wrong was its silence. So:InstallResultcarriesoptional_skipped(the gap ids--yesdeliberately left), populated on thealready_up_to_dateearly return too — the exact path where the report said "already up to date" and mentioned nothing;yes | abcd ahoy install.The two issues resolve each other. Before this change there was no non-interactive way to apply the optional pin at all, so "how to apply it" would have read "find a human with a terminal". iss-166 is resolved in the ledger — the ruling closes it on its own stated terms (help + completion output say the exclusion and how to apply it). Nothing remains.
Also updated in the same change:
commands/ahoy.md(the plugin surface an agent actually reads) gains the piped-answer form, framed as a channel for relaying an answer the user has given — ask first, then pipe — never a licence to answer on their behalf; and the ahoy surface brief records the stdin contract and the pin exclusion.Red → green witnessed
Tests written first, run against the unchanged code:
All four pass after.
TestAhoyInstallEmptyStdinStillDeclinespassed before and after by design — it guards the default the new read must not cost, and would have caught a fix that made an unattended run start approving things.The stdin in these tests is a real
os.Pipe, not astrings.Reader: the terminal test is made against the file's mode, so only a real non-character-device file exercises the seam that was broken.CI flagged the missing order contract before review did: the pin test fed a single
yand failed on both CI platforms while passing locally. The first response fed more answers, which papered over the product defect; the review was right to block on that, and the defect itself is fixed above.Red→green for the order contract (
internal/core/ahoy/prompt_order_test.go), against the unchanged walk:Green after, over 64 and 32 repetitions respectively — a single run of a randomised order passes by luck far too often to prove anything. At the CLI the transcript itself is now asserted to arrive in order, and the pin test asserts that the config-change question was the one answered
y, rather than assuming some question was.The tradeoff, now on the record
Reading a non-terminal stdin means a prompt can block on a stdin that is held open and never written — where the refusing prompter could not.
commands/ahoy.mdstates it where the agent will read it, with both escapes: close stdin, or pre-answer withabcd ahoy install --yes --refuse-adopt < /dev/null. The reachable non-interactive cases end immediately —/dev/nullis a character device, a closed pipe reads EOF, a--yesrun asks nothing — and this is the contract every prompting CLI has.Review findings addressed
categoryPromptOrder+presentInPromptOrder, sorted tail for unnamed categories; three tests assert the order contractyes | abcd ahoy installin the notice, the--yeshelp, the CLI reference,commands/ahoy.md, the brief and the changelogcommands/ahoy.mdwith both escapes, and in the surface briefThe reviewer explicitly cleared the terminal path, EOF semantics, the
termsafeecho, theoptional_skippedwiring and the pipe-based harness; none of those are reworked here.Second pass, on the fixes above:
hermeticReporedirects HOME, the plugin root and the bin target, but not PATH, so the dependency approval exists only while the opt-in scanners are absent: a maintainer following abcd's ownbrew install gitleakshint got a red preflight from a product behaving perfectly. Too few questions is now a no-op in the helper; the count assertion moves to the one test that owns its repo state (a fresh unmanaged repo, ≥3 categories on any host)yes |exits 141 underset -o pipefailcommands/ahoy.mdsays so where the form is introduced:yestakes SIGPIPE by design, so judge the run by abcd's own output and exit statusFinding 4 watched fail and pass under the reviewer's own condition — with stub
gitleaksandtrufflehogon PATH the committed test fails (transcript asked 1 category approvals, over a transcript readingApply config-change changes? [y/N] y, i.e. correct behaviour) and the fixed test passes.Gates
make preflightgreen andgofmt -l .empty at 57df348 — branch and SHA asserted identical immediately before and after the run.Notes for the maintainer
CHANGELOG.md; the entry is appended at the top of the[Unreleased] / Fixedsubsection with no neighbouring entries reflowed. Conflicts are resolved on merge.docs/reference/cli/commands.mdis refreshed viago generate ./internal/surface/cli(the drift test gates it)..abcd/development/release/surface.jsonis unchanged — flag help is not part of the compatibility snapshot.`y`rendered this boolean as--yes yin the help and the reference. Recorded as a comment at the flag.Assisted-by: Claude:claude-opus-5[1m]