Skip to content

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

Merged
jeremy merged 20 commits into
mainfrom
stdin
Aug 21, 2026
Merged

Stdin - support everywhere sensible; usage error for stray - elsewhere#641
jeremy merged 20 commits into
mainfrom
stdin

Conversation

@jeremy

@jeremyjeremy 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 covers every runnable command in the assembled tree (~380 commands, aliases and future ones included). On subcommands it wraps the Args validator: cobra runs ValidateArgs after flag parsing (so Changed and ArgsLenAtDash are live) but before the persistent pre-run chain, PreRunE, and required-flag validation — so a stray - is rejected before any lifecycle side effect and before a competing usage error can shadow it. It is not a PersistentPreRunE hook across the tree because cobra runs only the innermost one, and the agent hook already shadows the root's.

The root is the one exception: its Args must stay nil for cobra's legacyArgs unknown-subcommand check, so its guard hangs off the front of its own PersistentPreRunE, acting only when the root itself executes. That is still ahead of config loading, profile resolution and --jq validation.

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. Cobra's generated meta commands — help and the completion commands — are exempt too: they perform no Basecamp content write, and completion legitimately receives - as the word being completed (basecamp todos create -<TAB> runs basecamp __complete todos create -), so guarding it would break flag completion across the CLI.

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 installs a guard that errors on stray "-" when stdin is piped, preventing pipes from becoming literal content. Also fixes jq-backed output to avoid double writes on filter errors and classifies Cobra arity errors as usage.

  • Coverage: accepts "-" on content positionals (comments create/update; check-ins answer create/update; todos create; messages/cards/docs create body; chat post/update; boost create; notes set) and content flags (--data, --body, --content, --description, --comment, --file). --out - (download) keeps the stdout idiom. help and __complete are exempt; agent --help autolists stdin-capable inputs via stdinarg.
  • Guard and ordering: runs at Args-validation time, rejects two stdin consumers and empty stdin, and dedupes alias flags; the root is guarded in pre-run. Stdin is read only when "-" is present. TUIs (wizards/pickers) now require character‑device stdin and stdout. An AST backstop enforces pre-read validation, and it now catches requireNumericID sites.
  • Pre-read validations hoisted: parse/validate IDs before any read (chat line/room; boost/event; check-ins; schedules; explicitly supplied dock IDs; uploads --folder ID), reject foreign API hosts early, and validate attachment paths first. Files replace recording-type, cards update attachment validation, schedule timestamps, and todos --loose vs list are decided before reading. Messages/cards/docs create bound at MaximumNArgs(2). Chat post/update reject combining a positional message with --content.
  • Behavior changes:
    • Bare pipes are no longer auto-read; stdin is consumed only when "-" is supplied.
    • "comments create 123 - extra" — was a silent literal "- extra", now a usage error.
    • "-" with TTY stdin — was hang-until-EOF, now an immediate usage error with hints.
    • Stdin content trims trailing CRLF/LF.
  • Required actions:
    • Pass "-" wherever stdin should be consumed; the CLI never reads stdin implicitly.
    • For a literal "-" positional, use "--" to escape it. For flags with a literal "-", run without piped stdin.

Written for commit a6bcf29. 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.
CopilotAI balanced review requested due to automatic review settings August 19, 2026 03:46
@github-actionsgithub-actionsBot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Aug 19, 2026

