Skip to content

Stdin - support everywhere sensible; usage error for stray - elsewhere - #641

Open
jeremy wants to merge 8 commits into
mainfrom
stdin
Open

Stdin - support everywhere sensible; usage error for stray - elsewhere#641
jeremy wants to merge 8 commits into
mainfrom
stdin

Conversation

@jeremy

@jeremy jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member

Agents instinctively pass - to mean "read content from stdin" — but only comments create/update honored it. Everywhere else the hyphen landed as literal content: a todo titled -, a message body of -.

Tier 1 — - reads stdin on every content input

Content-kind positionals: comments create/update, checkins answer create/update, todos create (join-all pattern); messages create [body], cards create [body], docs create [content], chat post/update, boost create, notes set (exact-positional pattern).

Content flags: --data (api post/put), --body (messages/cards update), --content (chat, files update), --description (todos, schedule, projects, todolists, templates, gauges, cards column, uploads create, upload, files replace), --comment (todos sweep), --file (notes set).

Resolution lives in internal/commands/stdin.go. The shared vocabulary — the allow_dash annotation and pipe detection — is the new internal/stdinarg leaf package, because internal/cli needs it too: --agent help now auto-synthesizes a per-command note ("Pass - to read from stdin: [body], --description") from the annotation, so tier-1 coverage self-documents, including for future commands.

Tier 2 — stray literal - + piped stdin = usage error

A central guard wraps every runnable RunE in the assembled tree (~380 commands, aliases and future ones included). It wraps RunE rather than hooking PersistentPreRunE because cobra runs only the innermost one — the agent hook already shadows the root's — and wrapping runs after flag parsing with ArgsLenAtDash available.

When stdin is piped and an exact - appears anywhere not annotated (positional or string/stringArray flag value), the command fails with a usage error naming the offender, pointing at where it does accept stdin, and teaching the -- escape. On a TTY, a literal - stays legal everywhere. Two allowed - in one invocation can never both be satisfied, so that errors regardless of pipe state. --out - (attachments/files download) is exempted as the stdout idiom.

Behavior changes

  1. comments create 123 - extra — was a silent literal "- extra" comment, now a usage error.
  2. - with TTY stdin — was hang-until-Ctrl-D, now an immediate usage error teaching the escapes: pipe it, heredoc (… - <<'EOF'), cat | … - (type + Ctrl-D), or --edit where it exists.
  3. Bare-pipe auto-read removed from comments create and notes set — piped stdin without - now errors with a hint instead of being silently consumed. Corollary: an unclaimed pipe alongside a named source (generate | notes set --file x.md) is ignored rather than raising the old ambiguity error — pipes are only ever a source through an explicit -, uniformly.
  4. notes set - (piped) — was a bogus "two sources" error, now works; notes set --file - — was ENOENT on a file named -, now stdin.
  5. Piped scripts passing literal - as a title/name/path now error; -- is the documented escape. TTY usage unaffected.
  6. Stdin content gets trailing newlines trimmed — Markdown doesn't care, but titles and boost's 16-rune limit do (printf '🎉\n' | boost create <id> - no longer burns a rune).

Flag for review

No --stdin flag. A precedent survey settled on - as the universal content-from-stdin idiom; --stdin in the wild means other things (git plumbing = list-of-items, kubectl = attach container stdin), and heredoc/cat | give interactive humans the classic TTY path with zero new surface. This was an open question during planning — veto welcome if you still want the flag.

