Skip to content

fix: make flag values, errors, and help legible to humans and agents (COR-13656) - #21

Closed
Bradenream wants to merge 4 commits into
masterfrom
braden/flag-ergonomics/COR-13656
Closed

fix: make flag values, errors, and help legible to humans and agents (COR-13656)#21
Bradenream wants to merge 4 commits into
masterfrom
braden/flag-ergonomics/COR-13656

Conversation

@Bradenream

Copy link
Copy Markdown
Contributor

Why

An unattended coding agent, given only an access token and the README, built a working support agent and held a real conversation — but it took ~45 minutes. About 3 of those were the quickstart. The other ~42 were spent fighting the CLI itself.

This fixes four defects from that session. All of them live at the same moment: when a flag value is typed.

What changes

1. Flags that hold prose now accept prose

vf agent update --instructions 'You are a support agent for Acme.'
  invalid value for --instructions: error unmarshalling json response body:
  invalid character 'Y' looking for beginning of value

The field holds markdown. It is FlagKindJSON because the spec marks it nullable, and JSON is the only encoding that distinguishes a real null from the string "null"that is correct and is preserved. But nothing told the caller to wrap prose in JSON quotes, and the error named the first letter of their sentence.

An unparseable value is now retried JSON-quoted, but only when the destination unwraps to a Go string — 22 of 82 JSON flags. Structs, maps and slices still fail loudly, because prose is meaningless there.

The discriminator has to unwrap OptionalNullable[T], which is map[bool]*T and so indistinguishable from map[string]any by Kind alone. Requiring a bool key admits OptionalNullable[string] and rejects every ordinary map. Unit-tested against all 13 shapes that occur, including the near-misses map[string]string and OptionalNullable[[]string].

The trade, stated plainly: for those 22 flags, malformed JSON that used to error is now stored as literal text. Right for markdown authors, wrong for someone who meant JSON and typo'd.

2. Rejection messages are actionable

Flag errors never reached output.Error — they returned raw from the generated RunE and printed via a bare Fprintln. A mistyped flag got one unstructured line while an API 401 got a structured envelope. Backwards, since mistyped flags are the more common failure.

flagutil cannot import output (output already imports flagutil), so failures carry a typed FlagValueError up to Execute, which renders it.

invalid value for --llm: the value is valid JSON but not the shape this flag expects
  you passed: [1,2]
  expected shape: --llm '{"key":"value"}'
  decoder reported: cannot unmarshal array into Go value of type map[string]json.RawMessage
  pass null to clear the field: --llm null

3. Help shows the flag's type, not a word from its prose

pflag reads the first back-quoted word in a usage string as the value placeholder. Our descriptions come from the OpenAPI spec, where backticks are emphasis:

before after
agent update --instructions Name --instructions string
transcript search --version-param environmentAlias --version-param string

12 flags affected. The worst is --version-param, whose only legal values are draft and published, labelled with an unrelated parameter name. Fixed at the single point descriptions enter cobra, so a 13th flag is covered automatically. --usage output is byte-identical — it was already correct.

4. Agent-mode errors print exactly once

Every printer in internal/output writes to stderr and returns the error, and main.go printed whatever Execute returned. So one failure reached stderr twice. AgentModeError's own doc comment promised the opposite — but main.go is generated and printed unconditionally, so the contract was never kept.

stderr is now a single parseable JSON document for all three agent-mode error shapes. Pretty output is deliberately untouched: there the trailing line is the readable summary after a raw API body, so suppressing it would remove the most useful part rather than a duplicate. Verified byte-identical to master.

Design notes

Footprint on generated files is 12 insertions and 7 deletions across five files. All real logic lives in six new non-generated files. That was the binding constraint — regenerations land regularly (v0.234.0 was one), so every touched line of generated code is carried forever.

An OpenAPI overlay was assessed for items 1 and 3, and rejected. It would pin 12 descriptions verbatim so upstream rewording is silently overwritten and a 13th flag is missed; and fixing item 1 that way means deleting nullable, trading away the ability to clear a field. Persisted-edit durability was confirmed first — all four pre-existing edits survived the v0.234.0 regeneration intact.