CopilotAI 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
FileDescription
skills/basecamp/SKILL.mdDocuments stdin conventions.
internal/stdinarg/stdinarg.goAdds annotations and pipe detection.
internal/stdinarg/stdinarg_test.goTests shared stdin utilities.
internal/commands/stdin.goImplements resolution and dash guard.
internal/commands/stdin_test.goTests resolver behavior.
internal/commands/stdin_integration_test.goTests request-level stdin handling.
internal/commands/dash_guard_test.goTests central guard behavior.
internal/commands/api.goSupports stdin JSON bodies.
internal/commands/attachments.goExempts stdout output syntax.
internal/commands/boost.goSupports positional stdin content.
internal/commands/cards.goSupports card content stdin.
internal/commands/chat.goSupports chat content stdin.
internal/commands/checkins.goSupports answer content stdin.
internal/commands/comment.goMakes comment stdin explicit.
internal/commands/comment_test.goUpdates comment stdin tests.
internal/commands/commands_test.goInstalls guard in test tree.
internal/commands/files.goSupports document/upload content stdin.
internal/commands/gauges.goSupports description stdin.
internal/commands/helpers.goRemoves obsolete pipe reader.
internal/commands/messages.goSupports message body stdin.
internal/commands/notes.goMakes note stdin explicit.
internal/commands/notes_test.goTests explicit note sources.
internal/commands/projects.goSupports description stdin.
internal/commands/schedule.goSupports schedule description stdin.
internal/commands/templates.goSupports template description stdin.
internal/commands/todolists.goSupports todolist description stdin.
internal/commands/todos.goSupports todo content and flag stdin.
internal/cli/root.goInstalls guard and generates agent notes.
e2e/stdin_dash.batsExercises 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 threadinternal/commands/stdin.go Outdated

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/stdin.go Outdated
Comment threadinternal/commands/stdin.go Outdated
Comment threadinternal/commands/stdin.go Outdated
Comment threadinternal/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.
CopilotAI review requested due to automatic review settings August 19, 2026 05:17
@jeremy

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI 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.
CopilotAI review requested due to automatic review settings August 19, 2026 20:24
@jeremy

Copy link
Copy Markdown
MemberAuthor

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.

CopilotAI 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-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/gauges.go Outdated
Comment threadskills/basecamp/SKILL.md Outdated
CopilotAI review requested due to automatic review settings August 19, 2026 20:42

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/stdin.go

CopilotAI 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 threadinternal/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'.
CopilotAI review requested due to automatic review settings August 19, 2026 21:10
@github-actionsgithub-actionsBot added the tui Terminal UI label Aug 19, 2026
@jeremy

Copy link
Copy Markdown
MemberAuthor

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.

@jeremy

Copy link
Copy Markdown
MemberAuthor

Fixed in 27b8615. You were right, and I had fixed the wrong path.

The defect. I patched the fallback writer at the bottom of Execute and reasoned about it as though it were the only place a second write could happen. It isn't: the retry is in app.Err, above it. jqUsable was never able to prevent this — as you say, it only answers what is knowable before output exists — and my comment asserting the property was describing a path that wasn't the one at fault. Reproduced exactly as reported.

The fix, taking the invariant you stated: once a jq-backed write has begun, stdout is final. When app.Err fails and a filter was in play, the render failure goes to stderr and the process exits with the original code — no replay. Without a filter nothing partial can have been written through one, so the plain fallback stays as the last resort for a broken pipe.

$ basecamp todos create --jq '.error, error("stop")' 2>/dev/null
<content> required # one document, exit 1
$ basecamp todos create --jq '.error, error("stop")' 2>&1 >/dev/null
error rendering error output through --jq: jq filter error: error: stop

I did not buffer. Your point that TTY state can be captured separately is fair and it is a workable design, but it means threading the destination's TTY-ness through output.Options so the sanitizer keeps working — a change to the output package's contract to fix a control-flow bug in Execute. Not replaying is the smaller correct change, and it is the invariant you named as the minimum. Say the word if you want atomic stdout instead and I will do the plumbing.

On the test. Your criticism was exact — it drove two writers directly and never reached Execute, so it asserted something true about the output package and nothing about the decision under review. Replaced with a predicate test for jqUsable (which is honestly all a unit test can cover here) plus an e2e case running the real binary that asserts stdout holds exactly one line. I verified it catches the regression: with the fix removed it fails on the line count, and passes with it restored.

jqUsable stays — it covers the other path, where no app exists yet because the error was raised before one was built — but its comment now describes only that path rather than implying a global property.

Scope note. The 110 uncommitted lines you saw were mid-round work; they landed as ac3f38e and e4d545d (the remaining pre-read validations, --attach paths across all eight commands, and two incomplete fixes of mine from the round before). Your verdict predates them, so they are unreviewed.

And agreed on io.ReadAll — it remains unbounded regardless of ordering, and a cap is a policy call rather than a review fix. Left for a separate decision.

bin/ci green, e2e now 11 cases.

CopilotAI 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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/cli/root.go:467

  • A valid filter can still fail at runtime on this no-app fallback path (for example, the dash guard can fail before app creation and .error, error("stop") writes once, then errors). The writer.Err result below is discarded, so unlike the app-backed path, that filter failure is never reported on stderr. Capture it and emit the same stderr diagnostic without retrying stdout.
 if jqFilter != "" && !jqUsable(jqFilter) {
jqFilter = ""
}

