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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ Config directory is resolved as: `OMNI_CONFIG_DIR` > `XDG_CONFIG_HOME/omni-cli`

All output is JSON to stdout. Errors go to stderr as JSON. Use `--compact` for non-indented output (good for piping to `jq`).

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).
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. The one exception is a multi-job query stream where some jobs succeeded: the results that did decode are rendered and the failed jobs are reported on stderr, still exiting 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).

`query run` and `query wait` stream NDJSON with rows as base64 Arrow. For `--format human` and `--chart` the CLI decodes that stream (`internal/result`) and renders from the model's field metadata in `summary.fields` — label, `is_dimension`, `data_type`, `format` — polling `query/wait` until every job finishes. Nothing is inferred from names or values. `--chart` (on API command groups only, with `--chart-value`, `--chart-rows`) draws the Omni app's bar table: every dimension a column, every measure a column of bars scaled to its own maximum; `--chart-value` narrows the bars to the measures it names, in that order (on a pivot, the measures spread across the pivot values). A query whose `model_job.pivots` names result columns is reshaped from the stream's long-form rows (`Set.Pivot`) for both the table and the chart — the other dimensions as rows, a column per pivot value and measure (pivot value over measure label), pivot values in the order the stream's row groups agree on, falling back to the pivot fields' own sort (descending if the query sorts them so), capped at `column_limit` — and a measure's bars share one scale across its pivot columns; a chart drops columns that don't fit the terminal, with a note (a table shows every column up to `column_limit`). It is accepted only on commands whose spec returns the stream (`query run`, `query wait`) and refused before any request otherwise; a `resultType` in the body is dropped (with a note in human mode) since those documents carry no metadata; an explicitly requested JSON format (`--format json`, `OMNI_OUTPUT_FORMAT`, or the profile's `defaultOutputFormat`) is an error, while the JSON a pipe auto-resolves to is not, so `--chart | less` draws. `--workbook` (same groups) sets `workbookUrl: true` on a body whose spec declares it — refused with `planOnly`, and refused rather than silently dropped when there is no JSON object to set it on — and surfaces the `X-Omni-Workbook-Url` header: a line under human-rendered output, or on stderr (plain for a passed-through CSV/XLSX, `{"workbookUrl": …}` in JSON mode) so stdout stays the payload. Human rendering strips control characters from API and warehouse text (cell values, labels, JSON strings and keys, error details, the workbook link) so a value can't drive the terminal; JSON output and passed-through payloads are untouched. Stream rendering is buffered and written once, and a failing `query/wait` poll reports through the same error envelope as any API call.

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.

Expand Down
6 changes: 6 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ make test # Run tests
make clean # Remove built binary
```

To check table and chart rendering by eye, `scripts/chart-demo.sh` runs read-only queries (tables, charts, pivots, a workbook link) against a sales pipeline demo model:

```bash
OMNI_PROFILE=my-profile MODEL_ID=<model-id> PAUSE=1 scripts/chart-demo.sh
```

## Updating the OpenAPI Spec

The CLI auto-generates commands from the embedded OpenAPI spec. The default sync fetches it from `exploreomni/omni@main` via `gh`:
Expand Down
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,80 @@ A failed API call leaves exactly one JSON document on stderr, so `omni ... 2>err

`body` holds the API's own payload and is omitted when the response wasn't JSON. A **successful** response that isn't JSON — `query run` streams `text/ndjson`, and returns CSV or XLSX with a result type — is passed through to stdout unchanged.

### Query results

`query run` and `query wait` stream results as NDJSON with the rows as Arrow. In JSON mode that stream passes through untouched. In human mode the CLI decodes it and renders what the model says about each field — its label, whether it's a dimension or a measure, and its number format — so a `NUMBER_0` measure reads `12,526` and a `percent` measure reads `39.92%`, in the query's column order. If the first response's wait window elapses, the CLI polls `query/wait` until every job has finished.