Adversarial review

A 53-agent hostile review ran against the built binaries, with every finding independently verified by two skeptics instructed to refute it. 23 findings, 12 refuted, 11 survived, 3 blocking. All three blocking findings were real defects in my own code, and all three are fixed in a70fe3a:

  1. The suggested example was one the CLI rejects. An object example was hardcoded for every structured flag, so on array-valued flags the hint told the caller to pass a value the same binary refuses — following it exactly reproduced the identical error. A loop, for the audience this PR exists for. The example is now derived from the destination kind, and an unrecognized shape yields no example at all.

  2. Valid JSON of the wrong shape was reported as invalid JSON. --llm '[1,2]' parses fine. The decoder's real explanation was stored in Cause and never rendered on any path.

  3. Agent mode bypassed the truncation written for exactly this. Measured: master 118 B, human 311 B, agent 5,329 B for a 5,000-char value and 400,331 B for a 400 KB one. It also echoed a credential from a malformed blob that master never echoed.

Plus two non-blocking: byte-slicing split multi-byte runes (200 Japanese characters reported as 600), and a stale comment.

Residual, deliberately kept: up to 120 characters of the value are still echoed where master echoed none. The echo is what reveals shell-quoting mistakes, and the value is the caller's own argv — already in process args and shell history. Bounded and documented.

Verification

  • 39 new tests. The flag tests fail against master; the review-fix tests fail against the pre-review branch. Four pass on both — the invariants that must not move: --instructions null still sends a real null, quoted JSON is unchanged, object flags still reject.
  • One test takes the CLI's own suggested example and feeds it back in — the only assertion that actually proves the retry loop is closed.
  • Full suite: 66 passing, with the same 11 pre-existing failures as master (live integration tests with no credentials).
  • go build, go vet, gofmt clean. Non-flag human output byte-identical to master.

Found but not fixed

--agent-mode / --agent-mode=false is inert: InitAgentMode latches at root.go:222 before flags parse, so the post-parse call returns early. Only env vars work. Verified present on master — not a regression from this work, and worth its own ticket.

…OR-13656)

Three defects in the same place — the moment a flag value is typed — found
while watching an unattended coding agent configure a real agent. Together
they account for most of the 42 minutes it spent after the quickstart.

1. JSON flags whose destination is a string now accept raw text.

   vf agent update --instructions 'You are a support agent for Acme.'
     invalid value for --instructions: error unmarshalling json response
     body: invalid character 'Y' looking for beginning of value

   The field holds markdown. It is FlagKindJSON because the spec marks it
   nullable, and JSON is the only encoding that tells a real null apart from
   the string "null" — that part is correct and is preserved. But nothing
   told the caller to wrap prose in JSON quotes, and the error named the
   first letter of the sentence. The fix retries an unparseable value as a
   JSON string when, and only when, the destination ultimately holds a Go
   string. 22 flags qualify.

   The discriminator has to unwrap OptionalNullable[T], which is defined as
   map[bool]*T — indistinguishable from map[string]any by Kind alone.
   Requiring a bool key admits OptionalNullable[string] and rejects every
   ordinary map. Unit-tested against all 13 shapes that occur, including
   map[string]string and OptionalNullable[[]string].

   The trade, stated plainly: for those 22 flags, malformed JSON that used
   to fail now succeeds as literal text. Right for markdown authors, wrong
   for someone who meant JSON and typo'd. Structs, maps and slices are
   excluded, so mistyped objects still fail loudly.

2. Flag errors now teach.

   They never reached output.Error: they return raw from the generated RunE
   and main.go prints them with a bare Fprintln. So a mistyped flag gave one
   unstructured line while an API 401 gave a structured envelope with hints
   — backwards, since mistyped flags are the more common failure. flagutil
   cannot import output (output already imports flagutil), so failures carry
   a typed FlagValueError up to Execute, which renders it.