skills/basecamp/SKILL.md:119

  • This parenthetical overstates current behavior. Most required-content paths still use the generic missingArg/Cobra error; for example, piped todos create --json reports only <content> required plus usage and does not mention -. Only the explicitly updated comments/notes paths provide that hint, so either add the hint consistently or remove this claim.

CopilotAI review requested due to automatic review settings August 20, 2026 09:32

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:27b86152f5

ℹ️ 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 threadinternal/commands/cards.go
Comment threadinternal/commands/todolists.go
Comment threadinternal/commands/files.go
Comment threadinternal/commands/cards.go

CopilotAI 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 38 out of 38 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

internal/commands/chat.go:839

  • The target is still validated only after this stdin read (and after account/project resolution at lines 845-929). Thus producer | basecamp chat update nope - drains or can block forever on the producer before returning Invalid line ID, contrary to the PR's local-validation-before-stdin contract. Validate a bare line ID—and the host/type/collection shape for URL targets—before resolving the content; retain only account-dependent URL checks later.
 var contentErr error
messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what)
if contentErr != nil {
return contentErr
}

Comment threadinternal/cli/root.go Outdated
The AST check compared the first recognized check with the first read, so
one early check hid every later one — which is why the sweep kept looking
finished. It now flags every recognized check that follows the read, and
found twelve more sites immediately.
Dropped dateparse.Parse and isNumericID from the recognized set: neither
can fail, so they decide nothing and only report branch selection, which
would be answered with suppressions rather than fixes.
What it found: duplicate schedule timestamp and card column checks left
behind by earlier hoists, chat update's URL host and shape plus its line
and room IDs, and files replace's recording-type check. For the URL cases
only the account comparison genuinely needs a resolved account, so that
is all that still follows the read.
Semantic cases the AST cannot see, from the same review: cards update
never validated its attachments (the earlier sweep took one RunE per
file), schedule create discarded its ParseInt error and would have
created under schedule 0, and explicitly supplied dock IDs were parsed
only once a request was being built. getDockToolID returns an explicit
value verbatim, so requireNumericID settles them from the flag alone.
uploadAttachments now calls validateAttachPaths rather than duplicating
it, which is what its comment already claimed.
CopilotAI review requested due to automatic review settings August 20, 2026 10:18
@jeremy

Copy link
Copy Markdown
MemberAuthor

Fixed in 58ff6da. The instrument criticism was the important one and it was correct.

The backstop was comparing first-to-first.min(check) < min(read) means a single early check certifies the whole function, which is exactly why each round kept looking finished and then wasn't. It now flags every recognized check that follows the read. Re-running it immediately produced twelve violations across six commands — the sweep had never been close to complete, and the tool had been telling me it was.

I also removed two names from the recognized set rather than expanding blindly: dateparse.Parse and isNumericID cannot fail, so they decide nothing on their own. Listing them reports branch selection as if it were validation, and that noise gets answered with suppressions instead of fixes.

What the fixed check found, all now hoisted: duplicate schedule-timestamp and card-column checks left behind by my earlier hoists; chat update's URL host and shape plus its line and room IDs; files replace's recording-type check. For both URL cases only the account comparison genuinely needs a resolved account, so that alone still follows the read — the host and shape checks need just the configured base URL.

The semantic cases the AST cannot see, all confirmed and fixed:

  • cards update never validated attachments. My earlier --attach sweep took one RunE per file, so the second one in cards.go was skipped — the same first-match blindness in the script I used to find the sites.
  • schedule create discarded its ParseInt error and would have created under schedule 0. The create-side twin, as you said.
  • Explicit dock IDs were parsed only once a request was being built. getDockToolID returns an explicit value verbatim — there is no name resolution for these — so requireNumericID settles them from the flag alone. Applied to --schedule, --message-board, --folder, --todoset, and --card-table at the stdin-reading commands.

Eleven of these are pinned in the tracking-reader table (now 34 cases), which asserts stdin is left unread and no request is issued.

Factual correction accepteduploadAttachments duplicated the validation rather than calling validateAttachPaths, which is what its comment claimed. It calls it now.

