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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,9 @@ Config directory is resolved as: `OMNI_CONFIG_DIR` > `XDG_CONFIG_HOME/omni-cli`
## Output

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).

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 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).
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,20 @@ Config file lives at `~/.config/omni-cli/config.json`.

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

Failures write nothing to stdout — the API's error body, the error message, and any subcommand suggestions go to stderr, and the exit code is non-zero. An empty stdout therefore always means "no data", which keeps `omni ... | jq` from choking on error JSON.

A failed API call leaves exactly one JSON document on stderr, so `omni ... 2>err.json` stays parseable:

```json
{
"error": "bad model id",
"status": 400,
"body": { "detail": "bad model id", "code": "INVALID" }
}
```

`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.

## Environment variables

| Variable | Description |
Expand Down
15 changes: 15 additions & 0 deletions cmd/omni/agent_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ const agentHelpText = `# Omni CLI — Agent Guide
All output is JSON to stdout. Errors are JSON to stderr.
Use --compact for non-indented output (good for piping to jq).

## Streams and exit codes
On failure nothing is written to stdout — the API's error body, the error
message, and any suggestions all go to stderr, and the exit code is non-zero.
So an empty stdout always means "no data", never "parse this".

A failed API call leaves exactly one JSON document on stderr:
{"error": "<message>", "status": 400, "body": {<the API's payload>}}
"body" is omitted when the response wasn't JSON. A successful response that
isn't JSON (query run streams text/ndjson) passes through to stdout unchanged.

A group with no subcommand ("omni models") prints its help to stderr and exits
1; an unrecognized subcommand ("omni models list-branches") is an error with
suggestions, with or without --help. Use "omni <group> --help" to see the real
subcommand names.

## Auth
Set OMNI_API_TOKEN env var, or run: omni config init

Expand Down
3 changes: 3 additions & 0 deletions cmd/omni/branch_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ func createBranchCmd(exec openapi.Executor) *cobra.Command {
Long: "Create a new branch of an existing model. The model-id is the base model to branch from.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Runtime failures past this point shouldn't drag the usage block along.
cmd.SilenceUsage = true

baseModelID := args[0]
name, _ := cmd.Flags().GetString("name")

Expand Down
34 changes: 30 additions & 4 deletions cmd/omni/config_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

"github.com/exploreomni/omni-cli/internal/config"
"github.com/exploreomni/omni-cli/internal/oauth"
"github.com/exploreomni/omni-cli/internal/openapi"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"golang.org/x/term"
Expand All @@ -25,10 +26,8 @@ func applyOAuthToken(p *config.Profile, tok *oauth2.Token) {
}

func addConfigCommands(root *cobra.Command) {
configCmd := &cobra.Command{
Use: "config",
Short: "Manage CLI configuration profiles",
}
// Same unknown-subcommand handling as the generated groups.
configCmd := openapi.NewGroupCommand("config", "Manage CLI configuration profiles")
Comment on lines +29 to +30

configCmd.AddCommand(configInitCmd())
configCmd.AddCommand(configShowCmd())
Expand Down Expand Up @@ -68,6 +67,9 @@ accepted as a flag, so it can't leak into shell history.`,
# API key (prompts securely for the key)
omni config init --name prod --endpoint https://myorg.omniapp.co --auth api-key`,
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

reader := bufio.NewReader(os.Stdin)

if !cmd.Flags().Changed("name") {
Expand Down Expand Up @@ -163,6 +165,9 @@ func configShowCmd() *cobra.Command {
Use: "show",
Short: "Display current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("no config found at %s — run `omni config init`", config.ConfigPath())
Expand Down Expand Up @@ -198,6 +203,9 @@ func configSetFormatCmd() *cobra.Command {
Short: "Set the default output format",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

format := strings.ToLower(strings.TrimSpace(args[0]))
if !config.ValidOutputFormat(format) {
return fmt.Errorf("invalid format %q — must be one of: json, human, auto", args[0])
Expand Down Expand Up @@ -228,6 +236,9 @@ func configUseCmd() *cobra.Command {
Long: "Switch the default profile.\n\nIf the profile name contains spaces, quote it: `omni config use \"My Profile\"`. Run `omni config list` to see profile names.",
Args: profileNameArgs(1, 1),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("no config found — run `omni config init`")
Expand Down Expand Up @@ -285,6 +296,9 @@ func configListCmd() *cobra.Command {
Short: "List configured profiles",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("no config found — run `omni config init`")
Expand Down Expand Up @@ -317,6 +331,9 @@ func configRenameCmd() *cobra.Command {
Short: "Rename a profile",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

oldName, newName := args[0], args[1]

cfg, err := config.Load()
Expand Down Expand Up @@ -355,6 +372,9 @@ func configDeleteCmd() *cobra.Command {
Short: "Delete a profile",
Args: profileNameArgs(1, 1),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

name := args[0]
cfg, err := config.Load()
if err != nil {
Expand Down Expand Up @@ -396,6 +416,9 @@ func configLoginCmd() *cobra.Command {
Short: "Log in via OAuth browser flow",
Args: profileNameArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("no config found — run `omni config init` first")
Expand Down Expand Up @@ -444,6 +467,9 @@ func configLogoutCmd() *cobra.Command {
Short: "Clear OAuth tokens from a profile",
Args: profileNameArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
// Args are validated: failures below are runtime errors, not usage errors.
cmd.SilenceUsage = true

cfg, err := config.Load()
if err != nil {
return fmt.Errorf("no config found — run `omni config init` first")
Expand Down
44 changes: 44 additions & 0 deletions cmd/omni/config_commands_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"io"
"os"
"path/filepath"
Expand All @@ -9,6 +10,7 @@ import (
"time"

"github.com/exploreomni/omni-cli/internal/config"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
)

Expand Down Expand Up @@ -81,6 +83,48 @@ func TestApplyOAuthToken_CopiesAllFields(t *testing.T) {
}
}

// Runtime failures in the hand-written config commands must not dump the usage
// block: same contract the generated API commands follow, so an error message
// isn't buried under a wall of flags.
func TestConfigCommands_NoUsageOnRuntimeError(t *testing.T) {
// An empty (absent) config file makes every command that loads config fail.
withConfig(t, nil)

cases := []struct {
name string
cmd func() *cobra.Command
args []string
}{
{"init", configInitCmd, []string{"--name", "prod", "--endpoint", "https://myorg.omniapp.co", "--auth", "magic"}},
{"show", configShowCmd, nil},
{"list", configListCmd, nil},
{"use", configUseCmd, []string{"ghost"}},
{"rename", configRenameCmd, []string{"ghost", "other"}},
{"delete", configDeleteCmd, []string{"ghost", "--yes"}},
{"login", configLoginCmd, []string{"ghost"}},
{"logout", configLogoutCmd, []string{"ghost"}},
{"set-format", configSetFormatCmd, []string{"yaml"}},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
cmd := tc.cmd()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SilenceErrors = true
cmd.SetArgs(tc.args)

if err := cmd.Execute(); err == nil {
t.Fatalf("expected a runtime error for %s", tc.name)
}
if strings.Contains(out.String(), "Usage:") {
t.Errorf("output = %q, want no usage block after a runtime error", out.String())
}
})
}
}

// --- config init (non-interactive flags) ---
//
// Note: there is deliberately no --api-key flag. Accepting a secret on the
Expand Down
19 changes: 17 additions & 2 deletions cmd/omni/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"embed"
"errors"
"fmt"
"os"
"runtime/debug"
Expand Down Expand Up @@ -81,7 +82,12 @@ func main() {
addBranchCommands(root, executeAPICall)
addUserCommands(root, executeAPICall)

if err := root.Execute(); err != nil {
// ExecuteC, not Execute: cobra returns a nil error whenever it answers the
// help flag, including for `omni models list-branches --help`, where the
// "help" is really an unknown-subcommand error. UnknownSubcommand asks the
// command that ran whether that's what happened.
cmd, err := root.ExecuteC()
if err != nil || openapi.UnknownSubcommand(cmd) {
os.Exit(1)
}
}
Expand Down Expand Up @@ -124,7 +130,16 @@ func executeAPICall(req openapi.APIRequest) error {
}
defer resp.Body.Close()

return outputResponse(resp, format, compact)
err = outputResponse(resp, format, compact)
var apiErr *apiError
if errors.As(err, &apiErr) {
// outputResponse already wrote a complete error message to stderr —
// a JSON envelope carrying the status, or the human-mode one-liner.
// Letting cobra append its own line would say it twice, and would
// leave two documents on stderr for JSON consumers to trip over.
req.Cmd.SilenceErrors = true
}
return err
}

// resolveConfig builds the runtime config from flags, env, and config file.
Expand Down
Loading
Loading