3. Help now shows the flag's type instead of a word from its prose.

   pflag reads the first back-quoted word in a usage string as the value
   placeholder. Our descriptions come from the OpenAPI spec, where backticks
   are emphasis, so --instructions rendered as "--instructions Name" for a
   flag that wants markdown. 12 flags affected; the worst is transcript
   search's --version-param, whose only legal values are draft and published,
   labelled with an unrelated parameter name. Fixed at the one point
   descriptions enter cobra, so a 13th flag is covered automatically.

An OpenAPI overlay was assessed as an alternative for 1 and 3 and rejected:
it would pin 12 descriptions verbatim so upstream rewording is silently
overwritten, and fixing 1 that way means deleting nullable, trading away the
ability to clear a field. Persisted edits were confirmed durable first — all
four surviving edits came through the v0.234.0 regeneration intact.

Footprint on generated files is 4 insertions and 3 deletions across two
files; all real logic lives in new non-generated files.

Verified: 13 new tests, 9 of which fail against master and 4 of which pass
on both (the invariants: null still clears, quoted JSON unchanged, object
flags still reject). Full suite shows the same 11 pre-existing failures as
master — live integration tests with no credentials — and 13 more passes.
--usage output is byte-identical, as it was already correct.
Every error printer in internal/output writes the message to stderr and also
returns the error, and cmd/vf/main.go printed whatever Execute returned. So a
single failure reached stderr twice:

    CLAUDECODE=1 vf configure
    { "error": "…", "error_type": "configure_blocked", "hints": [ … ] }
    the 'configure' command is interactive and cannot be used in agent mode

AgentModeError's own doc comment promised the opposite — "outputs structured
JSON exactly once to stderr… Callers must NOT print the error again" — but
main.go is generated and printed unconditionally, so the contract was never
actually kept by anything.

This matters because in agent mode the envelope IS the product. A trailing
non-JSON line after a JSON object is exactly what breaks a consumer that does
the obvious thing, and being driven by agents is what this CLI is for. All
three agent-mode error shapes were affected: AgentModeError (configure, auth),
output.Error (API and preflight failures), and the flag-value errors added in
the previous commit.

Errors that have been printed are now marked, and main.go skips printing a
marked error. The marker renders as an empty message, which is what actually
suppresses the second print — main.go imports only internal/cli and so cannot
call a predicate in internal/output. That cost is stated in the file: anything
that formats one of these with %v renders nothing, which is contained because
exactly one place formats them and it is guarded.

Pretty output is deliberately untouched. There the trailing line is the
readable one-line summary that follows a raw API body, so suppressing it would
remove the most useful part of the message rather than a duplicate. Verified
byte-identical to master across four error classes.

One deliberate behavior change beyond agent mode: --output-format json now
emits pure JSON on stderr for errors, where it previously appended the same
prose line. If you asked for JSON you should get JSON, and everything in that
line is already in the envelope as message, hints and docs_url.

Footprint on generated files is 8 insertions and 4 deletions across three
files. Verified: 6 new tests, all 6 failing against master; stderr now parses
as a single JSON document for all three error shapes; exit codes unchanged at
1. Full suite shows the same 11 pre-existing failures as master.
…o (COR-13656)

Adversarial review of the two previous commits raised 23 findings; 12 were
refuted by independent verification and 11 survived. Three were blocking, and
all three were defects I introduced. Each is fixed here with a test that fails
against the pre-review branch.

1. The suggested example was one the CLI itself rejects.

   rawtext.go hardcoded '{"key":"value"}' for every structured destination, so
   on the array-valued flags it told the caller to pass a value the same binary
   refuses:

     vf agent update --playbooks notjson
       objects and arrays must be valid JSON, e.g. --playbooks '{"key":"value"}'
     vf agent update --playbooks '{"key":"value"}'    # do exactly what it said
       objects and arrays must be valid JSON, e.g. --playbooks '{"key":"value"}'

   Identical error, nothing new to try — a loop, for the agents this work is
   aimed at. Worse than master, which at least said "cannot unmarshal object
   into []json.RawMessage" and so named the array. The example is now derived
   from the destination kind, and an unrecognized shape produces no example at
   all: a wrong example is worse than none. The regression test takes the
   CLI's own suggestion and feeds it back in.

