diff --git a/CLAUDE.md b/CLAUDE.md index 318cd3d..b70ca89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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": , "status": , "body": }` — 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). diff --git a/cmd/omni/agent_help.go b/cmd/omni/agent_help.go index 645458c..7e331c9 100644 --- a/cmd/omni/agent_help.go +++ b/cmd/omni/agent_help.go @@ -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 # 2nd arg is the NAME + omni models delete-branch # same: NAME, not the branch UUID + omni labels get # labels are addressed by name, not id + omni documents add-label + omni users get-model-roles # 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 diff --git a/cmd/omni/output.go b/cmd/omni/output.go index ed06a88..536c398 100644 --- a/cmd/omni/output.go +++ b/cmd/omni/output.go @@ -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) @@ -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 + } + } } } } diff --git a/cmd/omni/output_test.go b/cmd/omni/output_test.go index 93b97e2..c1e96c8 100644 --- a/cmd/omni/output_test.go +++ b/cmd/omni/output_test.go @@ -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 { @@ -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) + } +}