Skip to content

fix: answer install prompts without a terminal, and say what --yes leaves (iss-167, iss-166) - #226

Merged
REPPL merged 6 commits into
mainfrom
fix/iss-167-non-tty-prompts
Aug 12, 2026
Merged

fix: answer install prompts without a terminal, and say what --yes leaves (iss-167, iss-166)#226
REPPL merged 6 commits into
mainfrom
fix/iss-167-non-tty-prompts

Conversation

@REPPL

@REPPL REPPL commented Aug 12, 2026

Copy link
Copy Markdown
Owner

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 install attached 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, 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, 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. newPrompter binds to cmd.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 (through termsafe.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-answers file, 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, categoryPromptOrderdependency, 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 --yes run, so the documented remedy is yes | abcd ahoy install everywhere it is printed or written: the completion notice, the --yes help, the regenerated CLI reference, commands/ahoy.md, the brief and the changelog. A single printf 'y\n' answers the first question and lets the rest read EOF and decline.

iss-166 — the ruling, stated. The fork was: either --yes covers optional categories too, or its help and completion output say they are excluded and how to apply them.

Ruling: --yes does NOT cover the optional git-identity pin, and the exclusion is now loud.

Letting --yes pin 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:

  • the flag's own help states it, and the generated CLI reference is regenerated to match;
  • InstallResult carries optional_skipped (the gap ids --yes deliberately left), populated on the already_up_to_date early return too — the exact path where the report said "already up to date" and mentioned nothing;
  • the completion output prints it with the way to apply it, which iss-167 makes available to a non-interactive caller for the first time: 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:

--- FAIL: TestAhoyInstallAcceptsPipedAnswersFromNonTTYStdin
    piped `y` was read as a decline at the adoption gate: status="aborted"
--- FAIL: TestAhoyInstallPipedAnswerAdoptsOptionalIdentityPin
    piped `y` did not adopt the optional identity pin: …/.abcd/config/identity.json: no such file
--- FAIL: TestAhoyInstallYesDisclosesOptionalIdentityPin
    --yes silently skipped the optional identity pin; output names no exclusion:
    abcd ahoy install — already_up_to_date
--- FAIL: TestAhoyInstallYesHelpStatesTheExclusion
    --yes help does not state the optional identity-pin exclusion:
    "--yes    approve every resolvable change category without prompting"

All four pass after. TestAhoyInstallEmptyStdinStillDeclines passed 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 a strings.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 y and 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:

--- FAIL: TestResolveApprovalPromptsInCanonicalOrder
    run 0 asked in a different order:
     got [config-change plugin-owned dependency safe-autocreate user-state]
    want [dependency safe-autocreate config-change user-state plugin-owned]
--- FAIL: TestResolveApprovalAsksUnknownCategoriesLast
    run 0: unknown categories not asked last and sorted

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.md states it where the agent will read it, with both escapes: close stdin, or pre-answer with abcd ahoy install --yes --refuse-adopt < /dev/null. The reachable non-interactive cases end immediately — /dev/null is a character device, a closed pipe reads EOF, a --yes run asks nothing — and this is the contract every prompting CLI has.

Review findings addressed

# Finding Fix
1 BLOCKER — map-range approval order made piped answers nondeterministic categoryPromptOrder + presentInPromptOrder, sorted tail for unnamed categories; three tests assert the order contract
2 MAJOR — the printed remedy could not answer every question yes | abcd ahoy install in the notice, the --yes help, the CLI reference, commands/ahoy.md, the brief and the changelog
3 MINOR — held-open stdin blocks, undocumented stated in commands/ahoy.md with both escapes, and in the surface brief

The reviewer explicitly cleared the terminal path, EOF semantics, the termsafe echo, the optional_skipped wiring and the pipe-based harness; none of those are reworked here.

Second pass, on the fixes above:

# Finding Fix
4 MINOR — the order helper asserted a question count the HOST decides hermeticRepo 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 following abcd's own brew install gitleaks hint 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)
5 NIT — yes | exits 141 under set -o pipefail commands/ahoy.md says so where the form is introduced: yes takes SIGPIPE by design, so judge the run by abcd's own output and exit status

Finding 4 watched fail and pass under the reviewer's own condition — with stub gitleaks and trufflehog on PATH the committed test fails (transcript asked 1 category approvals, over a transcript reading Apply config-change changes? [y/N] y, i.e. correct behaviour) and the fixed test passes.

Gates

make preflight green and gofmt -l . empty at 57df348 — branch and SHA asserted identical immediately before and after the run.

Notes for the maintainer

  • All six PRs in this run touch CHANGELOG.md; the entry is appended at the top of the [Unreleased] / Fixed subsection with no neighbouring entries reflowed. Conflicts are resolved on merge.
  • The generated docs/reference/cli/commands.md is refreshed via go generate ./internal/surface/cli (the drift test gates it). .abcd/development/release/surface.json is unchanged — flag help is not part of the compatibility snapshot.
  • One incidental find worth keeping: a flag usage string must contain no backquotes, because cobra reads the first backquoted word as the flag's argument placeholder — a quoted `y` rendered this boolean as --yes y in the help and the reference. Recorded as a comment at the flag.

Assisted-by: Claude:claude-opus-5[1m]

REPPL added 4 commits August 12, 2026 10:05
…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]
@REPPL
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
@REPPL
REPPL merged commit 77a3686 into main Aug 12, 2026
13 checks passed
@REPPL
REPPL deleted the fix/iss-167-non-tty-prompts branch August 12, 2026 14:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant