Skip to content

feat(body): accept --body @file and validate JSON client-side - #75

Merged
dspangen merged 7 commits into
mainfrom
feat/body-file-input
Aug 28, 2026
Merged

feat(body): accept --body @file and validate JSON client-side#75
dspangen merged 7 commits into
mainfrom
feat/body-file-input

Conversation

@dspangen

@dspangen dspangen commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

--body @file.json (curl-style) now works, and bodies are validated as JSON client-side before any network call. Previously a file path passed to --body was POSTed literally and the server's Invalid JSON 400 read as a body-shape problem, sending callers back to --schema (6 audited incidents).

  • --body @path reads the file (10 MB cap, ~/ expansion, @- = stdin); missing file is a clear local error.
  • Invalid JSON fails locally; a path-shaped value gets a transport hint: read the file instead: --body @/tmp/body.json.
  • --body '' is now an error (almost always an unexpanded "$BODY"); omitting the flag still sends no body.
  • Bytes are never re-serialized — what you pass is what gets sent. Multipart endpoints skip JSON validation but keep the path hint.
Details: behavior, edge cases, and verification

Problem

omni ai generate-query --body @/tmp/body.json     # curl syntax
omni ai generate-query --body /tmp/body.json      # bare path

Both were sent to the API verbatim as the body. The server replied {"detail": "Bad Request: Invalid JSON", "status": 400} — which reads as a body-shape problem, so the agent went back to --schema, rebuilt the JSON, and hit the same wall. The mistake was never about shape; it was about transport.

Changes

  • --body @path/to/file.json reads the file. Same 10 MB cap as stdin, ~/ expanded (shells don't expand it after @), and @- accepted as curl's spelling of stdin. A missing or unreadable file is a client-side error naming the path.

  • Bodies are checked with json.Valid before the request is made. No network call happens on a bad body. Bytes are never re-serialized — what you pass is what gets sent, field order and all.

  • A path-shaped --body gets a transport hint, not a parse error:

    Error: --body looks like a file path, not a request body: /tmp/body.json
    read the file instead: --body @/tmp/body.json   (or --body - < /tmp/body.json)
    

    Triggered by a /, ./, ../, ~/ prefix or an existing file — including paths with spaces, which arrive unquoted from the shell and get re-quoted in the suggestion. Anything else malformed gets the parse error plus byte offset and surrounding snippet.

  • --body '' is an error, not a bodiless request. Omitting the flag still sends no body.

  • Applies identically to the hidden --json-body alias; errors name whichever flag was typed.

  • The multipart/form-data upload operations skip JSON validation only (operationInfo.BodyNonJSON) — the transport checks still apply there, since --body @file is exactly what those endpoints need.

  • Help text updated in generate.go, omni agent-help, and the README.

Body shorthand (omni ai search-omni-docs "question") is unaffected: it assembles its body with json.Marshal and sets --body internally, so it passes validation untouched. Shorthand commands also accept --body @file.

Verification

make build && make test — all packages pass, including the existing spec-coverage test over every operation in api/openapi.json.

25 tests in internal/openapi/body_input_test.go cover: @file expansion, missing / oversized / non-JSON @file, bare @, the file-path hint for all four path shapes plus a space-containing existing path, the parse diagnostic, flag naming, byte-identical passthrough of valid JSON (including whitespace and non-object bodies), the non-JSON media-type carve-out and its retained path hint, stdin via - and @-, empty sources, --body '' on both plain and shorthand commands, @file end-to-end through a generated cobra command, the executor not running on an invalid body, and both shorthand paths.

Manual check against a real binary:

$ omni ai search-omni-docs --body /tmp/body.json
Error: --body looks like a file path, not a request body: /tmp/body.json
read the file instead: --body @/tmp/body.json   (or --body - < /tmp/body.json)

$ omni ai search-omni-docs --body '{"question":"x",}'
Error: request body from --body is not valid JSON: invalid character '}' looking
for beginning of object key string (at byte 17, near "{\"question\":\"x\",}")

$ omni query run --body ''
Error: --body is empty; omit the flag to send no body

$ omni uploads create --body /tmp/upload.csv
Error: --body looks like a file path, not a request body: /tmp/upload.csv
read the file instead: --body @/tmp/upload.csv   (or --body - < /tmp/upload.csv)

$ omni ai search-omni-docs --body @/tmp/body.json    # reaches the API

🤖 Generated with Claude Code

https://claude.ai/code/session_014TwwKSAsAGPBToNb4iUe5s

dspangen added a commit that referenced this pull request Aug 24, 2026
Review of #75 found three ways the new diagnostics went unreached:

1. `--body ''` never got validated — the resolve step keyed off a non-empty
   string, so the explicit-empty error was dead code. It now keys off
   Flag.Changed, which distinguishes "flag omitted" (send no body, unchanged)
   from "flag typed as empty" (usually an unexpanded shell variable). The
   shorthand wrapper defers to the same path instead of quietly assembling a
   body from positional args.
2. A quoted path with spaces got a JSON parse error instead of the hint —
   looksLikePath bailed on whitespace before it checked prefixes or the
   filesystem, but the shell strips the quotes, so those values arrive looking
   ordinary. The whitespace guard now only narrows the stat() branch, to
   newlines, and the suggested commands are re-quoted so they paste back.
3. Multipart operations skipped path detection along with JSON validation, so
   the endpoints where "--body @file" is the whole point sent the path
   literally. Transport checks (empty input, path-shaped value) now run for
   every media type; only json.Valid stays conditional.

Constraint: valid JSON is tested before path-shape, so a file that happens to
be named "{}" can't hijack a legitimate body
Rejected: treating `--body ''` as "send no body" | it is indistinguishable
from an unexpanded "$BODY" and silently posts nothing
Confidence: high
Scope-risk: narrow
Not-tested: a shorthand command given `--body ''` and zero positional args —
the arg validator rejects it first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TwwKSAsAGPBToNb4iUe5s
dspangen and others added 2 commits August 24, 2026 12:23
Agents reach for curl syntax (--body @/tmp/body.json) or pass a bare file
path. Both were sent verbatim as the request body, and the API answered
{"detail": "Bad Request: Invalid JSON"} — a message that reads like a
body-SHAPE problem and sends the caller back to re-read the schema for a
mistake that was purely about transport.

--body/--json-body now resolve "@path" (and curl's "@-") to file contents
under the same 10 MB cap as stdin, and every JSON-media-type body is run
through json.Valid before any network call. A value that looks like a path
(/, ./, ../, ~/ prefix, or an existing file) gets an error naming both
working forms instead of a parse error.

Constraint: bytes must reach the server unchanged — validation uses json.Valid and never re-serializes, so field order and formatting survive
Constraint: body shorthand sets --body internally to marshaled JSON; that path stays valid and untouched
Rejected: schema-aware validation of the body | needs the full JSON Schema evaluator and would reject bodies the API actually accepts
Rejected: silently treating a bare existing path as a file | hides the typo class this is meant to surface, and changes what an existing script sends
Confidence: high
Scope-risk: narrow
Directive: multipart/form-data operations (uploads) skip validation via operationInfo.BodyNonJSON — keep that carve-out if more media types appear
Not-tested: reading from a FIFO or /dev/stdin via @path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TwwKSAsAGPBToNb4iUe5s
Review of #75 found three ways the new diagnostics went unreached:

1. `--body ''` never got validated — the resolve step keyed off a non-empty
   string, so the explicit-empty error was dead code. It now keys off
   Flag.Changed, which distinguishes "flag omitted" (send no body, unchanged)
   from "flag typed as empty" (usually an unexpanded shell variable). The
   shorthand wrapper defers to the same path instead of quietly assembling a
   body from positional args.
2. A quoted path with spaces got a JSON parse error instead of the hint —
   looksLikePath bailed on whitespace before it checked prefixes or the
   filesystem, but the shell strips the quotes, so those values arrive looking
   ordinary. The whitespace guard now only narrows the stat() branch, to
   newlines, and the suggested commands are re-quoted so they paste back.
3. Multipart operations skipped path detection along with JSON validation, so
   the endpoints where "--body @file" is the whole point sent the path
   literally. Transport checks (empty input, path-shaped value) now run for
   every media type; only json.Valid stays conditional.

Constraint: valid JSON is tested before path-shape, so a file that happens to
be named "{}" can't hijack a legitimate body
Rejected: treating `--body ''` as "send no body" | it is indistinguishable
from an unexpanded "$BODY" and silently posts nothing
Confidence: high
Scope-risk: narrow
Not-tested: a shorthand command given `--body ''` and zero positional args —
the arg validator rejects it first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TwwKSAsAGPBToNb4iUe5s
@dspangen
dspangen force-pushed the feat/body-file-input branch from 0682e36 to 0827738 Compare August 24, 2026 16:24
@dspangen
dspangen requested a review from n8agrin August 24, 2026 16:48
@n8agrin
n8agrin requested a balanced review from Copilot August 24, 2026 22:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds file-backed body input and client-side JSON validation to generated API commands.

Changes:

  • Supports --body @file, @-, home expansion, and size limits.
  • Validates JSON locally with clearer diagnostics.
  • Adds comprehensive tests and updates help documentation.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
README.md Documents body input options.
internal/openapi/generate.go Integrates body resolution and media-type detection.
internal/openapi/body_shorthand.go Handles explicit body flags with shorthand commands.
internal/openapi/body_input.go Implements file reading, validation, and diagnostics.
internal/openapi/body_input_test.go Tests body-input behavior and integration.
cmd/omni/agent_help.go Updates agent-facing body guidance.

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

Comment thread internal/openapi/generate.go Outdated
if op.RequestBody != nil {
info.HasBody = true
info.BodySchema = requestBodySchema(op.RequestBody)
info.BodyNonJSON = !requestBodyIsJSON(op.RequestBody)
Comment thread internal/openapi/generate.go Outdated
Comment on lines +231 to +232
if jsonBodyFlag != "" || (cmd.Flags().Changed("json-body") && !cmd.Flags().Changed("body")) {
effectiveBody, flagName = jsonBodyFlag, "json-body"
Comment thread internal/openapi/body_shorthand.go Outdated
Comment on lines +329 to +333
// An explicitly empty --body/--json-body is a typo, not an invitation
// to assemble one from shorthand input. Hand it back to the generated
// RunE so it reports the same error every other command does.
if cmd.Flags().Changed("body") || cmd.Flags().Changed("json-body") {
return originalRunE(cmd, args[:numPathParams])
@n8agrin

n8agrin commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Code review found one bug.

internal/openapi/body_shorthand.go:363--body '' on shorthand commands doesn't reach the new "empty body" error.

flexibleArgs decides whether --body was passed by checking bodyFlag != "". An explicitly empty string looks the same as unset, so it falls through to requiring the shorthand positional arg count instead of just the path params. Reproduced on a build of this branch:

$ omni ai search-omni-docs --body ''
Error: accepts 1 arg(s), received 0

That's exactly the typo case (unexpanded --body "$VAR") this PR is meant to catch — on shorthand commands it never reaches the "--body is empty" message added at body_shorthand.go:332.

@n8agrin n8agrin 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.

one finding from the bots

dspangen and others added 2 commits August 24, 2026 21:45
Body-mode selection compared flag values, so `--body ''` looked unset:
shorthand commands demanded their positional args and never reached the
"--body is empty" diagnostic, and `--body '' --json-body '{}'` slipped past
the mutual-exclusion check. Both now key off Changed.

Multipart uploads were advertised as callable with `--body @file`, but
auth.Do labels every request application/json and the CLI builds no part
framing, so those bytes could never satisfy the API. Those operations now
fail client-side naming the media type and printing the equivalent curl
command, and the help text, agent-help and README say the same.

Constraint: auth.Do carries no media type — a body is always sent as application/json
Rejected: implement real multipart transport | needs media type plumbing through APIRequest plus part construction; out of scope for a bug-fix pass
Rejected: send raw bytes for multipart ops as before | mislabeled and unframed, guaranteed API rejection dressed up as CLI support
Confidence: high
Scope-risk: narrow
Directive: if multipart is ever implemented, drop unsupportedBodyError together with the help/README caveats — they must not outlive it
Not-tested: no live API call against the uploads endpoints

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TwwKSAsAGPBToNb4iUe5s
Constraint: 3d9b0e1 refused multipart client-side; its directive says drop unsupportedBodyError when multipart lands
Rejected: keep the refusal for uploads not yet verified | end-to-end round trip against a local server confirms framing and Content-Type
Confidence: high
Scope-risk: moderate
Directive: --body on a multipart op is a JSON map of field values, not raw file bytes — bodyFlagIsJSON() must keep returning true for multipart or #75's @file reading and validation stop applying there
Not-tested: no live call against the real Omni uploads API
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dspangen

Copy link
Copy Markdown
Contributor Author

@ernestoongaro #72 will land with this one.

Main landed flag normalization (#77) and --schema everywhere (#79), both of
which overlap this branch's generate.go work.

Resolutions:
- Query/path flag naming: main's canonicalName + SetNormalizeFunc +
  resolveQueryFlags replaces this branch's cliFlagName/queryFlagValue and the
  hidden deprecated legacy aliases. Main's approach covers the same spellings
  (--modelid, --modelId, --model_id) via pflag normalization, so the alias
  flags and their conflict check are no longer reachable behavior.
- operationInfo: kept both sides' fields (BodyMediaType/BodyFields from here,
  Response from main).
- requestBodySchema: dropped in favor of this branch's requestBodyMediaType,
  which also returns the MediaType so multipart encodings stay reachable.
- agent-help / README: kept both sides' text (--body @file and multipart from
  here, flag-spelling and --schema-everywhere notes from main).
- Removed TestBuildCommand_CamelCaseQueryFlags: it asserts the legacy-alias
  behavior main replaced. Main's TestBuildCommand_AlternateFlagSpellings and
  TestBuildCommand_CamelCaseQueryParamBecomesKebabFlag cover the successor.

Rejected: keeping legacy alias flags alongside main's normalization | duplicate
mechanisms for one behavior, and the aliases would shadow the normalize func
Confidence: high
Scope-risk: moderate
Directive: multipart FlagName now uses canonicalName; keep it in step with
resolveQueryFlags so upload flags and query flags stay spelled alike
Not-tested: multipart upload against a live API after the flag-name change
@ernestoongaro

Copy link
Copy Markdown
Collaborator

Opened #82 into feat/body-file-input — the multipart review findings from #72 that 3d9b0e1 and 0827738 didn't already cover.

Two are real bugs: ~ was never expanded for binary field paths, so --file ~/people.csv failed with "no such file or directory" while --body @~/x.json worked; and --body null decoded into a nil map, panicking with assignment to entry in nil map on the first generated flag merged into it. Three more are latent on the current spec — flag-name collisions could register the same pflag twice and panic at startup, array/object flag values decoded into interface{} so an object passed where the schema says array and trailing data was dropped, and requestBodyMediaType could prefer a schema-less application/json entry over a real multipart definition.

Regression test per fix; go test ./... green on top of 5eea40d. Stacked as a PR rather than pushed onto your branch, matching #74/#77/#79.

Closing #72 as superseded — its multipart commit already landed here as ffebcee.

One thing I left alone: operationInfo.bodyFlagIsJSON (generate.go:448) is dead now that 3d9b0e1 dropped resolveBody's validateJSON parameter, and its comment still describes the old behavior. Yours to cut or keep.

…82)

Follow-up to the multipart work now carried on this branch (ffebcee).
`3d9b0e1` and `0827738` already covered the body-flag Changed state and the
file-path hint; these are the findings from #72's review that no commit here
has picked up yet.

- Binary multipart field paths never expanded `~`, unlike `--body @path`, so
  `--file ~/people.csv` failed with "no such file or directory".
- `--body null` decoded into a nil map and panicked ("assignment to entry in
  nil map") as soon as any generated flag was merged into it.
- Array and object flag values decoded into interface{}, so an object was
  accepted where the schema says array, and anything after the first JSON
  value was silently dropped: `--labels '["a"] oops'` sent `["a"]`. The
  declared type is pinned now and the input must end there.
- registerMultipartFlags checked its "form-" replacement against nothing, so
  two fields colliding on one flag name would register the same pflag twice
  and panic at startup, taking down every command, not just the upload.
- requestBodyMediaType could prefer a schema-less application/json entry over
  a real multipart definition.

Each fix has a regression test; without the source changes the tilde and
nil-map tests fail, the latter by panicking.

Not addressed: Copilot's note that the upload file is buffered in memory
before the request is sent. Streaming means threading an io.Reader through
APIRequest and internal/auth, which is wider than a review-fix pass.


Claude-Session: https://claude.ai/code/session_01DYuiGGkmQifkCbF2qL8Lt6

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	internal/openapi/generate_test.go
@dspangen
dspangen merged commit 8500aac into main Aug 28, 2026
2 checks passed
@dspangen
dspangen deleted the feat/body-file-input branch August 28, 2026 12:46
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.

4 participants