One disclosure: this commit also carries a change I did not author. A concurrent edit in the worktree — terminal-injection sanitization for the stderr diagnostic I added last round, via jqRenderErrorDiagnostic — was swept in by a git add -A. It is a real hardening of my change (a jq runtime error can carry filter-selected response data), it is tested, and CI is green, but it is described by neither my commit message nor this comment's summary. Happy to split it into its own commit with a proper message on request.

bin/ci green.

CopilotAI 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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (2)

internal/commands/helpers.go:482

  • The existing applySubscribeFlags contract now precedes rejectSubscribeConflict, so Go associates all of it with the narrower helper even though that helper does not resolve subscriptions or return the documented pointer values. Keep the conflict helper's comment with it, then restore the contract directly above applySubscribeFlags.
// rejectSubscribeConflict answers the one part of applySubscribeFlags that
// needs neither the network nor an account, so callers that read stdin can
// settle it first: draining a pipe for an invocation this rejects makes the
// caller wait on a producer whose output is discarded, and lets a blank pipe
// answer "stdin is empty" instead of naming the conflict.

internal/commands/helpers.go:155

  • This block is attached to requireNumericID, but it starts by documenting getDockToolID; as a result the new helper has a misleading doc comment and getDockToolID loses the documentation immediately above it. Move the helper and its comment before the existing getDockToolID block.

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

// requireNumericID rejects an explicitly supplied dock or container ID that is
// not numeric. getDockToolID returns an explicit value verbatim — there is no
// name resolution for these — so the check needs neither an account nor the
// network, and commands that also read a "-" input run it before the read
// rather than discovering it after a request is already being built.

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:58ff6da8df

ℹ️ 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 threadinternal/commands/cards.go
uploads create and the top-level upload shortcut read --description -
before rejecting a malformed --folder, so a doomed invocation drained the
producer first. Both now validate it before the read.
Two backstop corrections, without which that class stays invisible:
requireNumericID was not in the recognized set, so moving one after a
read reported nothing — verified by doing exactly that, which now fails
the check. And urlarg.IsURL is out: like isNumericID it only selects a
branch and cannot reject an invocation, so listing it reports branch
selection as validation.
requireNumericID was also sitting inside getDockToolID's doc comment,
orphaning half of it. Moved above the block.
CopilotAI review requested due to automatic review settings August 21, 2026 00:58
@jeremy

Copy link
Copy Markdown
MemberAuthor

Fixed in a6bcf29. Not splitting 58ff6da — agreed, and the disclosure is now in the PR description under "Commit note" so it survives the comment thread scrolling past.

The upload hole, both constructors.uploads create and the top-level upload shortcut both validate --folder before the read now. Tracking-reader cases added for each, and I checked they bite: removing either check fails its case.

Both backstop corrections, and the second one matters more than it looks.

requireNumericID is now recognized. Your framing was exactly right — without it, moving one after a read reports nothing, which is a worse failure than the original bug because the check still appears to exist. I verified by doing precisely that rather than by deleting the call:

files.go: reads stdin at files.go:894:23 but checks its arguments at files.go:898:14
— hoist the check above the resolver

urlarg.IsURL removed. Same reasoning as isNumericID: it selects a branch and cannot reject anything, so listing it reports branch selection as validation. That is the noise that gets answered with suppressions rather than fixes, which is how a backstop stops being one.

The doc commentrequireNumericID had been inserted between getDockToolID's doc block and its signature, orphaning the second half onto the wrong function. Moved above the block.

One note on your bin/ci run. The missing shim is not specific to actionlint: shellcheck and pwsh have the same problem in this environment, and the pwsh one is the interesting case because it fails as three test failures in installer.bats rather than as a missing-tool error. I hit it on this run and confirmed it reproduces on a stashed tree before assuming it was environmental. Prepending the install dirs gets a true green:

$HOME/.local/share/mise/installs/actionlint/1.7.12
$HOME/.local/share/mise/installs/shellcheck/0.11.0/shellcheck-v0.11.0
$HOME/.local/share/mise/installs/powershell/7.6.5

Full bin/ci green with those on PATH — 36 tracking-reader cases, all e2e.

CopilotAI 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 38 out of 38 changed files in this pull request and generated no new comments.

Suppressed comments (1)