```
╭───────────────┬──────────┬────────────────────╮
│ Country │ Sessions │ Engaged Sessions % │
├───────────────┼──────────┼────────────────────┤
│ United States │ 12,526 │ 39.92% │
│ Ireland │ 838 │ 44.87% │
╰───────────────┴──────────┴────────────────────╯
```

### Charts

`--chart` draws the same results as the Omni app's bar table: every dimension is a column, and every measure gets a column of bars scaled to its own maximum, as the model defines them.

```bash
omni query run --body @revenue-by-category.json --chart --workbook
```

```
Category Total Sale Price
Jeans 1,602,513.81 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Accessories 955,617.30 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Outerwear & Coats 842,064.07 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Fashion Hoodies & Sweatshir… 756,824.63 ▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Open in Omni: https://myorg.omniapp.co/e/1:abc123/1
```

Narrow it to some of the measures by field or label — `--chart-value engaged_sessions_percent,"Sessions"`, `events_ext.sessions` and `sessions` all work; on a pivot, only those measures spread across the pivot values, so more of them fit — and cap the row count with `--chart-rows`. `--workbook` also opens the query in an ephemeral workbook: the link prints under the output (or, in JSON mode, as `{"workbookUrl": …}` on stderr, since stdout stays the API's payload).

A query with `pivots` renders pivoted, as a table and as a chart: the remaining dimensions stay as rows, each pivot value heads its own columns, and a measure's bars share one scale across all of them. A chart drops columns that don't fit the terminal, with a note; a table shows every pivot column up to the query's `column_limit`.

```
Stage Closed Lost Closed Won Negotiation
Region Total amount
AMER $13,966,500 ████████████ $3,903,000 ███▍ $167,500 ▏
EMEA $8,482,500 ███████▎ $1,949,500 █▋ -
APAC $3,919,500 ███▍ $1,591,500 █▍ $177,000 ▏
```

Values that cross zero get a zero axis rather than being scaled against the maximum:

```
Created At Month Mom Change
Feb 2024 2,793.82 │▇▇▇▇▇
Mar 2024 10,528.18 │▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
Apr 2024 -68.70 ▇│
Nov 2024 29,857.87 │▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
```

A chart is drawn from the query stream's field metadata. A `resultType` in the body would replace that stream with a document, so `--chart` drops it and says so. Flags that can't work are refused before any request is made; a `--chart-value` is matched against the result's columns once it arrives.

```console
$ omni query run --body @q.json --chart # body sets resultType
note: --chart ignores "resultType": "csv" and reads the query stream

$ omni query run --body @q.json --chart --format json # or OMNI_OUTPUT_FORMAT=json
Error: --chart cannot be combined with JSON output: a chart is not JSON

$ omni models list --chart
Error: --chart plots query results: use it with query run or query wait
```

Piping is fine — `omni ... --chart | less` still draws, since that JSON is auto-detected rather than asked for. Off a terminal — piped to a file, `pbcopy`, or a Slack message — the chart draws at 80 columns, which fits a code block.

| Flag | Description |
|------|-------------|
| `--chart` | Draw query results as a bar table |
| `--chart-value FIELDS` | Only these measures, by field name or label; comma-separated or repeated (default: every measure) |
| `--chart-rows N` | Most rows to draw before summarising the rest (default 50) |
| `--workbook` | Also open the query in an ephemeral workbook and print its link |

## Environment variables

| Variable | Description |
Expand Down
12 changes: 12 additions & 0 deletions cmd/omni/agent_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,18 @@ available; binary values in its JSON object are interpreted as file paths.

## Common Flags
--compact Non-indented JSON output
--chart Draw query run / query wait results as a terminal bar table:
every dimension a column, every measure a column of bars on
its own scale; a query with pivots is drawn pivoted. Leave
resultType out of the body. Cannot be combined with an
explicitly requested JSON format.
--chart-value FIELDS
Only these measures get bars, by field name or label;
comma-separated or repeated. Works on pivots too.
--chart-rows N Most rows to draw (default 50)
--workbook Also open the query in an ephemeral workbook; the link prints
under human output, or as {"workbookUrl": ...} on stderr in
JSON mode
--token TOKEN API token (overrides env/config)
--base-url URL API base URL (overrides config)
--profile NAME Config profile to use
Expand Down
172 changes: 170 additions & 2 deletions cmd/omni/main.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
package main

import (
"bytes"
"embed"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"runtime/debug"
"strings"

"github.com/exploreomni/omni-cli/internal/auth"
"github.com/exploreomni/omni-cli/internal/config"
"github.com/exploreomni/omni-cli/internal/openapi"
"github.com/exploreomni/omni-cli/internal/output"
"github.com/exploreomni/omni-cli/internal/updatecheck"
"github.com/exploreomni/omni-cli/internal/useragent"
"github.com/spf13/cobra"
Expand Down Expand Up @@ -86,6 +92,7 @@ func main() {
}

for _, cmd := range apiCmds {
addResultFlags(cmd)
root.AddCommand(cmd)
}

Expand Down Expand Up @@ -120,6 +127,143 @@ func addGlobalFlags(root *cobra.Command) {
root.PersistentFlags().StringP("format", "o", "", "output format: json, human, auto (default auto: human on TTY, json when piped)")
}

// addResultFlags registers the presentation flags on an API command group
// (not the root, so `config init --chart` is an error). Names are reserved
// in openapi.globalFlagKeys.
func addResultFlags(cmd *cobra.Command) {
f := cmd.PersistentFlags()
f.Bool("workbook", false, "also open the query in an ephemeral workbook and print its link")
f.Bool("chart", false, "draw query results as a bar chart")
f.StringSlice("chart-value", nil, "only these `fields` get bars, by field or label; comma-separated or repeated (default: every measure)")
f.Int("chart-rows", output.DefaultChartRows, "most rows to draw before summarising the rest")
}

// chartOptions reads the --chart flags; nil when no chart was asked for. An
// explicitly chosen JSON format (flag, env, config — not a pipe's auto
// detection) refuses a chart.
func chartOptions(cmd *cobra.Command, chosenFormat string) (*output.ChartOptions, error) {
if on, err := cmd.Flags().GetBool("chart"); err != nil || !on {
for _, name := range []string{"chart-value", "chart-rows"} {
if cmd.Flags().Changed(name) {
return nil, fmt.Errorf("--%s only applies with --chart", name)
}
}
return nil, nil
}
if chosenFormat == config.FormatJSON {
return nil, fmt.Errorf("--chart cannot be combined with JSON output: a chart is not JSON")
}
values, _ := cmd.Flags().GetStringSlice("chart-value")
if len(values) == 0 {
values = nil
}
rows, _ := cmd.Flags().GetInt("chart-rows")
return &output.ChartOptions{
Values: values,
Width: terminalWidth(),
MaxRows: rows,
}, nil
}

// prepareBody applies --chart (drops any resultType, with a note) and
// --workbook (sets workbookUrl) to a JSON body, touching only fields the
// command's spec declares.
func prepareBody(chart, workbook bool, format string, cmd *cobra.Command, body []byte) ([]byte, error) {
if !chart && !workbook {
return body, nil
}
if workbook && !openapi.BodyDeclares(cmd, "workbookUrl") {
return nil, fmt.Errorf("--workbook is not supported by %s", cmd.CommandPath())
}
if len(bytes.TrimSpace(body)) == 0 {
if workbook {
return nil, fmt.Errorf("--workbook needs a JSON request body to set workbookUrl on; pass one with --body or on stdin")
}
return body, nil
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil {
// Silently dropping the flag would send the request without
// workbookUrl and leave the user wondering where their link went.
if workbook {
return nil, fmt.Errorf("--workbook needs a JSON object as the request body: %w", err)
}
return body, nil
}
if isTrue(obj["planOnly"]) {
if chart {
return nil, fmt.Errorf("--chart cannot be combined with planOnly")
}
return nil, fmt.Errorf("--workbook cannot be combined with planOnly")
}
changed := false
if raw, ok := obj["resultType"]; chart && ok && openapi.BodyDeclares(cmd, "resultType") {
if format == config.FormatHuman {
fmt.Fprintf(os.Stderr, "note: --chart ignores \"resultType\": %s and reads the query stream\n", raw)
}
delete(obj, "resultType")
changed = true
}
if !workbook {
if !changed {
return body, nil
Comment thread
ernestoongaro marked this conversation as resolved.
}
filled, err := json.Marshal(obj)
if err != nil {
return nil, fmt.Errorf("preparing the request body: %w", err)
}
return filled, nil
}
if obj == nil {
return nil, fmt.Errorf("--workbook needs a JSON object as the request body")
}
obj["workbookUrl"] = json.RawMessage(`true`)
filled, err := json.Marshal(obj)
if err != nil {
return nil, fmt.Errorf("preparing the request body: %w", err)
}
return filled, nil
}

// printWorkbookLink surfaces the X-Omni-Workbook-Url header. It joins
// human-rendered output on stdout; a passed-through body (CSV, XLSX) or
// JSON output keeps stdout as the payload, so the link goes to stderr.
func printWorkbookLink(resp *http.Response, format string, compact bool, stdout, stderr io.Writer) {
u := resp.Header.Get("X-Omni-Workbook-Url")
if u == "" {
return
}
rendered := strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json")
switch {
case format == config.FormatHuman && rendered:
output.ChartLink(stdout, u)
case format == config.FormatHuman:
output.ChartLink(stderr, u)
default:
raw, _ := json.Marshal(map[string]string{"workbookUrl": u})
_ = output.JSONBytes(stderr, raw, compact)
}
}

func isTrue(raw json.RawMessage) bool {
var b bool
return json.Unmarshal(raw, &b) == nil && b
}

func terminalWidth() int {
w, _, err := term.GetSize(int(os.Stdout.Fd()))
if err != nil || w <= 0 {
return 80
}
if w < 40 {
return 40
}
if w > 160 {
return 160
}
return w
}

// executeAPICall is the callback invoked by generated commands to make the actual HTTP request.
func executeAPICall(req openapi.APIRequest) error {
cfg, err := resolveConfig(req.Cmd)
Expand All @@ -129,7 +273,21 @@ func executeAPICall(req openapi.APIRequest) error {

compact, _ := req.Cmd.Flags().GetBool("compact")
formatFlag, _ := req.Cmd.Flags().GetString("format")
format := config.ResolveOutputFormat(formatFlag, term.IsTerminal(int(os.Stdout.Fd())))
chosen := config.ChosenOutputFormat(formatFlag)
format := config.FormatFromChoice(chosen, term.IsTerminal(int(os.Stdout.Fd())))

chart, err := chartOptions(req.Cmd, chosen)
if err != nil {
return err
}
if chart != nil && !openapi.ReturnsStream(req.Cmd) {
return fmt.Errorf("--chart plots query results: use it with query run or query wait")
}
workbook, _ := req.Cmd.Flags().GetBool("workbook")
req.Body, err = prepareBody(chart != nil, workbook, format, req.Cmd, req.Body)
if err != nil {
return err
}

// Show a spinner on stderr while the request is in flight. Only when the
// user is at an interactive terminal AND they're going to see human output;
Expand All @@ -143,7 +301,17 @@ func executeAPICall(req openapi.APIRequest) error {
}
defer resp.Body.Close()

err = outputResponse(resp, format, compact)
// A query stream rendered for a person is decoded — labels, formats,
// dimensions from the model — and waited on to completion. For JSON it
// passes through untouched, as the output contract promises.
if isQueryStream(resp) && (chart != nil || format == config.FormatHuman) {
err = renderStream(cfg, resp, format, compact, chart, os.Stdout, os.Stderr)
} else {
err = outputResponse(resp, format, compact, chart)
if err == nil {
printWorkbookLink(resp, format, compact, os.Stdout, os.Stderr)
}
}
var apiErr *apiError
if errors.As(err, &apiErr) {
// outputResponse already wrote a complete error message to stderr —
Expand Down
Loading
Loading