2. Valid JSON of the wrong shape was reported as invalid JSON.

   --llm '[1,2]' parses perfectly; it is simply not an object. The message said
   "expected a JSON value" and hinted "must be valid JSON", sending the reader
   to re-check syntax that was never wrong. Master said "cannot unmarshal array
   into Go value of type map[string]json.RawMessage" — uglier, and correct.

   That detail was being swallowed: FlagValueError.Cause was stored and never
   rendered on any path. Shape mismatch is now named separately, and the
   decoder's own message is surfaced, minus its "response body" prefix, which
   is wrong for a request the CLI has not sent.

3. Agent mode bypassed the truncation written for exactly this.

   renderFlagValueError echoed the raw value; truncateForError was unexported
   so internal/cli could not reach it. Measured on the same input: master 118
   bytes, human 311, agent 5,329 for a 5,000-char value and 400,331 for a
   400KB one — unbounded and linear in input. It also reproduced a credential
   embedded in a malformed config blob that master never echoed. Now behind an
   exported DisplayValue, tested in both modes.

Also fixed, non-blocking:

4. truncateForError sliced bytes, so a multi-byte rune was cut in half and the
   reported length was wrong — 200 Japanese characters reported as 600. Now
   counts and cuts in runes.

5. A comment in flagerrors.go described the double-print that the previous
   commit removed. Rewritten.

Residual, deliberately kept: up to 120 characters of the value are still
echoed, where master echoed none. The echo is what reveals shell-quoting
mistakes, the most common flag error, and the value is the caller's own argv —
already in process args and shell history. Bounded, and documented in the file.

Not fixed here, filed separately: --agent-mode / --agent-mode=false is inert
because InitAgentMode latches before flag parsing. Verified present on master,
so not a regression from this work.

Verified: 20 tests in flag-ergonomics, 6 of the 7 new ones failing against the
pre-review branch (the 7th guards human mode, which already had the cap). Full
suite 66 passing with the same 11 pre-existing failures as master. Non-flag
human output still byte-identical to master.
Copilot AI lite review requested due to automatic review settings August 31, 2026 22:10
@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

COR-13656

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The raw-text retry failure path reports the wrong underlying error (hiding the actual retry failure), and a new test helper does not fully isolate agent-detection env vars, risking flaky mode-dependent assertions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Improves CLI ergonomics at the “flag value parsing” boundary by (1) accepting raw prose for certain JSON-typed string destinations, (2) surfacing actionable, structured flag parsing errors (especially in agent mode), (3) preventing backticks in OpenAPI-derived descriptions from breaking help placeholders, and (4) ensuring agent-mode errors are emitted exactly once.

Changes:

  • Add raw-text fallback for JSON flags whose destination unwraps to a Go string, while keeping structured JSON flags strict and improving mismatch diagnostics.
  • Introduce a typed FlagValueError and render it via the existing internal/output machinery for consistent agent-mode JSON envelopes.
  • Neutralize backticks in flag descriptions so help placeholders show the flag’s real type, and suppress duplicate stderr printing via AlreadyReported.
File summaries
File Description
test/flag-ergonomics.test.ts New integration-style tests covering raw-text fallback, actionable errors, help placeholder behavior, and bounded echoing.
test/errors-teach.test.ts Tightens agent-mode assertions to require pure JSON stderr and single-print behavior.
internal/output/reported.go Adds AlreadyReported wrapper to prevent stderr duplication while preserving Unwrap.
internal/output/output.go Returns AlreadyReported(err) after printing error JSON to avoid double-printing from main.go.
internal/output/agentmode.go Wraps returned errors with AlreadyReported so agent-mode envelopes aren’t duplicated.
internal/flagutil/rawtext.go Implements string-destination raw-text retry + improved structured-flag error shaping/hints.
internal/flagutil/metadata.go Routes JSON unmarshal failures through raw-text retry / typed errors; neutralizes description backticks at registration.
internal/flagutil/flagerror.go Introduces FlagValueError with bounded, rune-aware value truncation for safe echoing.
internal/flagutil/description.go Replaces backticks in descriptions to prevent pflag placeholder hijacking.
internal/cli/root.go Wraps execution with flag-value error rendering for agent mode.
internal/cli/flagerrors.go Converts FlagValueError into output.AgentModeError envelope in agent mode.
cmd/vf/main.go Skips printing errors with an empty message to prevent double stderr output.
Review details

