Conversation
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.
There was a problem hiding this comment.
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 incrementsallowedtwice 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.
There was a problem hiding this comment.
💡 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".
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.
|
Addressed the advisory in cfb9959. Per finding: 1 (guard timing) — fixed. The guard now wraps each runnable command's 2 (alias false-positive) — fixed. pflag hands aliases sharing a backing variable the same 3 (impossible escape) — fixed. 4 (newlines) — CRLF fixed; the per-input trim policy declined. Trailing 5 (weak e2e) — fixed. The final case is now a deterministic local success —
|
There was a problem hiding this comment.
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 - unexpectedreads stdin and posts the message while droppingunexpected, even thoughUsedeclares 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 - unexpectedconsumes stdin but dropsunexpected. 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 examinesargs[1], soprintf body | basecamp docs create Title - unexpectedsucceeds and silently discardsunexpected. Enforce the two positionals declared byUsebefore 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.
|
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.
Positionals are unchanged ( 2. Exact-positional consumers discard extra arguments — fixed, and the class is closed.
Validation-before-consumption is proven, not asserted: the test wires stdin to a reader that records whether 3. Right; it is wrong on Windows and on headless runners with no controlling terminal. The hint is now One adjacent fix, flag it if you want it split out. Adding the arity bound surfaced that cobra's arity errors were classified That tells an agent to retry a call that can never succeed — the opposite of what this PR is for.
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 |
There was a problem hiding this comment.
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--contentbefore 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 postsliteral, 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
VisitAllis alphabetical, not invocation order. For example,--in old --project -leaves both aliases changed with the shared value-, but--inis visited first and reported even though--projectcarried 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] {
There was a problem hiding this comment.
💡 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".
There was a problem hiding this comment.
💡 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".
…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'.
|
Addressed the body-level (suppressed) findings from the Copilot review rounds in 9310135:
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 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7aeaeddb8b
ℹ️ 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".
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 37 out of 37 changed files in this pull request and generated no new comments.
Suppressed comments (5)
internal/cli/root.go:450
- Pre-parsing cannot prevent runtime jq failures. A valid filter such as
.data, error("stop")passesjqUsable,writeJQemits the first result and then returns an error, and the existing fallback appends an unfiltered error envelope. Buffer jq output until iteration succeeds, or otherwise prevent the fallback once filtered output may have been written.
// An unusable filter must not swallow the error: --jq is validated in
// the pre-run, so an error raised *before* that check would otherwise
// be rendered through an unparseable filter and exit non-zero having
// printed nothing. Decide here instead of retrying after a failed
// write — writeJQ streams each result as it produces it, so a filter
// that fails partway (".error, error(\"stop\")") has already written,
// and a second pass would emit two incompatible envelopes.
if jqFilter != "" && !jqUsable(jqFilter) {
jqFilter = ""
internal/commands/chat.go:344
chat updatevalidates--content-typebefore reading stdin, butchat postdoes not. Thuschat post - --content-type bogusdrains the pipe and proceeds toward a request with an unsupported content type instead of failing immediately; apply the same vocabulary check before this resolver.
var err error
messageContent, err = resolveContentValue(cmd, messageContent, argIndex, what)
internal/commands/schedule.go:644
- The locally decidable
--starts-at/--ends-atchecks still occur after this stdin read (and after account/project resolution).schedule update 1 --starts-at bogus --description -therefore drains or blocks on the pipe before returning the timestamp usage error. Move both timestamp validations ahead ofresolveContentValue, as the create path already does.
// Syntactic checks first, then "-", then account and network.
description, err = resolveContentValue(cmd, description, -1, "--description")
internal/commands/cards.go:876
- The
--column <name>/missing--card-tableconflict is fully knowable from flags, but it is checked only after this stdin read and account setup. Consequentlycards create Title - --column Backlogconsumes the producer before returning the usage error. Hoist that conflict check aboveresolveContentValue.
var content string
if len(args) > 1 {
var err error
content, err = resolveContentValue(cmd, args[1], 1, "[body]")
internal/commands/todos.go:1578
- Due/start date validation remains below this stdin read and account resolution. For example,
todos update 1 --due not-a-date --description -drains the pipe before returningInvalid due date, contrary to the fail-fast ordering introduced here. Parse and validatedue/startsOnbefore resolving the description.
--attach paths are readable or not regardless of the body, so all eight
commands that take both now check them before the read, via a shared
validateAttachPaths that uploadAttachments also calls.
Six more local checks moved ahead of the read: a named --column without
--card-table, an untrusted host in a files replace URL (the account
half still follows, as with the API path split), schedule update's
timestamp formats, and cards/schedule attachments.
Two were incomplete fixes from the previous round rather than new sites.
The --loose gate read only the local --list flag, so a global --todolist
still reached the destination check after the pipe was drained. And files
update rejected an unknown --type early but not --content against a
folder — which is decidable from Changed("content") alone, without the
resolved value the switch below needs.
chat post never got the --content-type check its update sibling has, and todos update validated --due/--starts-on after the read. Both now precede it; the parsed dates are carried forward rather than re-derived. The interactivity tests stubbed only stdout, so their interactive baseline depended on the ambient stdin — now that both streams are checked, a piped stdin fails them. Extracted stubCharDeviceStdio, which points both at /dev/null. Note the exposure is not reachable through 'go test', which gives the test binary its own stdin; running the compiled binary with a piped stdin is what reproduces it, and does: the old baseline fails there and the new one passes.
|
Processed the remaining review bodies. Three of the five suppressed comments were live; two were already fixed in ac3f38e (
Both interactivity tests now stub stdout and stdin through a shared
I did not buffer, as noted before: the terminal-injection sanitizer is TTY-gated on the writer, so buffering would silently disable escape stripping.
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.
Suppressed comments (4)
internal/commands/cards.go:1119
cards updateaccepts--attach, but unlike the create path it resolves and drains--body -before checking whether attachment paths are readable. Thusproducer | basecamp cards update 1 --body - --attach /missingwaits for/consumes the producer and only then reports the already-known file error, contrary to the fail-before-stdin behavior added for attachment-bearing commands. ValidateattachFilesbefore resolving the body.
content, err := resolveContentValue(cmd, content, -1, "--body")
internal/commands/chat.go:830
- The target line is not validated until after this stdin read (the
ParseInt/URL checks are at lines 843–925). Consequently,producer | basecamp chat update nope -drains the entire producer before returningInvalid line ID; malformed or foreign URLs do the same. Hoist the locally decidable line-reference validation ahead ofresolveContentValue, as the other stdin-enabled update commands do.
case "", "text/html", "text/plain":
skills/basecamp/SKILL.md:119
- This claim is broader than the implementation. For example,
printf x | basecamp todos create --jsonreturnsmissingArg, whose structured hint is only the usage line; check-in answers and boost have the same behavior. Onlycomments createandnotes setexplicitly detect a bare pipe and teach-, so agents following this skill will not receive the promised guidance elsewhere. Narrow the statement to those commands (or add the hint consistently).
internal/commands/todos.go:1584 - Due/start-date validation still occurs after this stdin read at lines 1606–1618. An invocation such as
producer | basecamp todos update 1 --description - --due nonsensetherefore drains the producer before returning the deterministic invalid-date error. Parse and validate both date flags before resolving--description -, while retaining the parsed values for the request.
There was a problem hiding this comment.
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/cards.go:1116
- Validate
attachFilesbefore resolving--body.cards update 1 --body - --attach /missingcurrently drains (and can block on) stdin, then fails inuploadAttachmentson a path that was already known to be unreadable. This is the one attachment-capable stdin command missing the new pre-read validation.
content, err := resolveContentValue(cmd, content, -1, "--body")
internal/commands/chat.go:836
- Validate the target before reading stdin. The existing line-reference checks remain below this resolver, so
chat update nope - --room 1drains the producer before returningInvalid line ID; invalid or untrusted chat-line URLs do the same. Move the local URL/line-ID validation ahead ofresolveContentValue, as the other stdin-enabled updates do.
var contentErr error
messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e4d545d200
ℹ️ 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".
| } | ||
|
|
||
| var contentErr error | ||
| messageContent, contentErr = resolveContentValue(cmd, messageContent, argIndex, what) |
There was a problem hiding this comment.
Validate chat update targets before draining stdin
Unlike the fixed chat post path, chat update still reads the entire message before validating the target URL, line ID, or explicit --room. For example, producer | basecamp chat update https://evil.example/123 - or producer | basecamp chat update nope - --room nope can block or exhaust memory before reaching the deterministic errors at lines 856-872 and 925-931, while an empty producer masks the target error as “stdin is empty.” Parse and validate these argument-only target values before resolving the content.
Useful? React with 👍 / 👎.
| if len(args) > 1 { | ||
| var err error | ||
| content, err = contentArgOrStdin(cmd, args[1:]) | ||
| content, err = resolveContentArg(cmd, args[1:], 1) |
There was a problem hiding this comment.
Reject all-invalid comment targets before reading content
When every comma-separated comment target is malformed, the invocation cannot create any comment, but this resolver drains stdin before extractIDs and the ParseInt loop at lines 1214 and 1254-1259. Thus producer | basecamp comments create nope - can block indefinitely or buffer an unbounded stream, and an empty producer reports the stdin error instead of the invalid target. Determine that at least one target is valid before consuming the content while preserving the existing partial-success behavior for mixed batches.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| var err error | ||
| body, err = resolveContentValue(cmd, body, 1, "[body]") |
There was a problem hiding this comment.
Validate explicit message-board IDs before reading the body
When --message-board is explicitly nonnumeric, this call drains the stdin body before the value is returned unchanged by getMessageBoardID and rejected by ParseInt at lines 523-530. For example, producer | basecamp messages create Title - --message-board nope --in 123 can block or exhaust memory before reporting the already-known invalid board ID, while an empty producer masks it as “stdin is empty.” Validate an explicitly supplied board ID before resolving the body.
Useful? React with 👍 / 👎.
| // waiting on the producer. The account-identity checks below need | ||
| // the session account, so they necessarily follow. Only an exact | ||
| // "-" reads stdin; --description "" stays the clear idiom. | ||
| description, err := resolveContentValue(cmd, description, -1, "--description") |
There was a problem hiding this comment.
Check replacement URL type before draining stdin
Fresh evidence beyond the earlier trusted-host fix is that a URL on the configured host can still deterministically identify the wrong recording type or an uploads collection, but those checks remain after this read at lines 1804-1816. For example, producer | basecamp files replace https://3.basecamp.com/123/buckets/456/todos/789 ./valid --description - can block or exhaust memory before reporting that the URL is not an upload, while an empty producer masks that error. Parse the URL and validate its type and collection status before resolving the description; only the account-identity comparison needs to remain after account resolution.
Useful? React with 👍 / 👎.
| if len(args) > 1 { | ||
| content = args[1] | ||
| var contentErr error | ||
| content, contentErr = resolveContentValue(cmd, args[1], 1, "[content]") |
There was a problem hiding this comment.
Reject empty subscriptions before reading document content
Fresh evidence beyond the earlier mutual-exclusion fix is that an explicitly empty --subscribe "" is still guaranteed to fail in applySubscribeFlags, but docs documents create Title - --subscribe "" drains stdin here before that validation runs at lines 1294-1297. A slow or unbounded producer can therefore block before the deterministic “requires at least one person” error, and a blank producer masks it as “stdin is empty.” Extend the pre-read subscription validation to reject an explicitly changed but empty value; the messages and schedule create paths have the same ordering.
Useful? React with 👍 / 👎.
The fallback writer was not the path that retried — app.Err was. When a filter emits results and then fails, writeJQ has already written, and falling through to the plain writer appended a second, unfiltered envelope. 'todos create --jq ".error, error(\"stop\")"' printed both a bare line and a full JSON document to stdout, which is two incompatible outputs for the machine consumer the filter exists to serve. Once a jq-backed write has begun, stdout is final: report the render failure on stderr and exit with the original code. Without a filter nothing partial can have been written through one, so the plain fallback stays as the last resort for a broken pipe. jqUsable stays — it covers the other path, where no app existed yet — but it was never able to prevent this, and the comment that implied otherwise now describes only the path it governs. The old unit test drove two writers directly rather than Execute, so it could not have caught this. Replaced with a predicate test, plus an e2e case that runs the real binary and asserts stdout holds exactly one document; removing the fix makes it fail.
|
Fixed in 27b8615. You were right, and I had fixed the wrong path. The defect. I patched the fallback writer at the bottom of The fix, taking the invariant you stated: once a jq-backed write has begun, stdout is final. When 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 On the test. Your criticism was exact — it drove two writers directly and never reached
Scope note. The 110 uncommitted lines you saw were mid-round work; they landed as ac3f38e and e4d545d (the remaining pre-read validations, And agreed on
|
There was a problem hiding this comment.
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). Thewriter.Errresult 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, pipedtodos create --jsonreports only<content> requiredplus 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.
There was a problem hiding this comment.
💡 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".
| // Syntactic checks first, then "-", then account and network: a | ||
| // malformed ID is answered without waiting on the producer, and a | ||
| // blank pipe cannot mask it. | ||
| content, err := resolveContentValue(cmd, content, -1, "--body") |
There was a problem hiding this comment.
Validate card attachments before reading the body
Fresh evidence beyond the prior attachment-path thread is that newCardsUpdateCmd still reaches this stdin resolver without calling validateAttachPaths, despite the earlier fix claiming all affected commands were covered. With producer | basecamp cards update 123 --body - --attach missing.png, the command can block or buffer the entire producer before uploadAttachments reports the already-known missing-file error, while an empty producer masks it as “stdin is empty”; validate attachFiles before resolving --body, as the card-create path already does.
Useful? React with 👍 / 👎.
| return fmt.Errorf("app not initialized") | ||
| } | ||
|
|
||
| description, err := resolveContentValue(cmd, description, -1, "--description") |
There was a problem hiding this comment.
Validate the explicit todoset before reading the description
When --todoset is explicitly malformed, its value is returned unchanged by ensureTodoset and rejected by the ParseInt at lines 327-329, but this resolver consumes stdin first. Thus producer | basecamp todolists create Name --todoset nope --description - can block or buffer indefinitely before reporting the already-known invalid todoset ID, and an empty producer masks that error as “stdin is empty”; parse an explicitly supplied todoset ID before resolving the description.
Useful? React with 👍 / 👎.
| if err != nil { | ||
| return err | ||
| } | ||
| description, err = resolveContentValue(cmd, description, -1, "--description") |
There was a problem hiding this comment.
Validate explicit folder IDs before consuming stdin
Fresh evidence beyond the prior upload-file validation is that a malformed explicit --folder or --vault still reaches this description resolver before runUploadFile parses the folder ID at lines 1036-1038. With a valid upload file, producer | basecamp uploads create ./report.pdf --folder nope --description - can therefore block or exhaust memory before returning the deterministic folder-ID error, while an empty producer reports the wrong stdin error; validate an explicitly supplied folder ID before resolving the description in both upload entry points (and before the analogous document-content read).
Useful? React with 👍 / 👎.
| if len(args) > 1 { | ||
| content = args[1] | ||
| var err error | ||
| content, err = resolveContentValue(cmd, args[1], 1, "[body]") |
There was a problem hiding this comment.
Validate explicit card-table IDs before reading card bodies
Fresh evidence beyond the prior named-column requirement fix is that a nonnumeric explicit --card-table satisfies that pre-read check, so this resolver drains the body before getCardTableID rejects the table value. For example, producer | basecamp cards create Title - --column Backlog --card-table nope --in 123 can block or buffer indefinitely before reporting that the card table is invalid, while an empty producer masks it as “stdin is empty”; reject a syntactically invalid explicit card-table ID before resolving the body (and do the same before the stdin description in column creation).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
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 returningInvalid 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
}
| // exists to serve. Once a jq-backed write has begun, stdout is | ||
| // final: report the failure on stderr and stop. | ||
| if jq, _ := cmd.PersistentFlags().GetString("jq"); jq != "" { | ||
| fmt.Fprintf(os.Stderr, "error rendering error output through --jq: %v\n", writeErr) |
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.
|
Fixed in 58ff6da. The instrument criticism was the important one and it was correct. The backstop was comparing first-to-first. I also removed two names from the recognized set rather than expanding blindly: What the fixed check found, all now hoisted: duplicate schedule-timestamp and card-column checks left behind by my earlier hoists; The semantic cases the AST cannot see, all confirmed and fixed:
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 accepted — 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
|
There was a problem hiding this comment.
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
applySubscribeFlagscontract now precedesrejectSubscribeConflict, 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 aboveapplySubscribeFlags.
// 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 documentinggetDockToolID; as a result the new helper has a misleading doc comment andgetDockToolIDloses the documentation immediately above it. Move the helper and its comment before the existinggetDockToolIDblock.
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.
There was a problem hiding this comment.
💡 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".
| // Syntactic checks first, then "-", then account and network: a | ||
| // malformed ID is answered without waiting on the producer, and a | ||
| // blank pipe cannot mask it. | ||
| content, err := resolveContentValue(cmd, content, -1, "--body") |
There was a problem hiding this comment.
Validate due dates before reading card bodies
When --due contains an unsupported value and --body - reads from a slow or unbounded producer, this drains all of stdin before dateparse.Parse processes the due date later; that parser returns unrecognized input unchanged, so the request eventually fails only after the producer has been consumed. Validate the advertised natural-date/YYYY-MM-DD formats before resolving the body, as the todo-update path already does, so producer | basecamp cards update 123 --due not-a-date --body - fails promptly instead of blocking or buffering discarded content.
Useful? React with 👍 / 👎.
Agents instinctively pass
-to mean "read content from stdin" — but onlycomments create/updatehonored it. Everywhere else the hyphen landed as literal content: a todo titled-, a message body of-.Tier 1 —
-reads stdin on every content inputContent-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 — theallow_dashannotation and pipe detection — is the newinternal/stdinargleaf package, becauseinternal/clineeds it too:--agenthelp 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 errorA central guard covers every runnable command in the assembled tree (~380 commands, aliases and future ones included). On subcommands it wraps the
Argsvalidator: cobra runsValidateArgsafter flag parsing (soChangedandArgsLenAtDashare 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 aPersistentPreRunEhook 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
Argsmust stay nil for cobra'slegacyArgsunknown-subcommand check, so its guard hangs off the front of its ownPersistentPreRunE, acting only when the root itself executes. That is still ahead of config loading, profile resolution and--jqvalidation.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 —helpand 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>runsbasecamp __complete todos create -), so guarding it would break flag completion across the CLI.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 it, heredoc (… - <<'EOF'),cat | … -(type + Ctrl-D), or--editwhere it exists.comments createandnotes 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.notes set -(piped) — was a bogus "two sources" error, now works;notes set --file -— was ENOENT on a file named-, now stdin.-as a title/name/path now error;--is the documented escape. TTY usage unaffected.printf '🎉\n' | boost create <id> -no longer burns a rune).Flag for review
No
--stdinflag. A precedent survey settled on-as the universal content-from-stdin idiom;--stdinin 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 establishededit_test.goseam).--escape through real parses.projects create -), unlisted flag (todos update --title -), TTY passthrough,--out -exemption, double-dash rejection,--attach -alongside an allowed body,--escape.-,api post --data -,todos create -,boost create -(+ over-limit stdin),todos update --description -, notes set both forms.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/cigreen: 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 to all content inputs and installs a dash guard that rejects stray “-” when stdin is piped, so pipes aren’t misinterpreted as literal content. Also fixes jq-backed output to avoid double writes on filter errors and classifies Cobra arity errors as usage.
--data,--body,--content,--description,--comment,--file).--out -(download) keeps the stdout idiom. Cobra meta commands (help,__complete) are exempt.--content. Messages/cards/docs create bound at two args to avoid draining stdin and dropping extras. Stdin content trims trailing CRLF/LF.Written for commit 58ff6da. Summary will update on new commits.