internal/cli/root.go:859

  • The new self-documenting agent-help behavior is not covered by a test. Existing agent-help tests only assert argument metadata, so dropping this call, misrendering an arg:N+ token, or accidentally advertising the --out - stdout exemption as stdin would still pass. Please add focused agent-help assertions for a command with both positional and flag stdin inputs and for a download command whose --out annotation must be omitted.
	if note := stdinDashNote(cmd, info.Args); note != "" {
info.Notes = append(info.Notes, note)

@jeremy
jeremy merged commit 8ce0854 into mainAug 21, 2026
26 checks passed
@jeremy
jeremy deleted the stdin branch August 21, 2026 07:22
@jeremy

Copy link
Copy Markdown
MemberAuthor

Note for anyone arriving from a notification: this PR was squash-merged as 8ce0854 with eleven review threads still open. All eleven are now answered and resolved.

Eight described code that later commits on this branch had already changed — they were reviewing a snapshot from earlier in the round. I verified each against the merged tree rather than assuming, and each is pinned in the tracking-reader table so it cannot regress silently.

Three were genuinely still live and are fixed in #645:

  • comments create with an all-invalid target argument
  • an explicitly blank --subscribe ""
  • cards update --due accepting an unparseable date

Two more orderings that were already correct but unpinned — chat update --room and cards create --card-table — got tracking-reader cases there too.

jeremy added a commit that referenced this pull request Aug 22, 2026
…or-roundtrip
* origin/main: (96 commits)
ci: bump the github-actions group with 6 updates (#639)
Reject three more doomed invocations before draining stdin (#645)
Stdin `-` support everywhere sensible; usage error for stray `-` elsewhere (#641)
Add hey-cli Windows signing secrets to the release env manifest (#642)
deps: bump the go-dependencies group with 5 updates (#638)
Update nix flake and plugin version for v0.9.1
ci: bump the github-actions group with 4 updates (#633)
Add basecamp files replace: publish a new version of an uploaded file (#634)
Add basecamp files versions — HELD, blocked on the SDK (#622)
Update nix flake and plugin version for v0.9.0
Make the Codex probe's timeout actually bound doctor (#629)
Make the lockstep check catch stale agreement and .yaml workflows (#628)
Keep refreshing opencode's other spelling (#627)
Lint the release the same way we lint everything else (#625)
Install the skill where opencode actually looks (#624)
Take the communiques out of the source tree (#623)
Correct the API coverage claim: 183/184, not 100% (#621)
Stop echoing back step fields the caller never changed (#620)
Drive the circuit breaker's clock from tests, not sleep() (#619)
Tell agents the truth about card column moves (#618)
...
jeremy added a commit that referenced this pull request Aug 22, 2026
readStdinContent was a bare io.ReadAll with no cap, and all 36 "-"-accepting
call sites funnel through it. `yes | basecamp api post /valid --data -` is a
perfectly valid invocation that read until the process died — which is why the
recent pre-read ordering work (#641, #645) did not touch it: nothing here is a
doomed invocation to reject early, the read itself was unbounded.
Refuse rather than truncate. Silently posting the first megabyte would write
partial content to Basecamp and report success, and a note or message is
unrecoverable once saved. The cap counts bytes read, so it lands before the
trailing-newline trim: an overflow that is only a trailing "\n" is still a
refusal, because telling it apart from any other overflow would mean reading
past the cap.
`notes set --file <path>` was the file twin of the same read — a bare
os.ReadFile — so it gets the same bound and the same error shape. Which side of
the "-" the bytes arrive on no longer changes whether they are accepted.
Worth naming: `boost create -` now stops at 1 MiB before its 16-rune check,
instead of reading an unbounded stream and copying it to a string in order to
reject 16 characters.
maxStdinContent is declared alongside the stdin machinery rather than reusing
maxAgentHookInput. Same value today, different purpose — one bounds a JSON
envelope an agent harness writes, the other bounds prose a person pipes — and
they should stay free to move apart.
The reads left alone are internal/editor/editor.go and the TUI composer: both
read back what the user's own $EDITOR just wrote, which is not a streaming
source.
@jeremyjeremy added the breaking Breaking change label Aug 31, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breakingBreaking changecommandsCLI command implementationsskillsAgent skillstestsTests (unit and e2e)tuiTerminal UI

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@jeremy