Files not reviewed (5)

  • cmd/vf/main.go: Generated file
  • internal/cli/root.go: Generated file
  • internal/flagutil/metadata.go: Generated file
  • internal/output/agentmode.go: Generated file
  • internal/output/output.go: Generated file

Suppressed comments (1)

internal/flagutil/rawtext.go:107

  • Same issue as above: the non-pointer retry path reports the original parse error (cause) instead of the retry error, so the user/dev never sees why the quoted retry failed.
	target := reflect.New(fieldType)
	if err := utils.UnmarshalJsonFromString(string(quoted), target.Interface(), m.Annotations); err != nil {
		return rawTextRetryFailed(m, val, cause)
	}
  • Files reviewed: 7/12 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

Comment on lines +94 to +102
if isPtr {
holder := reflect.New(reflect.PtrTo(fieldType))
holder.Elem().Set(reflect.New(fieldType))
if err := utils.UnmarshalJsonFromString(string(quoted), holder.Interface(), m.Annotations); err != nil {
return rawTextRetryFailed(m, val, cause)
}
field.Set(holder.Elem())
return nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in c37b670.

The retry error was shadowed and cause passed in its place, so a message whose only job is explaining why the quoted retry failed instead printed the original parse error — "this text is not JSON". That is the precondition for reaching this path at all, so it was true, expected, and useless for the one case the diagnostic exists for.

rawTextRetryFailed now takes the retry error and reports that; the original is kept as the wrapped Cause so the full sequence is still recoverable.

Comment on lines +22 to +27
function run(args: string[], opts: { agentMode?: boolean } = {}) {
const env: Record<string, string | undefined> = opts.agentMode
? { CLAUDECODE: '1' }
: { CLAUDECODE: undefined, CLAUDE_CODE: undefined, CURSOR_AGENT: undefined };
return execa({ reject: false, timeout: 20_000, stdin: 'ignore', env, extendEnv: true })(VF, args);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in c37b670 — and it was not theoretical.

internal/output/agentmode.go treats twelve variables as "an agent is driving this". The helper cleared three. I reproduced it: with GITHUB_COPILOT=1 in the environment, the old helper failed 3 of 20 tests. The new one passes 20/20 with GITHUB_COPILOT, CODEX and AMAZON_Q all set.

The list is duplicated from Go into TypeScript and can drift, so tests that depend on which renderer ran now assert the mode explicitly. A thirteenth variable upstream fails loudly naming the cause, rather than quietly testing the wrong renderer.

@effervescentia effervescentia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

changes to the type of a field (here accepting a string for Markup fields) should happen at the API level, not by modifying the generated flags downstream in the CLI

…OR-13656)

Both from Copilot's review of this PR. Both verified before fixing, and both
real.

1. rawTextRetryFailed reported the wrong error.

   The retry's own error was shadowed and discarded, and the original parse
   error was passed in its place. So a message whose entire purpose is to
   explain why the quoted retry failed instead printed "this text is not JSON"
   — true, expected, and useless, since not-JSON is the precondition for
   reaching that path at all. This path only fires when stringLikeJSONTarget
   and the SDK unmarshaler disagree, which is a CLI bug, so the diagnostic is
   the only thing a reporter has to go on. The retry error is now what gets
   reported; the original is kept as the wrapped Cause.

2. The human-mode test helper cleared 3 of 12 agent-detection variables.

   internal/output/agentmode.go treats twelve environment variables as "an
   agent is driving this", including GITHUB_COPILOT and CODEX. The helper
   cleared CLAUDECODE, CLAUDE_CODE and CURSOR_AGENT only, so on any machine
   with one of the other nine set, human-mode tests ran against the agent
   renderer.

   Demonstrated rather than assumed: with GITHUB_COPILOT=1 in the environment,
   the old helper failed 3 of 20 tests. The new one passes 20/20 with
   GITHUB_COPILOT, CODEX and AMAZON_Q all set.

   The list is duplicated from Go into TypeScript and can drift, so tests that
   depend on which renderer ran now assert the mode explicitly. A thirteenth
   variable appearing upstream fails loudly with a message naming the cause,
   instead of quietly testing the wrong renderer.
@Bradenream

Copy link
Copy Markdown
Contributor Author

Splitting this rather than arguing it — @effervescentia's objection is isolated to one of the four changes, so the other three should not wait on it.

On the objection itself, for the record here as well as on #24: I could not reproduce the premise.

  • Markup is unreachable by this change. Markup is a union struct and every field holding one holds it as a struct or a slice of them; stringLikeJSONTarget admits neither. Unit-tested against Markup, []Markup, *Markup, OptionalNullable[Markup], OptionalNullable[[]Markup]. --url on mcp-server create is []components.Markup and still rejects raw text, with a regression test pinning it. No property in .speakeasy/out.openapi.yaml references Markup at all.
  • No field's type changes. The retry only fires where the spec already says type: stringinstructions is nullable: true, type: string. The bytes on the wire are identical to what the JSON-quoted form already produced, and identical to master.

So there is nothing to change at the API level: the spec is already correct. What is awkward is the codegen mapping from "nullable string" to a JSON-encoded flag. If you would still rather not carry that in the CLI, I am happy to drop #24#23 stands on its own and carries most of the value.

Closing this in favour of the two.

@Bradenream Bradenream closed this Sep 1, 2026
Bradenream added a commit that referenced this pull request Sep 4, 2026
…OR-13656)