Tests

  • internal/stdinarg: annotation parsing, pipe detection (char-device TTY stand-in per the established edit_test.go seam).
  • Resolver semantics table + -- escape through real parses.
  • Guard: unlisted positional (projects create -), unlisted flag (todos update --title -), TTY passthrough, --out - exemption, double-dash rejection, --attach - alongside an allowed body, -- escape.
  • Per-pattern integration through mock transports: messages create body -, api post --data -, todos create -, boost create - (+ over-limit stdin), todos update --description -, notes set both forms.
  • New e2e/stdin_dash.bats: empty-pipe rejection, TTY no-hang, bare-pipe hint, tier-2 rejection with -- escape, -- passthrough — all pre-network, no cassette needed. (The planned "posts body against cassette" e2e isn't recordable without live credentials — the happypath cassette set is read-only — so wire-level posting is covered by the mock-transport integration tests instead.)

bin/ci green: fmt, vet, lint, unit, e2e, surface snapshot (no Use-string or flag renames, so no regen), skill drift, smoke coverage, provenance. SKILL.md's - idiom is generalized in the same PR — it previously over-promised; now it's true.


Summary by cubic

Adds uniform “-” (stdin) support across content inputs and rejects stray “-” as a usage error when stdin is piped. TUIs (first‑run wizard, pickers, including the profile picker) now require character‑device stdin+stdout so piped input is never eaten as keystrokes.

  • Accepts “-” on content inputs:

    • Positionals: comments create/update; checkins answer create/update; todos create; messages/cards create [body]; docs documents create [content]; chat post/update; boost create; notes set.
    • Flags: --data (api post/put), --body, --content, --description, --comment (todos sweep), --file (notes set). “--out -” remains stdout (exempt).
  • Guard and implementation:

    • Runs at args validation; root is guarded too (piped “basecamp -” is usage; “-- -” keeps it literal). Rejects multiple stdin consumers; alias flags dedupe and errors name alias groups. Empty stdin is a usage error. Stdin content trims trailing CRLF/LF. Resolves “-” before account/project/network. Helpers live in internal/commands/stdin.go; shared annotation/pipe detection in internal/stdinarg. Agent help lists stdin‑capable inputs from the annotation. Help examples are validated against real command paths.
  • Behavior changes and required actions:

    • Piped input is never consumed implicitly; add “-” where stdin is intended.
    • For a literal “-”: escape positionals with “--”; for flags, run without piped stdin.
    • Using “-” on a TTY with no pipe errors immediately with hints.
    • Root: piped “basecamp -” errors; “basecamp -- -” stays literal.
    • messages/cards/docs create now use MaximumNArgs(2); Cobra arity errors are usage.
    • notes set: “-” and “--file -” read stdin; prior two‑source/ENOENT cases fixed.
    • chat post/update: cannot combine a positional message with --content.

Written for commit d73b2c3. Summary will update on new commits.

Review in cubic

Agents instinctively pass - to mean "read content from stdin", but only
comments create/update honored it — everywhere else the hyphen landed as
literal content (a todo titled "-", a message body of "-").

Tier 1: - now reads stdin on every content-kind positional (comments
create/update, checkins answer create/update, todos create, messages
create [body], cards create [body], docs create [content], chat
post/update, boost create, notes set) and content flag (--data on api
post/put, --body, --content, --description, --comment on todos sweep,
--file on notes set). Resolution lives in internal/commands/stdin.go;
the shared vocabulary (allow_dash annotation, pipe detection) in the new
internal/stdinarg leaf package, since internal/cli needs it too for
agent help.

Tier 2: everywhere else, a literal - combined with piped stdin is
ambiguous — the caller almost certainly meant the pipe — so a central
guard wrapped around every RunE in the tree rejects it with a usage
error naming the offender, pointing at where the command does accept
stdin, and teaching the -- escape for a literal hyphen. On a TTY,
literal - stays legal everywhere. --out - (attachments/files download)
is exempted as the stdout idiom.

Behavior changes:
- "comments create 123 - extra" — was a silent literal "- extra"
  comment, now a usage error.
- "-" with TTY stdin — was hang-until-Ctrl-D, now an immediate usage
  error teaching the escapes (pipe, heredoc, cat |, --edit where it
  exists). No new --stdin flag: - is the universal idiom, and --stdin
  in the wild means other things (git plumbing, kubectl).
- Bare-pipe auto-read removed from comments create and notes set: a
  pipe without - errors with a hint instead of being silently consumed.
  Pipes are only ever a source through an explicit -; an unclaimed pipe
  alongside a named source is ignored, the CLI-wide rule.
- "notes set -" (piped) — was a bogus two-source error, now works;
  "notes set --file -" — was ENOENT on a file named -, now stdin.
- Piped scripts passing literal - as a title/name/path now error; -- is
  the documented escape.
- Stdin content gets trailing newlines trimmed (Markdown doesn't care;
  titles and boost's 16-rune limit do).

Agent help auto-documents each command's stdin inputs from the
allow_dash annotation; SKILL.md generalizes the - idiom it previously
over-promised.
Copilot AI balanced review requested due to automatic review settings August 19, 2026 03:46
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Aug 19, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Standardizes explicit - stdin handling across content-bearing CLI commands and rejects ambiguous stray dashes.

Changes:

  • Adds shared stdin resolution and command-tree guard logic.
  • Enables stdin for supported positional arguments and flags.
  • Adds unit, integration, E2E, agent-help, and skill documentation updates.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 29 out of 29 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
skills/basecamp/SKILL.md Documents stdin conventions.
internal/stdinarg/stdinarg.go Adds annotations and pipe detection.
internal/stdinarg/stdinarg_test.go Tests shared stdin utilities.
internal/commands/stdin.go Implements resolution and dash guard.
internal/commands/stdin_test.go Tests resolver behavior.
internal/commands/stdin_integration_test.go Tests request-level stdin handling.
internal/commands/dash_guard_test.go Tests central guard behavior.
internal/commands/api.go Supports stdin JSON bodies.
internal/commands/attachments.go Exempts stdout output syntax.
internal/commands/boost.go Supports positional stdin content.
internal/commands/cards.go Supports card content stdin.
internal/commands/chat.go Supports chat content stdin.
internal/commands/checkins.go Supports answer content stdin.
internal/commands/comment.go Makes comment stdin explicit.
internal/commands/comment_test.go Updates comment stdin tests.
internal/commands/commands_test.go Installs guard in test tree.
internal/commands/files.go Supports document/upload content stdin.
internal/commands/gauges.go Supports description stdin.
internal/commands/helpers.go Removes obsolete pipe reader.
internal/commands/messages.go Supports message body stdin.
internal/commands/notes.go Makes note stdin explicit.
internal/commands/notes_test.go Tests explicit note sources.
internal/commands/projects.go Supports description stdin.
internal/commands/schedule.go Supports schedule description stdin.
internal/commands/templates.go Supports template description stdin.
internal/commands/todolists.go Supports todolist description stdin.
internal/commands/todos.go Supports todo content and flag stdin.
internal/cli/root.go Installs guard and generates agent notes.
e2e/stdin_dash.bats Exercises CLI stdin behavior.
Suppressed comments (2)

internal/commands/stdin.go:203

  • The documented -- escape cannot preserve a literal - used as a flag value. For example, with piped stdin, todos update 1 --title - is rejected, but moving - after -- makes it positional rather than the value of --title; --title=- is still detected by this guard. Please either define a workable flag-value escape (and test it) or avoid rejecting flag values, rather than directing users to an impossible invocation.
		hint := `For a literal "-", pass it after the -- separator`

internal/commands/stdin.go:191

  • Changed alias flags that share one destination are double-counted from their final value. For example, templates update 1 --description text --desc - leaves both flag values reporting -, so this increments allowed twice and rejects the invocation even though only one dash was supplied. Count actual dash occurrences or model aliases as one input before enforcing the one-reader rule.
		if allow.Flag(f.Name) {
			allowed += dashes

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/commands/stdin.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b81a070f07

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Comment thread internal/commands/stdin.go Outdated
Four fixes to the dash guard's contract from review:

- Run the guard at Args-validation time instead of wrapping RunE. Cobra
  runs ValidateArgs after flag parsing (Changed and ArgsLenAtDash are
  available) but before the persistent pre-run chain, PreRunE, and
  required-flag validation — so the stray-dash error fires before any
  lifecycle side effect (config hardening, the update check) and before
  a competing usage error can shadow it. The root command stays
  unwrapped: its nil Args is load-bearing — cobra's Find() rejects
  unknown subcommands (legacyArgs) only while Args == nil, and wrapping
  it turned "basecamp unknowncmd" into a quickstart run (caught by
  core.bats). Nothing is lost: root positionals are subcommand names,
  and a bare "basecamp -" runs quickstart, which posts no content.

- Dedupe alias flags by their shared pflag.Value. --description and
  --desc wrap one backing variable, and pflag hands both the same Value
  instance; counting each spelling separately made
  "--description old --desc -" a false "two stdin inputs" error.
  One logical value now counts once, in both flag orders.

- Stop advertising -- as the escape for flag values — it only escapes
  positionals. Positional offenders keep the -- hint; flag offenders
  get the honest remedy (run without piped stdin, append </dev/tty).
  SKILL.md updated to match.

- Trim trailing CRLF, not just LF, from stdin content: a Windows-style
  pipe left \r behind, counting a phantom rune against boost's
  16-rune limit.

Also replace the weak final e2e case (which contradicted the file's
no-network header by dialing localhost) with a deterministic local
success: config set ... -- - stores a literal "-", read back via
config show.
Copilot AI review requested due to automatic review settings August 19, 2026 05:17
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the advisory in cfb9959. Per finding:

1 (guard timing) — fixed. The guard now wraps each runnable command's Args validator instead of RunE: cobra runs ValidateArgs after flag parsing (so Changed/ArgsLenAtDash are live) but before the persistent pre-run chain, PreRunE, and required-flag validation, so the stray-dash error fires before any lifecycle side effect or competing usage error. One discovery en route: the root's nil Args is load-bearing — cobra's Find() applies legacyArgs (unknown-subcommand rejection) only while Args == nil, and wrapping the root turned basecamp unknowncmd into a successful quickstart run (core.bats caught it). The root stays unwrapped, losing nothing: its positionals are subcommand names, and a bare basecamp - runs quickstart, which posts no content. New test pins the ordering (guard beats ExactArgs, PreRunE, and MarkFlagRequired).

2 (alias false-positive) — fixed. pflag hands aliases sharing a backing variable the same Value instance, so the guard now dedupes on Value identity: --description old --desc - is one logical stdin input (reads stdin), and --desc - --description old resolves to the literal old. Both orders tested; covers schedule, templates, and every other alias pair for free.

3 (impossible escape) — fixed. --name=- can't be the explicit-literal form — the guard sees only the parsed value, and special-casing the = spelling would need re-scanning os.Args. So the hint is now honest per offender kind: -- is mentioned only for positional offenders; flag offenders get the real remedy, run without piped stdin (</dev/tty). SKILL.md matches.

4 (newlines) — CRLF fixed; the per-input trim policy declined. Trailing \r\n is now trimmed alongside \n (test: a 16-rune boost followed by CRLF passes). But I'm keeping the uniform trailing-newline trim rather than classifying inputs as body-like vs title-like: only trailing newlines are touched (interior breaks preserved, so chat's text/plain "line breaks preserved" promise holds), Markdown→HTML conversion makes trailing newlines invisible for every rich-text body, and a per-site trim knob across ~30 call sites buys correctness only for the case of a chat message whose trailing blank lines are deliberate — which a trailing newline in a pipe almost never is. The uniform rule is also what SKILL.md documents. Happy to revisit if a real case surfaces.

5 (weak e2e) — fixed. The final case is now a deterministic local success — printf 'x' | basecamp config set project_id --json -- - stores a literal -, read back via config show — and the file header's no-network claim is now true.

bin/ci green end to end after the changes.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 29 out of 29 changed files in this pull request and generated no new comments.

Suppressed comments (3)

internal/commands/messages.go:457

  • This new stdin path still accepts extra positionals silently. For example, printf body | basecamp messages create Title - unexpected reads stdin and posts the message while dropping unexpected, even though Use declares only <title> [body]. Add a maximum-argument validator so malformed stdin invocations fail instead of losing input.
			// Validate user input first, before checking account. The --edit
			// exclusion runs before "-" resolution so --edit … - errors
			// without consuming stdin.

internal/commands/cards.go:875

  • Extra positionals remain silently ignored on the new stdin path: printf body | basecamp cards create Title - unexpected consumes stdin but drops unexpected. Since this command declares exactly <title> [body], cap it at two arguments before resolving -.
				var err error
				content, err = resolveContentValue(cmd, args[1], 1, "[body]")
				if err != nil {
					return err

internal/commands/files.go:1252

  • The new - resolver only examines args[1], so printf body | basecamp docs create Title - unexpected succeeds and silently discards unexpected. Enforce the two positionals declared by Use before consuming stdin.
				var contentErr error
				content, contentErr = resolveContentValue(cmd, args[1], 1, "[content]")
				if contentErr != nil {
					return contentErr
				}

The TTY hint for a flag-borne "-" suggested a bare trailing "-", which
would exceed the command's positional arity — it now repeats the flag
("api post ... --data -").

messages/cards/docs create took unbounded positionals, so a stray third
token was silently dropped after "-" had already drained stdin. All
three now bound at MaximumNArgs(2), which runs before the read; the
other exact-positional consumers were already bounded.

Cobra's arity errors classified as api_error, telling agents to retry a
call that can never succeed. They are usage errors by construction.

Drop the concrete </dev/tty redirect from the literal-dash hint: it is
unusable on Windows and on headless runners with no controlling
terminal. The remedy stays, minus the platform-specific spelling.
Copilot AI review requested due to automatic review settings August 19, 2026 20:24
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round 2 addressed in b8f09d7. Both mediums accepted, the low-priority tightening taken, plus one adjacent fix the arity bound exposed.

1. TTY hints are invalid for flag-based stdin — fixed.

stdinEscapeHint now takes the what it was given and repeats the input that actually carried the -. Verified against your exact repro:

$ basecamp api post /foo --data - --json </dev/null
"hint": "Pipe the content (printf '...' | basecamp api post ... --data -), use a heredoc
         (basecamp api post ... --data - <<'EOF'), or run cat | basecamp api post ... --data -
         and type the content, ending with Ctrl-D"

Positionals are unchanged (... -). Covered by a unit test that asserts the flag spelling is present and that a bare ... -) is absent, plus an integration test driving api post --data - through a real Execute with a transport that fails the test if any request escapes. Closes r3809916059.

2. Exact-positional consumers discard extra arguments — fixed, and the class is closed.

MaximumNArgs(2) on messages create, cards create, docs create. I audited every positional resolveContentValue call site rather than just the three you named: boost create (ExactArgs(2)), chat post (MaximumNArgs(1)), chat update (MaximumNArgs(2)), notes set (MaximumNArgs(1)) were already bounded. Those three were the whole remainder.

Validation-before-consumption is proven, not asserted: the test wires stdin to a reader that records whether Read was ever called and the SDK to a transport that counts calls, then runs create Title - unexpected on all three and asserts the arity error, read == false, and zero requests. The ordering holds structurally too — cobra runs ValidateArgs (guard wrapper → original validator) before RunE, so resolveContentValue is unreachable.

3. </dev/tty — removed.

Right; it is wrong on Windows and on headless runners with no controlling terminal. The hint is now For a literal "-" flag value, run the command without piped stdin, and SKILL.md matches. Kept the shape of the remedy, dropped the platform-specific spelling.

One adjacent fix, flag it if you want it split out. Adding the arity bound surfaced that cobra's arity errors were classified api_error (exit 7):

$ printf body | basecamp messages create Title - unexpected --json
{"ok": false, "error": "accepts at most 2 arg(s), received 3", "code": "api_error"}

That tells an agent to retry a call that can never succeed — the opposite of what this PR is for. transformCobraError already rewrites the received 0 case to a usage error; I extended it to the rest of the arity family, keeping cobra's wording (already clear) and fixing only the code. Now usage / exit 1. This is pre-existing and affects other commands too (chat post a b had the same envelope), so it is a behavior change beyond the stated scope — say the word and I will lift it into its own PR.

bin/ci green.

On the Go bump: agreed it is unrelated to this feature and belongs on main, not here. Worth knowing before someone attempts it as a one-liner: everything derives from go-version-file: go.mod, so the pin itself is one line — but the nix-build job exists precisely to catch a go.mod bump outpacing flake.lock (see the comment at test.yml:481, written after #533 did exactly that), so the change is go.mod + a nixpkgs carrying 1.26.6 + make update-nix-hash. Not mine to land from this branch; I will open it separately on request.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated no new comments.

Suppressed comments (3)

internal/commands/chat.go:790

  • The update path has the same silent stdin loss: printf intended | basecamp chat update 123 literal --content - chooses the positional value and never consumes the explicitly requested stdin input. Reject simultaneous positional content and --content before choosing the source.
			if len(args) > 1 {
				messageContent = args[1]
				argIndex, what = 1, "[content]"

internal/commands/chat.go:327

  • When a positional message and --content - are both provided, this branch silently wins and the explicit stdin source is never read. For example, printf intended | basecamp chat post literal --content - passes the guard (there is only one -) but posts literal, discarding the pipe. Reject the two content sources together before selecting one.

This issue also appears on line 788 of the same file.

			if len(args) > 0 {
				messageContent = args[0]
				argIndex, what = 0, "<message>"

internal/commands/stdin.go:196

  • Deduplicating shared flag values here can misname the offending alias because VisitAll is alphabetical, not invocation order. For example, --in old --project - leaves both aliases changed with the shared value -, but --in is visited first and reported even though --project carried the dash. Preserve/report the changed alias group so the diagnostic does not identify the wrong flag.
	// Alias flags (--description/--desc) share one backing value, and pflag
	// hands each alias the same Value instance — dedupe on it, or a value set
	// through both spellings would count as two stdin inputs.
	seen := map[pflag.Value]bool{}
	cmd.Flags().VisitAll(func(f *pflag.Flag) {
		if !f.Changed || seen[f.Value] {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8f09d7a7d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/gauges.go Outdated
Comment thread skills/basecamp/SKILL.md Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 20:42

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 42b536095f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/stdin.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 30 out of 30 changed files in this pull request and generated 1 comment.

Comment thread internal/commands/stdin.go Outdated
…docs path

- Resolver.IsInteractive now requires stdin to be a character device too:
  a Bubble Tea picker reads keystrokes from stdin, so piped stdin can never
  drive one — and when a command is consuming piped content (a "-" stdin
  input), a picker would eat that content as key events. This closes the
  ordering hazard for every picker at the mechanism rather than reordering
  each stdin-enabled RunE (gauges create, docs, cards, schedule, templates).
- chat post/update reject a positional message combined with --content
  instead of the positional silently winning; with "-" in play the losing
  source would discard piped content unread.
- SKILL.md and the docs-create example referenced 'docs create', which does
  not exist; the registered path is 'docs documents create'.
Copilot AI review requested due to automatic review settings August 19, 2026 21:10
@github-actions github-actions Bot added the tui Terminal UI label Aug 19, 2026
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the body-level (suppressed) findings from the Copilot review rounds in 9310135:

  • chat post/update dual content sources — fixed: a positional message combined with --content is now a usage error instead of the positional silently winning; with - in play the losing source would have discarded piped content unread. Unit-tested. (The messages/cards/docs extra-positional arity findings were already fixed in b8f09d7's bounded create arity.)
  • Alias diagnostic may name the other spelling — not doing this: when both spellings of one aliased flag carry the dash, the guard names whichever alias VisitAll reaches first. Both names point at the same logical input the user just typed, so the diagnostic still identifies the right thing to fix; tracking invocation order through pflag to fix a cosmetic corner isn't worth the machinery.

Also in this round: pickers are now gated off when stdin is piped (mechanism fix for the read-ordering finding — details in that thread), and the docs create references now use the registered docs documents create path.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 33 out of 33 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/commands/stdin.go:147

  • The runnable root is the one command this guard skips, so printf x | basecamp - still bypasses the stated tier-2 rule. On a first run with terminal stdout, RunQuickStartDefault considers the app interactive (that check does not inspect stdin) and can start the setup wizard, which then consumes the pipe as keystrokes—the failure this PR is intended to prevent. Keep the root's Args nil for Cobra's unknown-command handling, but guard its RunE (or add an equivalent pre-execution root check) so an unescaped piped - is rejected while basecamp -- - remains literal.
	skipRoot := root.Args == nil && !root.HasParent() && root.HasSubCommands()

The root skip's justification held for quickstart's summary but missed the
first-run wizard: isFirstRun gates on App.IsInteractive, which only looked
at stdout, so piped stdin plus a terminal stdout on a first run launched
the wizard reading the pipe as keystrokes.

Extract the stdout+stdin character-device check into
stdinarg.InteractiveStdio — the second copy of this logic — and use it from
both App.IsInteractive and resolve.Resolver.IsInteractive. Every TUI gate
(wizard, pickers, animations, update notice) now takes its non-interactive
path when either end of stdio is piped.
Copilot AI review requested due to automatic review settings August 19, 2026 22:29
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Addressed the suppressed finding from the latest Copilot review in 1322a36:

First-run wizard could consume piped stdin — the real failure confirmed and fixed at the mechanism: isFirstRun gates on App.IsInteractive, which only inspected stdout, so a first run with piped stdin and terminal stdout launched the setup wizard reading the pipe as keystrokes (with or without a - argument). The stdout+stdin character-device check is now extracted to stdinarg.InteractiveStdio — this was the second copy of the same logic — and both App.IsInteractive and Resolver.IsInteractive use it, so every TUI gate (wizard, pickers, animations) takes its non-interactive path when either end of stdio is piped. Unit-tested; the root-skip comment in stdin.go now records why the skip is safe.

Not doing the suggested root RunE dash guard — with the wizard gated, printf x | basecamp - falls through to the non-interactive quickstart summary, which posts no content and reads nothing: the stray dash is inert rather than dangerous. Rejecting it would add a bespoke root-only guard for a case with no failure left in it; the tier-2 rule exists to stop content corruption and pipe consumption, both of which are now impossible on this path.

…red arity

chat post/update let a positional and an explicit --content coexist, with
the positional silently winning — so --content - dropped the flag and left
the pipe unread. Both now reject two explicit sources.

Seven commands resolved "-" after account or project work, so a stdin
mistake surfaced as "--account is required" instead of the stdin hint.
The order is now local validation, then stdin, then account/network at
every resolveContentValue site; an audit script confirms none remain.

Long help and SKILL.md taught "basecamp docs create", which resolves to
the docs group and exits 0 showing help. The real path is
"docs documents create". A new test resolves every help example through
Find and fails when a group swallows a leftover subcommand name.

transformCobraError matched arity text anywhere in any error, flattening
typed errors that merely quoted the phrase. It now returns typed errors
untouched and anchors on cobra's exact arity formats.

The guard named one alias of a shared value, reporting --in for a caller
who wrote --project. Parsed state cannot say which spelling was typed, so
the error names the group (--in/--project).
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Round 3 in 6a98dd7, on top of 9310135 and 1322a36. All five accepted; two notes on how findings 1–3 landed, since they were fixed twice in parallel and the resolution matters.

First, the CI blockers are gone. Your review was against b8f09d7; the branch is now at 6a98dd7. main picked up toolchain go1.26.7 in #638 and the branch merged it (42b5360). Security and Trivy Security Scan both pass — all 23 checks green, mergeStateStatus: CLEAN. No separate Go PR is needed.

Worth recording why the separate PR you asked for would not have worked as specified: bumping the go directive to 1.26.6 is not executable. nixpkgs nixpkgs-unstable and master both still ship Go 1.26.5, so there is no flake.lock bump that carries 1.26.6, and I reproduced the consequence — under GOTOOLCHAIN=local with 1.26.5, a go 1.26.6 directive hard-fails (go.mod requires go >= 1.26.6). That is exactly the nix-build job. The toolchain line is the form that works: setup-go v7 and Trivy 0.70 both prefer it (Trivy's toolchainVersion() falls back to the go line only when it is absent), while the nix build reads only the go directive. That is the shape #638 shipped.

1 (chat) and 3 (docs path) — fixed in 9310135, not by me. I had written both independently; on rebase I took yours and dropped mine. I kept one addition: a test that also asserts the pipe stays unread and zero transport calls when the dual-source error fires, and covers chat update as well as post — the silent-precedence bug's real damage was the discarded pipe, not just the ignored flag.

2 — your mechanism fix and the reorder are complementary, not alternatives. 9310135 says gating IsInteractive closes this "at the mechanism rather than reordering each stdin-enabled RunE". It closes a hazard I had missed and is the better fix for that hazard — but it does not close the finding as reported. I built your commit in isolation and ran your own example:

$ ./bc-9310135 docs documents create T - --json </dev/null    # no account configured
{"ok": false, "error": "--account is required (or set account_id in config)", "code": "usage"}

The picker gate stops a TUI eating the pipe; it does not change which error the caller sees. So I did the reorder too — local validation → stdin → account/network — at all seven sites (cards update, docs create, gauges create/update, schedule create, templates update/construct). Same command now:

{"code": "usage", "error": "[content] is \"-\" (read from stdin) but nothing is piped"}

I found the seven by script rather than by your list, and the same script reports zero remaining out-of-order sites. A table test drives all seven with no account and a counting transport, asserting the stdin error, no "account" in the message, and zero requests.

3 — the guard you asked for is generic. Rather than a fixture for the one path, a test resolves every basecamp … invocation in every command's Long and Example through root.Find and fails when a group is left holding a leftover token that names a command. That is the discriminator: only a group swallows a mistyped subcommand and exits 0. Reverting the one-line doc fix makes it fail with the exact diagnosis, across all three alias trees:

basecamp docs documents create: "basecamp docs create ..." resolves to the "basecamp docs" group
with "create" left over — it exits 0 showing group help

Coverage is stated honestly in the test: only the leading run of bare lowercase words is resolved, and a leftover is reported only when it names a command somewhere — which is what keeps basecamp assignments due overdue (a literal argument that happens to collide with reports overdue) from false-positiving.

4 — tightened both ways, and wider than asked. The rule now anchors on cobra's exact formats (^accepts (\d+|at most \d+|between \d+ and \d+) arg\(s\), received \d+$). For typed errors I put the guard at the top of transformCobraError, not just on my rule: every other transform in that function matched on rendered text too, so an SDK error whose message contained invalid argument was equally exposed. Typed *output.Error and *basecamp.Error now return untouched. Negative tests cover both types plus unanchored text that merely quotes an arity phrase. Exit-code comment corrected to 7.

5 — group naming. --in/--project. Parsed state cannot say which spelling was typed, so the label lists every name bound to the shared pflag.Value; a solo flag still prints as --title with no slash, which is a second test.

bin/ci green, including all e2e.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/commands/stdin.go:150

  • The root exception leaves a runnable invocation outside the advertised tier-2 policy: with piped stdin, basecamp - reaches quick-start instead of returning the stray-- usage error (the comment below explicitly describes that path). This contradicts the PR’s “every runnable command”/“any unannotated -” behavior and still allows startup lifecycle work to run while silently ignoring the pipe. Please preserve root unknown-command handling with a root-specific validator while applying the dash guard, and add a regression for piped basecamp -.
	skipRoot := root.Args == nil && !root.HasParent() && root.HasSubCommands()
	if root.Runnable() && !skipRoot {

Copilot AI review requested due to automatic review settings August 19, 2026 22:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a98dd7ef5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/stdinarg/stdinarg.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/appctx/context.go:382

  • timeline --watch bypasses this helper and still starts Bubble Tea directly at internal/commands/timeline.go:587. As a result, printf x | basecamp timeline --watch still consumes the pipe as key events and can leave the watch TUI without interactive input, contrary to the new “pipes are never consumed implicitly” behavior. Gate that direct TUI entry point before launching it.
	// Both stdout and stdin must be character devices: a TUI draws to stdout
	// and reads keystrokes from stdin, so a pipe on either end can never
	// drive one — and when the command is consuming piped content (a "-"
	// stdin input), a TUI would eat that content as key events.
	return stdinarg.InteractiveStdio()

internal/commands/stdin.go:134

  • The PR description says the guard wraps every runnable RunE, but this implementation wraps Args so it runs before pre-runs and required-flag validation. Those lifecycle guarantees are materially different; update the description to match the implementation so reviewers and future maintainers do not rely on the obsolete ordering.
// InstallDashGuard wraps every runnable command in the tree with the tier-2
// dash guard. It wraps the Args validator: cobra runs ValidateArgs after flag
// parsing (so Changed and ArgsLenAtDash are available) but before the
// persistent pre-run chain, PreRunE, and required-flag validation — so a
// stray "-" is rejected before any lifecycle side effect (config hardening,

The profile picker runs from PersistentPreRunE and reads keystrokes, but
isInteractiveTTY only looked at stdout. With multiple profiles and no
default, "printf body | basecamp todos create -" opened the picker on a
terminal stdout and let it eat the piped body. It now uses the same
stdinarg.InteractiveStdio predicate as App.IsInteractive and the project
resolver — the third and last TUI-launch gate; the remaining
ModeCharDevice checks pick an output format and never read keys.

The root was skipped entirely by the dash guard, so a piped "basecamp -"
ran quick-start and ignored both the dash and the pipe. Its Args must stay
nil for cobra's unknown-command handling, so the guard hangs off RunE
there instead. Regressions cover all four root behaviors: piped dash
errors, -- keeps it literal, unknown commands still error, bare still
runs.
Copilot AI review requested due to automatic review settings August 19, 2026 22:49
@jeremy

jeremy commented Aug 19, 2026

Copy link
Copy Markdown
Member Author

Fixed — d73b2c3 addresses the one open review thread and the finding Copilot raised in both of its last two reviews.

Profile picker gated on stdin (Codex, internal/stdinarg/stdinarg.go:105). isInteractiveTTY stat-ed only os.Stdout, so with multiple profiles and no default, printf body | basecamp todos create - opened the picker from PersistentPreRunE and it consumed the piped body as keystrokes. It now uses the same stdinarg.InteractiveStdio predicate as App.IsInteractive and resolve.Resolver.IsInteractive.

Rather than fix just the reported site, I checked whether more remained: this was the third and last TUI-launch gate. Every other ModeCharDevice check picks an output format and never reads key events (output/render.go, output/envelope.go, cli/root.go's machine-output check, commands/helpers.go, update_notice.go), and the --edit guards in comment.go/messages.go already stat os.Stdin.

Root stray dash (Copilot, internal/commands/stdin.go:147 and :150). Correct, and reproducible — printf 'x' | basecamp - exited 0 running quick-start, ignoring both the dash and the pipe, which contradicted the tier-2 rule this PR advertises. Taking the suggested shape: the root's Args stays nil so cobra's legacyArgs keeps rejecting unknown subcommands, and the guard hangs off its RunE instead.

$ printf 'x' | basecamp - --json
{"ok": false, "code": "usage", "error": "basecamp does not read stdin via \"-\" for argument 1"}

RunE is later than Args validation, which is why every other command guards at Args — but the root's pre-run work (config hardening, the update check) neither reads stdin nor writes content, and its one TUI path, the first-run wizard, is now stdin-gated by the same predicate above. So nothing a stray - could corrupt runs first.

All four root behaviors are pinned by regressions, since they have to hold together: piped - errors, basecamp -- - stays literal, basecamp unknowncmd still reports an unknown command, and a bare basecamp still runs. Two of them are also e2e tests.

bin/ci green.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/commands/stdin.go:160

  • The root guard runs only in RunE, after PersistentPreRunE. That lets pre-run validation shadow the promised stray-dash error; for example, with piped stdin, basecamp --jq - fails jq parsing before guardDashArgs runs. Keep Args nil for Cobra's root lookup, but guard root execution at the start of its persistent pre-run (skipping inherited calls for subcommands), before config/profile/jq work.
		existing := root.RunE
		root.RunE = func(cmd *cobra.Command, args []string) error {
			if err := guardDashArgs(cmd, args); err != nil {
				return err
			}
			return existing(cmd, args)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands CLI command implementations skills Agent skills tests Tests (unit and e2e) tui Terminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants