Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,6 @@ All output is JSON to stdout. Errors go to stderr as JSON. Use `--compact` for n

Nothing is written to stdout on failure: HTTP ≥400 bodies, error messages, and subcommand suggestions all go to stderr, and the exit code is non-zero. A failed API call leaves exactly one JSON document on stderr — `{"error": <detail>, "status": <code>, "body": <the API's payload>}` — so `2>err.json` stays parseable; nothing else is printed alongside it. Runtime errors don't print the usage block (flag-parse errors still do).

A 2xx body that isn't JSON (e.g. `query run`'s `text/ndjson` stream, or CSV/XLSX with `--result-type`) is passed through to stdout unchanged and counts as success. The body is read in full before anything is written, so a truncated response never leaves a partial payload on stdout.
A 2xx body that isn't JSON (e.g. `query run`'s `text/ndjson` stream, or CSV/XLSX when `query run`'s body sets `"resultType"`) is passed through to stdout unchanged and counts as success. The body is read in full before anything is written, so a truncated response never leaves a partial payload on stdout.

A group command with no subcommand (`omni models`) prints its help to stderr and exits 1; an unknown subcommand errors with suggestions, `--help` or not (`omni models list-branches --help` is a typo, not a help request).
4 changes: 4 additions & 0 deletions cmd/omni/agent_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,10 @@ available; binary values in its JSON object are interpreted as file paths.
Generated API commands describe every positional in an "Arguments:" section in
their --help. Read it — some positionals take a NAME, not a UUID:
omni models merge-branch <model-id> <branch-name> # 2nd arg is the NAME
omni models delete-branch <model-id> <branch-name> # same: NAME, not the branch UUID
omni labels get <name> # labels are addressed by name, not id
omni documents add-label <identifier> <label-name>
omni users get-model-roles <membership-id> # membership ID, not the user ID
The few hand-written commands (config *, agent-help, models create-branch,
users set-attributes) have no Arguments section — read their Usage line.
- Query parameters are flags: omni models list --page-size 10
Expand Down
19 changes: 15 additions & 4 deletions cmd/omni/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ func outputResponseTo(stdout, stderr io.Writer, resp *http.Response, format stri
return nil
}

// Non-JSON payloads (`query run`'s text/ndjson stream, CSV/XLSX with
// --result-type) go out unchanged: no re-indenting, no appended newline,
// Non-JSON payloads (`query run`'s text/ndjson stream, or CSV/XLSX when
// its body sets resultType) go out unchanged: no re-indenting, no appended newline,
// so a redirect to a file reproduces the response byte for byte.
if trimmed := bytes.TrimSpace(data); len(trimmed) > 0 && !json.Valid(trimmed) {
_, err := stdout.Write(data)
Expand Down Expand Up @@ -106,8 +106,19 @@ func extractErrorDetail(body json.RawMessage, raw []byte, status int) string {
var obj map[string]any
if err := json.Unmarshal(body, &obj); err == nil {
for _, key := range []string{"detail", "message", "error"} {
if s, ok := obj[key].(string); ok && s != "" {
return s
switch v := obj[key].(type) {
case string:
if v != "" {
return v
}
case map[string]any:
// Auth failures arrive as {"error":{"code":403,"message":"..."}}:
// surface the message rather than the whole object re-serialised.
for _, inner := range []string{"message", "detail"} {
if s, ok := v[inner].(string); ok && s != "" {
return s
}
}
}
}
}
Expand Down
14 changes: 13 additions & 1 deletion cmd/omni/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func TestOutputResponseTo_NonJSONSuccessPassesThrough(t *testing.T) {
// A CSV whose last row has no trailing newline: appending one would
// change the file the user redirected to disk.
{"csv without trailing newline", "id,name\n1,widget"},
// Binary payloads (XLSX with --result-type) must survive verbatim too.
// Binary payloads (XLSX from a query run body with resultType) must survive verbatim too.
{"binary", "PK\x03\x04\x14\x00\x00\x00\x08\x00"},
}
for _, tc := range bodies {
Expand Down Expand Up @@ -334,3 +334,15 @@ func TestOutputResponseTo_SuccessGoesToStdout(t *testing.T) {
})
}
}

func TestExtractErrorDetail_NestedErrorObject(t *testing.T) {
body := json.RawMessage(`{"error":{"code":403,"message":"Invalid bearer token"}}`)
if got := extractErrorDetail(body, []byte(body), 403); got != "Invalid bearer token" {
t.Errorf("extractErrorDetail = %q, want the nested message", got)
}
// An object without a recognisable message still falls back to the raw body.
body = json.RawMessage(`{"error":{"code":403}}`)
if got := extractErrorDetail(body, []byte(body), 403); got != string(body) {
t.Errorf("extractErrorDetail = %q, want raw body fallback", got)
}
}
Loading