Split out of #21 as the contested half, stacked on the errors-only PR so it can
be judged on its own.

Fields marked `nullable: true` generate as FlagKindJSON, because JSON is the
only encoding that expresses a real null distinctly from the string "null".
Correct, and the reason --instructions null clears the field. The cost is that
prose has to arrive JSON-quoted, and the workaround is quotes inside quotes —
exactly what shell users and coding agents get wrong:

  vf agent update --instructions '"You are a support agent for Acme."'

When a JSON flag fails to parse and its destination ultimately holds a Go
string, the value is re-encoded as a JSON string and retried. 22 of 82 JSON
flags qualify. Nullability is untouched: the null branch runs earlier.

On the review objection — that this accepts a string for Markup fields, and so
belongs at the API level rather than downstream in the generated CLI. Both
halves are testable, and both are now pinned by tests rather than argued:

  No field's type changes. The retry only fires where the spec already declares
  `type: string`; the request body is byte-identical to what the JSON-quoted
  form produced, and to master. This is how the CLI reads a flag, not what the
  API accepts. Verified across plain text, quoted JSON, null, objects, arrays,
  empty and numeric input.

  Markup is unreachable. Markup is a union struct, and every field holding one
  holds it as a struct or a slice of them; stringLikeJSONTarget admits neither.
  --url on mcp-server create is []components.Markup and still rejects raw text.
  There is also no property in .speakeasy/out.openapi.yaml that references
  Markup at all.

There is correspondingly nothing to fix upstream: the spec is already right.
What is awkward is the mapping from "nullable string" to a JSON-encoded flag,
which is codegen, not API design.

The discriminator has to unwrap OptionalNullable[T], which is map[bool]*T and
so indistinguishable from map[string]any by Kind alone. Requiring a bool key
admits OptionalNullable[string] and rejects every ordinary map. Unit-tested
against all 13 shapes that occur, including map[string]string and
OptionalNullable[[]string].

The trade, stated plainly: for those 22 flags, malformed JSON that used to
error is now stored as literal text. Right for markdown authors, wrong for
someone who meant JSON and typo'd. Structs, maps and slices are excluded, so a
mistyped object still fails loudly.

One test from the errors-only PR is updated rather than kept: it asserted that
--instructions rejects prose and explains how to quote it. That rejection is
what this change removes, so the test now pins the behaviour that replaced it.

Verified: 7 new tests plus the updated one; full suite shows the same
pre-existing failures as master.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants